YouCompleteMe插件的一些实现
一、vim对python脚本的支持
vim作为一个开发环境,不仅支持原生的vim脚本,还支持其它的动态脚本语言,例如lua、ruby、perl、python等。这些脚本语言在vim的源代码中都是通过if_XXX型文件实现。具体对于python的支持来说,实现在if_python.h、if_python3.c中。
vim一个流行的自动补全插件YouCompleteMe就是使用了python扩展功能。所以,如果要使用YouCompleteMe这个插件的话,vim在构建的时候需要支持python。可以通过--version命令行选项列出vim支持的特性、构建时使用的参数等信息。
二、YouCompleteMe脚本对python的使用
1、YouCompleteMe使用的自动加载模块
根据vim的规定,自动运行的脚本放在autoload文件夹中,YouCompleteMe使用的自动运行脚本一些相关的代码在下面摘录出来。
可以看到,python代码通过import vim来导入vim模块,并通过该模块和vim实时通讯。
YouCompleteMe/autoload/youcompleteme.vim
22 " This needs to be called outside of a function
23 let s:script_folder_path = escape( expand( '<sfile>:p:h' ), '\' )
……
53 function! s:UsingPython3()
54 if has('python3')
55 return 1
56 endif
57 return 0
58 endfunction
59
60
61 let s:using_python3 = s:UsingPython3()
62 let s:python_until_eof = s:using_python3 ? "python3 << EOF" : "python << EOF"
63 let s:python_command = s:using_python3 ? "py3 " : "py "
……
151 function! s:SetUpPython() abort
152 exec s:python_until_eof
153 from __future__ import unicode_literals
154 from __future__ import print_function
155 from __future__ import division
156 from __future__ import absolute_import
157
158 import os
159 import sys
160 import traceback
161 import vim
162
163 # Add python sources folder to the system path.
164 script_folder = vim.eval( 's:script_folder_path' )
165 sys.path.insert( 0, os.path.join( script_folder, '..', 'python' ) )
166
167 from ycm.setup import SetUpSystemPaths, SetUpYCM
168
169 # We enclose this code in a try/except block to avoid backtraces in Vim.
170 try:
171 SetUpSystemPaths()
172
173 # Import the modules used in this file.
174 from ycm import base, vimsupport
175
176 ycm_state = SetUpYCM()
177 except Exception as error:
178 # We don't use PostVimMessage or EchoText from the vimsupport module because
179 # importing this module may fail.
180 vim.command( 'redraw | echohl WarningMsg' )
181 for line in traceback.format_exc().splitlines():
182 vim.command( "echom '{0}'".format( line.replace( "'", "''" ) ) )
183
184 vim.command( "echo 'YouCompleteMe unavailable: {0}'"
185 .format( str( error ).replace( "'", "''" ) ) )
186 vim.command( 'echohl None' )
187 vim.command( 'return 0' )
188 else:
189 vim.command( 'return 1' )
190 EOF
191 endfunction
……
831 function! s:CompleterCommand(...)
832 " CompleterCommand will call the OnUserCommand function of a completer.
833 " If the first arguments is of the form "ft=..." it can be used to specify the
834 " completer to use (for example "ft=cpp"). Else the native filetype completer
835 " of the current buffer is used. If no native filetype completer is found and
836 " no completer was specified this throws an error. You can use
837 " "ft=ycm:ident" to select the identifier completer.
838 " The remaining arguments will be passed to the completer.
839 let arguments = copy(a:000)
840 let completer = ''
841
842 if a:0 > 0 && strpart(a:1, 0, 3) == 'ft='
843 if a:1 == 'ft=ycm:ident'
844 let completer = 'identifier'
845 endif
846 let arguments = arguments[1:]
847 endif
848
849 exec s:python_command "ycm_state.SendCommandRequest(" .
850 \ "vim.eval( 'l:arguments' ), vim.eval( 'l:completer' ) )"
851 endfunction
2、如何获得插件使用的python文件目录
按照python的规定,如果希望一个文件夹作为python模块的搜索路径,需要将它追加到sys.path列表中。
在autoload脚本中,将该vim脚本本身(YouCompleteMe/autoload/youcompleteme.vim)所在路径的"../python"文件夹添加到sys.path中,从而该文件夹下的所有python脚本(模块)都可以在python代码中导入和使用。
163 # Add python sources folder to the system path.
164 script_folder = vim.eval( 's:script_folder_path' )
165 sys.path.insert( 0, os.path.join( script_folder, '..', 'python' ) )
其实也就是YouCompleteMe/python/文件夹,下面是插件文件夹结构
tsecer@harry: tree -L 2 YouCompleteMe
YouCompleteMe
├── appveyor.yml
├── autoload
│ └── youcompleteme.vim
├── ci
│ ├── appveyor
│ └── travis
├── codecov.yml
├── CODE_OF_CONDUCT.md
├── CONTRIBUTING.md
├── COPYING.txt
├── doc
│ └── youcompleteme.txt
├── install.py
├── install.sh
├── plugin
│ └── youcompleteme.vim
├── print_todos.sh
├── python
│ ├── test_requirements.txt
│ └── ycm
├── README.md
├── run_tests.py
├── third_party
│ ├── pythonfutures
│ ├── requests-futures
│ └── ycmd
└── tox.ini
12 directories, 15 files
tsecer@harry:
3、使用python脚本
将插件的python文件夹添加到sys.path之后,就可以让python从这些文件夹下搜索模块(module)。例如,
167 from ycm.setup import SetUpSystemPaths, SetUpYCM
就是从YouCompleteMe/python/ycm/setup.py文件中导入SetUpSystemPaths和SetUpYCM函数。
而SetUpYCM函数返回的对象(名字为ycm_state)
176 ycm_state = SetUpYCM()
是之后vim脚本操作的“句柄”变量,也就是之后的大部分操作都是通过该对象的方法来完成。
167 from ycm.setup import SetUpSystemPaths, SetUpYCM
168
169 # We enclose this code in a try/except block to avoid backtraces in Vim.
170 try:
171 SetUpSystemPaths()
172
173 # Import the modules used in this file.
174 from ycm import base, vimsupport
175
176 ycm_state = SetUpYCM()
三、ycm_state的由来
从前面可以看到,ycm_state主要由setup.py中的SetUpYCM函数返回。
在YouCompleteMe/python/ycm/setup.py文件中,可以看到函数实现为
44 def SetUpYCM():
45 from ycm import base
46 from ycmd import user_options_store
47 from ycm.youcompleteme import YouCompleteMe
48
49 base.LoadJsonDefaultsIntoVim()
50
51 user_options_store.SetAll( base.BuildServerConf() )
52
53 return YouCompleteMe( user_options_store.GetAll() )
也即是返回的是一个YouCompleteMe对象。
四、server相关
1、server的启动
在执行YouCompleteMe类的构造函数时,会通过_SetupServer函数启动server
YouCompleteMe/python/ycm/youcompleteme.py
135 def _SetupServer( self ):
136 self._available_completers = {}
137 self._user_notified_about_crash = False
138 self._filetypes_with_keywords_loaded = set()
139 self._server_is_ready_with_cache = False
140
141 hmac_secret = os.urandom( HMAC_SECRET_LENGTH )
142 options_dict = dict( self._user_options )
143 options_dict[ 'hmac_secret' ] = utils.ToUnicode(
144 base64.b64encode( hmac_secret ) )
145 options_dict[ 'server_keep_logfiles' ] = self._user_options[
146 'keep_logfiles' ]
147
148 # The temp options file is deleted by ycmd during startup.
149 with NamedTemporaryFile( delete = False, mode = 'w+' ) as options_file:
150 json.dump( options_dict, options_file )
151
152 server_port = utils.GetUnusedLocalhostPort()
153
154 BaseRequest.server_location = 'http://127.0.0.1:' + str( server_port )
155 BaseRequest.hmac_secret = hmac_secret
156
157 try:
158 python_interpreter = paths.PathToPythonInterpreter()
159 except RuntimeError as error:
160 error_message = (
161 "Unable to start the ycmd server. {0}. "
162 "Correct the error then restart the server "
163 "with ':YcmRestartServer'.".format( str( error ).rstrip( '.' ) ) )
164 self._logger.exception( error_message )
165 vimsupport.PostVimMessage( error_message )
166 return
167
168 args = [ python_interpreter,
169 paths.PathToServerScript(),
170 '--port={0}'.format( server_port ),
171 '--options_file={0}'.format( options_file.name ),
172 '--log={0}'.format( self._user_options[ 'log_level' ] ),
173 '--idle_suicide_seconds={0}'.format(
174 SERVER_IDLE_SUICIDE_SECONDS ) ]
175
176 self._server_stdout = utils.CreateLogfile(
177 SERVER_LOGFILE_FORMAT.format( port = server_port, std = 'stdout' ) )
178 self._server_stderr = utils.CreateLogfile(
179 SERVER_LOGFILE_FORMAT.format( port = server_port, std = 'stderr' ) )
180 args.append( '--stdout={0}'.format( self._server_stdout ) )
181 args.append( '--stderr={0}'.format( self._server_stderr ) )
182
183 if self._user_options[ 'keep_logfiles' ]:
184 args.append( '--keep_logfiles' )
185
186 self._server_popen = utils.SafePopen( args, stdin_windows = PIPE,
187 stdout = PIPE, stderr = PIPE )
2、server端口的选择
从实现上看,操作系统比较清楚哪些端口没有使用。所以这里其实是让操作系统自动选择一个没有使用的端口地址。
YouCompleteMe/third_party/ycmd/ycmd/utils.py
193 def GetUnusedLocalhostPort():
194 sock = socket.socket()
195 # This tells the OS to give us any free port in the range [1024 - 65535]
196 sock.bind( ( '', 0 ) )
197 port = sock.getsockname()[ 1 ]
198 sock.close()
199 return port
3、server根据文件类型确定completer的逻辑
主要就是到ycmd/${filetype}/hook.py创建文件,其中的${filetype}替换为具体的、运行时文件名。当然,一些特殊文件夹不需要,例如general文件夹下的completer,它们作为基本的、常驻的completer,是静态访问的。
third_party/ycmd/ycmd/completers/completer_utils.py
162 def PathToFiletypeCompleterPluginLoader( filetype ):
163 return os.path.join( _PathToCompletersFolder(), filetype, 'hook.py' )
164
165
166 def FiletypeCompleterExistsForFiletype( filetype ):
167 return os.path.exists( PathToFiletypeCompleterPluginLoader( filetype ) )
例如,ycmd中支持的文件夹包括了常见的文件类型,例如cpp类型文件就是用cpp文件夹下的hook.py创建completer对象。
tsecer@harry: find . -type d
.
./general
./cs
./objcpp
./typescript
./all
./go
./python
./rust
./objc
./cpp
./javascript
./__pycache__
./c
五、vim请求的发送
1、发送请求的基本内容
可以看到,主要包括当前文件路径,输入位置的行号和列号,当前编辑buffer的内容(包括没有写回文件的内容)。
YouCompleteMe/python/ycm/client/base_request.py
155 def BuildRequestData( filepath = None ):
156 """Build request for the current buffer or the buffer corresponding to
157 |filepath| if specified."""
158 current_filepath = vimsupport.GetCurrentBufferFilepath()
159
160 if filepath and current_filepath != filepath:
161 # Cursor position is irrelevant when filepath is not the current buffer.
162 return {
163 'filepath': filepath,
164 'line_num': 1,
165 'column_num': 1,
166 'file_data': vimsupport.GetUnsavedAndSpecifiedBufferData( filepath )
167 }
168
169 line, column = vimsupport.CurrentLineAndColumn()
170
171 return {
172 'filepath': current_filepath,
173 'line_num': line + 1,
174 'column_num': column + 1,
175 'file_data': vimsupport.GetUnsavedAndSpecifiedBufferData( current_filepath )
176 }
2、文件类型的同步
类型是客户端通过vim的ft变量获得,然后传递ycmd进程的。
YouCompleteMe/python/ycm/vimsupport.py
123 def GetUnsavedAndSpecifiedBufferData( including_filepath ):
124 """Build part of the request containing the contents and filetypes of all
125 dirty buffers as well as the buffer with filepath |including_filepath|."""
126 buffers_data = {}
127 for buffer_object in vim.buffers:
128 buffer_filepath = GetBufferFilepath( buffer_object )
129 if not ( BufferModified( buffer_object ) or
130 buffer_filepath == including_filepath ):
131 continue
132
133 buffers_data[ buffer_filepath ] = {
134 # Add a newline to match what gets saved to disk. See #1455 for details.
135 'contents': JoinLinesAsUnicode( buffer_object ) + '\n',
136 'filetypes': FiletypesForBuffer( buffer_object )
137 }
138
139 return buffers_data
……
599 def FiletypesForBuffer( buffer_object ):
600 # NOTE: Getting &ft for other buffers only works when the buffer has been
601 # visited by the user at least once, which is true for modified buffers
602 return GetBufferOption( buffer_object, 'ft' ).split( '.' )
六、ycmd的内置completer
1、identifier completer
对于常见的C++,使用clang作为语义分析并完成智能提示。但是如果没有clang支持,ycmd也有内置的identifier提示。这个提示功能相对比较朴素,就是对文件的内容进行基于“正则表达式”的拆分,从文件中拆分出不同的单词(可能还有其它的特殊结构,例如C语言中的注释等)。
identifier completer需要ycm_core.so文件的支持。
YouCompleteMe\third_party\ycmd\ycmd\identifier_utils.py
30 C_STYLE_COMMENT = "/\*(?:\n|.)*?\*/"
31 CPP_STYLE_COMMENT = "//.*?$"
32 PYTHON_STYLE_COMMENT = "#.*?$"
……
101 # At least c++ and javascript support unicode identifiers, and identifiers may
102 # start with unicode character, e.g. ålpha. So we need to accept any identifier
103 # starting with an 'alpha' character or underscore. i.e. not starting with a
104 # 'digit'. The following regex will match:
105 # - A character which is alpha or _. That is a character which is NOT:
106 # - a digit (\d)
107 # - non-alphanumeric
108 # - not an underscore
109 # (The latter two come from \W which is the negation of \w)
110 # - Followed by any alphanumeric or _ characters
111 DEFAULT_IDENTIFIER_REGEX = re.compile( r"[^\W\d]\w*", re.UNICODE )
……
183 def ExtractIdentifiersFromText( text, filetype = None ):
184 return re.findall( IdentifierRegexForFiletype( filetype ), text )
如果指定文件类型的completer没有返回任何完成选项(completion是completer返回的可选集合结果:if not completions),则使用默认的completer(_server_state.GetGeneralCompleter().ComputeCandidates( request_data ) )。
88 @app.post( '/completions' )
89 def GetCompletions():
90 _logger.info( 'Received completion request' )
91 request_data = RequestWrap( request.json )
92 ( do_filetype_completion, forced_filetype_completion ) = (
93 _server_state.ShouldUseFiletypeCompleter( request_data ) )
94 _logger.debug( 'Using filetype completion: %s', do_filetype_completion )
95
96 errors = None
97 completions = None
98
99 if do_filetype_completion:
100 try:
101 completions = ( _server_state.GetFiletypeCompleter(
102 request_data[ 'filetypes' ] )
103 .ComputeCandidates( request_data ) )
104
105 except Exception as exception:
106 if forced_filetype_completion:
107 # user explicitly asked for semantic completion, so just pass the error
108 # back
109 raise
110 else:
111 # store the error to be returned with results from the identifier
112 # completer
113 stack = traceback.format_exc()
114 _logger.error( 'Exception from semantic completer (using general): ' +
115 "".join( stack ) )
116 errors = [ BuildExceptionResponse( exception, stack ) ]
117
118 if not completions and not forced_filetype_completion:
119 completions = ( _server_state.GetGeneralCompleter()
120 .ComputeCandidates( request_data ) )
121
122 return _JsonResponse(
123 BuildCompletionResponse( completions if completions else [],
124 request_data[ 'start_column' ],
125 errors = errors ) )
而对于通用completer来说,它们之间没有互斥关系,一旦触发,所有的completer都会被执行。可以看到流程是循环追加的模式。
另外也可以看到,它会将编辑过程中输入的标识符(OnInsertLeave)也加入到选项集合中,并且没有删除。这也意味着只要输入过某个标识符,即使之后被删除,它依然会存在于可选集中。
YouCompleteMe/third_party/ycmd/ycmd/completers/general/general_completer_store.py
82 def ComputeCandidates( self, request_data ):
83 if not self.ShouldUseNow( request_data ):
84 return []
85
86 candidates = []
87 for completer in self._current_query_completers:
88 candidates += completer.ComputeCandidates( request_data )
89
90 return candidates
……
172 def OnInsertLeave( self, request_data ):
173 self._AddIdentifierUnderCursor( request_data )
174
175
176 def OnCurrentIdentifierFinished( self, request_data ):
177 self._AddPreviousIdentifier( request_data )
2、如何识别一个identifier输入完成
这个在ycm的客户端侧做的判断。当输入一个字符时,通过CurrentIdentifierFinished函数判断当前identifier是否输入完成。如何判断是一个identifier这个还是跟文件类型有关,并且和ycmd使用的文件内标识符识别正则表达式模式相同(前一个小段中的DEFAULT_IDENTIFIER_REGEX变量)。
YouCompleteMe/python/ycm/base.py
28 from ycmd import identifier_utils
……
59 def CurrentIdentifierFinished():
60 line, current_column = vimsupport.CurrentLineContentsAndCodepointColumn()
61 previous_char_index = current_column - 1
62 if previous_char_index < 0:
63 return True
64 filetype = vimsupport.CurrentFiletypes()[ 0 ]
65 regex = identifier_utils.IdentifierRegexForFiletype( filetype )
66
67 for match in regex.finditer( line ):
68 if match.end() == previous_char_index:
69 return True
70 # If the whole line is whitespace, that means the user probably finished an
71 # identifier on the previous line.
72 return line[ : current_column ].isspace()
3、C语言的提示
completer下只有CPP的文件夹而没有C文件夹,对于C文件是如何处理的呢?
这个其实同样是有cpp文件夹下的clang支持
YouCompleteMe/third_party/ycmd/ycmd/completers/cpp/clang_completer.py
46 CLANG_FILETYPES = set( [ 'c', 'cpp', 'objc', 'objcpp' ] )
……
70 def SupportedFiletypes( self ):
71 return CLANG_FILETYPES
4、文件名的提示
在使用YouCompleteMe的时候,还可以发现ycm还是支持文件名自动匹配的。查看ycm的实现,可以看到completer中有一个filename的完成器,顾名思义,它应该就是对文件名进行自动完成的工具了。
可以看到,其中是通过路径分隔符来触发自动完成的,所以,即使在C++中代码中输入了除法符号(/),也会触发当做根目录下的文件名匹配。
YouCompleteMe/third_party/ycmd/ycmd/completers/general/filename_completer.py
42 def __init__( self, user_options ):
43 super( FilenameCompleter, self ).__init__( user_options )
44
45 # On Windows, backslashes are also valid path separators.
46 self._triggers = [ '/', '\\' ] if OnWindows() else [ '/' ]
……
75 def ShouldUseNowInner( self, request_data ):
76 current_line = request_data[ 'line_value' ]
77 start_codepoint = request_data[ 'start_codepoint' ]
78
79 # inspect the previous 'character' from the start column to find the trigger
80 # note: 1-based still. we subtract 1 when indexing into current_line
81 trigger_codepoint = start_codepoint - 1
82
83 return ( trigger_codepoint > 0 and
84 current_line[ trigger_codepoint - 1 ] in self._triggers )
但是使用ycm的时候,在输入
#include ""
的双引号内部,也会触发文件名的自动完成,但是这个是clang完成的。验证的方法是把completer文件夹下的cpp重命名,之后发现#include内部的文件名完成失效,但是使用"./"还是会触发文件名匹配。
七、vim内置的completer
runtime\autoload\README.txt
Omni completion files:
ccomplete.vim C
csscomplete.vim HTML / CSS
htmlcomplete.vim HTML
javascriptcomplete.vim Javascript
phpcomplete.vim PHP
pythoncomplete.vim Python
rubycomplete.vim Ruby
syntaxcomplete.vim from syntax highlighting
xmlcomplete.vim XML (uses files in the xml directory)
在文件类型插件中,如果识别使用的C语言,则设置默认的omnifunction为内置的ccomplete#Complete,也就是ccomplete.vim中的Complete函数。其它一些常用的,例如python也有类似的机制。当使用这种机制的时候,通过ctrl-x ctrl-o就可以打开并使用内置的自动完成功能。
runtime\ftplugin\c.vim
" Set completion with CTRL-X CTRL-O to autoloaded function.
if exists('&ofu')
setlocal ofu=ccomplete#Complete
endif
关于vim内置的omnifunc及自动完成功能,可以通过vim内置的
:h ins-completion
:h omnifunc
查看相关帮助手册。
八、一些总结
关键的是文件类型,也就是当前文件的ft信息,如果ft为空,通常所有的智能提示都会失效。
例如,在cmd中执行
:set ft=
之后,几乎所有的只能提示都会失效。