忙里偷闲打磨代码编辑环境
1、背景
-
日常工作在
Linux
下。自己写的代码就用VIM
加少量插件, 原因详见这篇文章。 -
查看别人的代码就使用易用性更强的编辑器甚至专门的集成开发环境, 从以前的
Source Insight
(需借助Wine
)、KDevelop
、Eclipse
, 到新近的Visual Studio Code
(后文简称VSCode
)。 -
最近在阅读较新版本的
Linux
内核源码时,VSCode
解析起来不仅一片飘红、满屏波浪线, 而且符号预览窗口、代码悬浮提示久久刷新不出内容,函数跳转也不正确。 查看一下系统资源消耗情况,发现某个CPU核占用100%,并且是一核有难、八核围观
的经典现象, 加上此前也在网上看到有人抱怨Linux
版本的VSCode
时不时出现一些奇怪的问题, 不如Windows
版本的稳定和好用,于是考虑直接使用VIM
能否胜任如此庞大的项目的解析工作。 -
此前使用
VIM
时,主要倚靠YouCompleteMe
插件(后文简称YCM
), 但该插件擅长代码语法检查和补全,函数跳转只有半桶水的水平(后文详述原因), 也无法解析一个函数的引用情况(与上一个原因有一定关系),所以并非正确答案, 惟有把目光转向经典的ctags
、cscope
(原因还是前述那篇文章)。 -
花一些时间查看这两个工具的
man
手册、上网搜一些相关的文章、再做一些小测试, 觉得此路可通,于是正式动工,花了几天时间,一边细化需求并随时按需调整, 一边写VIM
脚本并反复测试,力求在实现功能的基础上使用顺手、内容简洁易懂、形式通用 (不然也不至于要花费几天)。
2、需求梳理
2.1 核心需求
-
代码补全仍使用
YCM
,因为太方便太好用;函数跳转仍然优先使用YCM
, 因为YCM
具备语法分析的能力,能跳转当然是最准确的结果,跳转不了再使用降级方案。 -
cscope
比ctags
功能丰富(例如可查找一个函数的所有引用位置),且在部分场合下跳转的准确性也比ctags
高, 故可作为次优方案。 -
ctags
宜作为兜底方案,可能是因为它的初衷或实现机制而导致适合用来做非精确查找, 实测中也出现过本应跳到函数定义却跳到同名的结构体字段。对比之下,cscope
是“宁缺勿滥”,ctags
则是“宁杀错莫放过”。 -
由于
cscope
和ctags
都需要先生成自己的数据库才能工作,所以要配备按需刷新数据库的功能。 -
按键映射、命令操作要简单顺手!!
2.2、进阶需求
- 不做大包大揽式的单一项目,而是制作模块化、既可单独使用也可互相配合的小单元,
强调在已有的组件和功能之上进行整合、增强:
- 若某个功能点在原插件已有且好用,则沿用。
- 若未有,或不好用,则添加或修改。
-
由于这些脚本是打算公开、通用化的,但每个人的口味和习惯又千差万别,所以需要可配置、 可动态加载/卸载(如同
Linux
模块驱动)。 - 展示形式要直观。
3、YouCompleteMe
的增强
3.1 确认其确实不支持跨文件的函数跳转
很重要!因为若是旧版本不支持而新版本支持,或者虽未支持但近期已有计划或进展, 那么我也就没有写这些脚本以及这篇文章的理由了!
确认的过程无需细说,直接说结果。在YCM
的官方GitHub
有这么一项议题
(issue
,顺便推一份觉得不错的Git术语翻译,
若链接失效可点击此处查看备份文档),
标题为(C/C++) GoToDefinition cannot jump to source file in different directory
,
首次提问的时间是2013年12月份,关键回复如下:
For C-family languages [GoToDefinition] only works in certain situations, namely when the definition of the symbol is in the current translation unit. A translation unit consists of the file you are editing and all the files you are including with #include directives (directly or indirectly) in that file.
大概意思是由于类C语言的的翻译单元
机制的限制,YCM
的GoToDefinition
子命令当前并不支持跨文件的函数定义跳转。
而当提问者询问是否考虑实现这样的特性时,回复如下:
Maybe some time in the future, but not any time soon; it’s difficult to implement well.
看来难度很大,短期内无望。很快时间一晃就到2018年,又有一个用户来询问, 这次的回复是这样的:
No, but once clangd gets this ability, it will be in YCM. For now you can jump to declaration and then open the corresponding source file and find the definition. Or you can get clever with your extra conf and automate that process.
透露了一个很重要的信息,即YCM
的背后依赖的是clangd
,但如果clangd
无此能力,
YCM
也彻底躺平了,不过聪明的用户可以考虑手动操作或者利用YCM
配置文件(即.ycm_extra_conf.py
,
实质上是一个Pythonn
脚本,理论上可玩出无数种花样)搞搞新意思……
正身处2023年的我正准备发一句“2023 now! Any progress?
”,却发现评论区已关闭……
往好的方面想,写Bug脚本和水发文算是师出有名了。
3.2 实现核心需求:封装一个跳转函数并映射到原有按键
函数核心逻辑如下:
function GoToDefinitionIfPossible()
" let l:msg = execute('YcmCompleter GoToDefinition', '')
let l:msg = execute('YcmCompleter GoToDefinitionElseDeclaration', '')
if '' == l:msg
return
endif
let l:first_line = split(l:msg, '\n')[0]
if match(first_line, '^RuntimeError') < 0
return
endif
if match(first_line, 'Still parsing') >= 0
return
endif
if exists('*GoToDefinitionViaCscopeOrCtags')
call GoToDefinitionViaCscopeOrCtags()
endif
endfunction
简单地说,就是执行YCM
的GoToDefinitionElseDeclaration
子命令,捕获其输出结果并进行判断,
若能成功跳转则皆大欢喜,否则就交由cscope
来作进一步处理,即调用GoToDefinitionViaCscopeOrCtags()
,
该函数的实现详见后面的cscope
章节。值得一提的是,应执行的YCM
子命令原本是GoToDefinition
,
但由于想在YCM
跳转失败时能有所察觉并顺便知道函数声明的位置,
而GoToDeclaration
子命令又没想好与哪个顺手又好记的键进行绑定,
所以就暂时这样安排。
至于快捷键映射,则由原来的:
nnoremap <Leader>d :YcmCompleter GoToDefinitionElseDeclaration<CR>
改为:
nnoremap <Leader>d :call GoToDefinitionIfPossible()<CR>
其中<Leader>
在Linux
下默认为反斜线(\
)。并且,由于动态加载/卸载的需求,
这个语句也不会写得这么直接,详见后文。
3.3 实现进阶需求:动态加载/卸载
所谓加载,就是将一个VIM
脚本导入到当前编辑环境,便可使用里面的全局变量、函数和按键映射等,
与Shell
的source
命令作用一样且同名。卸载则相反,将以上这些抹除掉或回复原值/状态。
所谓动态则是指不限于用VIM
打开一个新窗口读入配置文件(vimrc
)时仅加载一次,
而是在运行期间可以根据需要反复加载和卸载。但这个特性的重点并不是让人闲得无聊而反复加载卸载,
而是将脚本逻辑打包在一起,方便用户在需要之时导入使用,不需要则禁用,从而不与其他配置冲突,
而这个特性是很多开源插件所忽略的。尤其是当用户本来已有自己的一套配置,
又想从一个开源项目获取自己缺失的一部分功能,同时又用不到其余一部分功能,
且那部分功能的某些快捷键与自己的有冲突,这个时候就会很想禁掉一部分功能了,
这就是动态加载/卸载的意义所在!
动态加载/卸载的核心逻辑如下:
function EnableYcmConfig()
call s:apply_global_variables(1)
call s:save_old_key_mappings()
call s:apply_new_key_mappings()
let s:YCM_CONF_DISABLED = 0
endfunction
function DisableYcmConfig()
let s:YCM_CONF_DISABLED = 1
call s:apply_global_variables(0)
call s:restore_old_key_mappings()
endfunction
逻辑非常简单,两个函数及其调用的子函数也是见名知意,以至于我都不知从何说起。
稍微一提的是,s:
是作用域修饰符,s
即static
,与C/C++
的同名关键字作用一样,
被其修饰的变量或函数只能在该脚本范围内使用。
而前面的GoToDefinitionIfPossible()
内被l:
修饰的变量的作用域则只局限在函数内,
l
即local
之意。之所以解释这个,是想强调无论是写VIM
、Shell
脚本,
还是C
、C++
、Java
代码,作用域都是个很重要的概念!除了对外提供的接口是全局作用域,
其余函数及变量的作用域越小越好,除了能防止名称冲突,还能将代码复杂度限制在最小范围内,
还不会到处交叉引用,给人一种随地大小便的感觉,这是最基本的代码修养!
各个子函数的分析如下:
apply_global_variables()
:应用全局变量的值,这些全局变量既可能是脚本自己的, 也可能是所依赖的底层插件的——对YCM
而言只有后者。而该函数有一个入口参数, 是为了区分待生效的到底是脚本加载时的工作状态值,还是卸载后还原回去的初始值。 该函数的核心逻辑如下:" exists()的检测是为了允许用户预先定义变量值覆盖以下定义 if !exists('g:YCM_VARIABLES') " 为了形式统一,后文的全局变量字典(Dict)均采用这种格式: " { 'var': [ <default value>, <working value> ] } let g:YCM_VARIABLES = { \ 'g:ycm_confirm_extra_conf': [ 1, 0 ], \ 'g:ycm_add_preview_to_completeopt': [ 0, 1 ], \ 'g:ycm_keep_logfiles': [ 0, 0 ], \ 'g:ycm_key_detailed_diagnostics': [ '"<Leader>d"', '"<Leader>v"' ], \ 'g:ycm_max_diagnostics_to_display': [ 30, 0 ], \ 'g:ycm_complete_in_comments': [ 0, 1 ], \ } endif function s:get_global_variables() return g:YCM_VARIABLES endfunction function s:apply_global_variables(value_index) for [ l:key, l:value ] in items(s:get_global_variables()) " 核心语法是:let <var_name> = <var_value> " 但由于变量名及值均是动态变量而非静态字面值,所以要借助execute命令, " 后面用到execute的语句均是这个原因,不再赘述。 execute 'let ' . l:key . ' = ' . l:value[a:value_index] endfor endfunction
save_old_key_mappings()
:在加载配置并应用新的按键映射之前先保存旧的内容, 核心逻辑如下:let s:OLD_KEY_MAPPINGS = {} function s:save_old_key_mappings() " get_new_key_mappings()的实现见后文 for l:key in keys(s:get_new_key_mappings()) " 按键的当前映射情况可通过maparg()读出,并直接存入OLD_KEY_MAPPINGS字典变量 let s:OLD_KEY_MAPPINGS[l:key] = maparg(l:key, 'n') endfor endfunction
restore_old_key_mappings()
:在卸载配置后还原旧的按键映射,核心逻辑如下:function s:restore_old_key_mappings() " get_new_key_mappings()的实现见后文 for [ l:key, l:value ] in items(s:get_new_key_mappings()) " 若当前的映射内容与预定义的新按键映射内容不符, " 说明有其他插件也使用此按键,这时就不要恢复。 if maparg(l:key, 'n') != l:value continue endif let l:action = get(s:OLD_KEY_MAPPINGS, l:key, '') if '' == l:action " 空值表示加载该配置之前,目标按键未被使用,所以在卸载恢复时也应取消映射 execute 'nunmap ' . l:key else " 否则就要恢复成之前保存过的旧映射值 execute 'nnoremap ' . l:key . ' ' . l:action endif endfor endfunction
apply_new_key_mappings()
:应用新的按键映射,核心逻辑如下:" exists()的检测是为了允许用户预先定义变量值覆盖以下定义 if !exists('g:YCM_KEY_MAPPINGS') let g:YCM_KEY_MAPPINGS = { \ '<Leader>d': ':call GoToDefinitionIfPossible()<CR>', \ '<Leader>h': ':YcmCompleter GoToInclude<CR>', \ } endif function s:get_new_key_mappings() return g:YCM_KEY_MAPPINGS endfunction function s:apply_new_key_mappings() " 遍历YCM_KEY_MAPPINGS每一项的值并用来进行映射 for [ l:key, l:value ] in items(s:get_new_key_mappings()) if '' == l:action execute 'nunmap ' . l:key else execute 'nnoremap ' . l:key . ' ' . l:value endif endfor endfunction
3.4 完整脚本
详见“懒编程秘笈”项目的vim/youcompleteme.vim
文件。
4、cscope
的整合
4.1 核心需求1:定义一个跳转函数并进行按键映射
函数核心逻辑如下:
function s:go_to_definition_via_cscope(target)
" 关掉Quickfix列表窗口,因为函数定义跳转功能不需要此窗口,
" 可在VIM执行“:help quickfix”来了解该窗口的作用。
cclose
execute 'cscope find g ' . a:target
endfunction
let s:DB_NAME = 'cscope.out'
function GoToDefinitionViaCscopeOrCtags()
" 检测是否已导入且激活ctags配置
" (CtagsConfigIsEnabled()的实现逻辑详见后面的ctags章节)
let l:ctags_conf_usable = (exists('*CtagsConfigIsEnabled') && CtagsConfigIsEnabled())
" search_cscope_database()的实现逻辑详见后文
let l:cscope_db = s:search_cscope_database()
" 未找到cscope数据库
if '' == l:cscope_db
if l:ctags_conf_usable
" 若ctags可用,则先尝试用它来进行跳转,
" (GoToDefinitionViaCtags()的实现逻辑详见后面的ctags章节 )
call GoToDefinitionViaCtags()
else
" 否则报错,提示cscope数据库缺失
echohl ErrorMsg
echo '*** Can not find any ' . s:DB_NAME . ' !!!'
echohl None
endif
return
endif
let l:target = expand("<cword>")
" 若ctags不可用,则直接使用cscope来跳转,有错误就第一时间报告,
if !l:ctags_conf_usable
call s:go_to_definition_via_cscope(l:target)
return
endif
" 否则,就借助异常机制,先使用cscope来跳转,
" 出错后会抛异常,在异常处理分支再调用ctags来善后。
try
call s:go_to_definition_via_cscope(l:target)
catch
try
call GoToDefinitionViaCtags()
catch
echohl ErrorMsg
echo "Both cscope and ctags can't find definition of [" . l:target . "]."
echohl None
endtry
endtry
endfunction
按键映射见后面的动态加载/卸载
小节。
4.2 核心需求2:定义一个数据库刷新函数并进行按键映射
函数的核心逻辑如下:
let s:LIST_FILE = 'cscope.files'
let s:DEFAULT_SRC_SEARCH_CMD = 'find -L . -iname "*.h" -o -iname "*.hpp"'
\ . ' -o -iname "*.c" -o -iname "*.cc" -o -iname "*.cpp" -o -iname "*.cxx"'
" exists()的检测是为了允许用户预先定义变量值覆盖以下定义
if !exists('g:CSCOPE_VARIABLES')
" { 'var': [ <default value>, <working value> ] }
let g:CSCOPE_VARIABLES = {
\ 'cmd_search_src': [ '', s:DEFAULT_SRC_SEARCH_CMD ],
\ 'cmd_create_db_from_list': [ '', 'cscope -bq -i ' . s:LIST_FILE ],
\ 'cmd_create_db_default_way': [ '', 'cscope -Rbq' ],
\ }
endif
function s:search_cscope_database()
let l:db_path = ''
let l:db_dir = ''
" 从上到下逐级目录查找cscope数据库文件,以最后找到的一个为准
for l:i in split(fnamemodify(expand('%:p'), ':h'), '/')
let l:db_dir = l:db_dir . '/' . l:i
if filereadable(l:db_dir . '/' . s:DB_NAME)
let l:db_path = l:db_dir . '/' . s:DB_NAME
endif
endfor
return l:db_path
endfunction
function RefreshCscopeDatabase()
" 由于应用场景千变万化,所以生成源码文件列表和数据库文件的命令必须可定制化
let l:cmd_create_list = g:CSCOPE_VARIABLES['cmd_search_src'][1] . ' > ' . s:LIST_FILE
let l:cmd_build_with_list = g:CSCOPE_VARIABLES['cmd_create_db_from_list'][1]
let l:cmd_build_without_list = g:CSCOPE_VARIABLES['cmd_create_db_default_way'][1]
let l:db_path = s:search_cscope_database()
" 若找不到数据库文件则报错,首次创建数据库时可根据报错信息的提示来操作
if '' == l:db_path
echohl ErrorMsg
echo '*** Can not find any ' . s:DB_NAME . ' !!!'
echo 'You have to create it manually in proper directory by running:'
echo ' ' . l:cmd_build_without_list
echo 'Or:'
echo ' # Modify arguments of "find" command according to your need.'
echo ' ' . l:cmd_create_list
echo ' ' . l:cmd_build_with_list
echohl None
return
endif
let l:db_dir = fnamemodify(l:db_path, ':h')
let l:cmds = 'cd ' . l:db_dir . ' && time ('
if filereadable(l:db_dir . '/' . s:LIST_FILE)
" 发现有源码列表文件,先询问是否刷新该文件
echo 'Update ' . s:LIST_FILE . ' first? [y/N] '
let l:confirm = nr2char(getchar())
if l:confirm == 'y' || l:confirm == 'Y'
let l:cmds = l:cmds . l:cmd_create_list . ' && '
endif
" 无论是否刷新列表文件,最后都是基于列表文件来刷新数据库
let l:cmds = l:cmds . l:cmd_build_with_list
else
" 若无列表文件,则自动查找源文件再刷新数据库
let l:cmds = l:cmds . l:cmd_build_without_list
endif
let l:cmds = l:cmds . ' && echo "Refreshed: ' . l:db_path . '")'
execute '!' . l:cmds
" 注意要重置cscope连接才能使新内容生效
cscope reset
endfunction
按键映射见后面的动态加载/卸载
小节。
4.3 进阶需求:动态加载/卸载
与前面YCM
的类似,不再赘述,仅列出按键映射的字典(Dict):
" 指明哪些子命令需要用到Quickfix窗口,注意g(即跳转到函数定义)不需要用到,
" 详细说明可在VIM执行“:help cscopequickfix”查看。
set cscopequickfix=a-,c-,d-,e-,i-,s-,t-
if !exists('g:CSCOPE_KEY_MAPPINGS')
" 注意a、c、e、i、s均用到Quickfix列表窗口以方便操作,
" 打开窗口时用“copen”命令能使光标自动停留到当前选取的结果项,
" 而不是很多文章所说的“cw”命令。
" 跳转到上一个或下一个结果项分别用“cprevious”或“cnext”命令(或它们的缩写),
" 此处也专门为这两个命令映射了快捷键。
let g:CSCOPE_KEY_MAPPINGS = {
\ '<': ':cprevious<CR><CR>',
\ '>': ':cnext<CR><CR>',
\ '<Leader>a': ':cscope find a <C-R>=expand("<cword>")<CR><CR><C-o>:copen<CR>',
\ '<Leader>c': ':cscope find c <C-R>=expand("<cword>")<CR><CR><C-o>:copen<CR>',
\ '<Leader>e': ':cscope find e <C-R>=expand("<cword>")<CR><CR><C-o>:copen<CR>',
\ '<Leader>g': ':call GoToDefinitionViaCscopeOrCtags()<CR>',
\ '<Leader>i': ':cscope find i <C-R>=expand("<cfile>")<CR><CR><C-o>:copen<CR>',
\ '<Leader>r': (
\ exists('*RefreshCtagsFile')
\ ? ':call RefreshCtagsFile()<CR>:call RefreshCscopeDatabase()<CR>'
\ : ':call RefreshCscopeDatabase()<CR>'
\ ),
\ '<Leader>s': ':cscope find s <C-R>=expand("<cword>")<CR><CR><C-o>:copen<CR>',
\ }
endif
注意刷新数据库的按键映射是连同ctags
索引文件刷新函数(若检测到可用)一起绑到同一个键的,
RefreshCtagsFile()
的实现逻辑详见后面的ctags章节。
4.4 完整脚本
详见“懒编程秘笈”项目的vim/cscope.vim
文件。
5、ctags
的兜底
5.1 核心需求1:定义一个跳转函数
函数核心逻辑如下:
let s:TAGS_FILE = 'tags'
function s:search_tags_file()
let l:tags_path = ''
let l:tags_dir = ''
" 从上到下逐级目录查找索引文件,以最后找到的一个为准
for l:i in split(fnamemodify(expand('%:p'), ':h'), '/')
let l:tags_dir = l:tags_dir . '/' . l:i
if filereadable(l:tags_dir . '/' . s:TAGS_FILE)
let l:tags_path = l:tags_dir . '/' . s:TAGS_FILE
endif
endfor
return l:tags_path
endfunction
function GoToDefinitionViaCtags()
let l:tags_path = s:search_tags_file()
if '' == l:tags_path
echohl ErrorMsg
echo '*** Can not find any ' . s:TAGS_FILE . ' !!!'
echohl None
return
endif
" 模拟Ctrl+]按键
execute "normal \<C-]>"
endfunction
5.2 核心需求2:定义一个索引刷新函数
函数核心逻辑如下:
if !exists('g:CTAGS_VARIABLES')
" { 'var': [ <default value>, <working value> ] }
let g:CTAGS_VARIABLES = {
\ 'program': [ '', 'ctags' ],
\ 'prior_commands': [ '', '' ],
\ 'extra_cmd_options': [ '', '--exclude=".git" --exclude=".svn" --exclude=".build" --exclude="*.log" -R' ],
\ }
endif
function RefreshCtagsFile()
let l:cmds = g:CTAGS_VARIABLES['program'][1] . ' ' . g:CTAGS_VARIABLES['extra_cmd_options'][1]
let l:tags_path = s:search_tags_file()
if '' != g:CTAGS_VARIABLES['prior_commands'][1]
let l:cmds = '(' . g:CTAGS_VARIABLES['prior_commands'][1] . ') && ' . l:cmds
endif
if '' == l:tags_path
echohl ErrorMsg
echo '*** Can not find any ' . s:TAGS_FILE . ' !!!'
echo 'You have to create it manually in proper directory by running:'
echo ' # Add more options if necessary.'
echo ' ' . l:cmds
echohl None
return
endif
exec '!time (cd ' . fnamemodify(l:tags_path, ':h') . ' && ' . l:cmds . ' && echo "Refreshed: ' . l:tags_path . '")'
endfunction
设计思路与cscope
类似,就不再赘述。
5.3 进阶需求:动态加载/卸载
非常简单,只需对一个s:CTAGS_CONF_DISABLED
变量赋不同的值即可,详见完整脚本。
5.4 完整脚本
详见“懒编程秘笈”项目的vim/ctags.vim
文件。
6、为满足个性化配置所需的前置
/后置
脚本的自动载入特性
6.1 核心逻辑
function s:load_module_config_if_any(infix)
let l:cfg_path = ''
let l:cfg_dir = ''
for l:i in split(fnamemodify(expand('%:p'), ':h'), '/')
let l:cfg_dir = l:cfg_dir . '/' . l:i
if filereadable(l:cfg_dir . '/module-specific.' . a:infix . '.vim')
let l:cfg_path = l:cfg_dir . '/module-specific.' . a:infix . '.vim'
endif
endfor
if '' != l:cfg_path
execute 'source ' . l:cfg_path
endif
endfunction
call s:load_module_config_if_any('pre')
" 导入YCM、cscope、ctags以及其他配置
call s:load_module_config_if_any('post')
原理与cscope数据库、ctags索引文件的搜索逻辑相同,从上到下逐级目录搜索
前置
脚本module-specific.pre.vim
和后置
脚本module-specific.post.vim
并加载,
从而覆盖相关模块里的变量值、按键映射等,满足不同项目的不同需求。
完整脚本详见“懒编程秘笈”项目的vim/main.vim
文件。
如果只是提取本项目的部分文件使用,那么以上这段脚本通常可以放入~/.vimrc
或用户自己的入口配置中。
6.2 使用示例
以Linux
内核源码为例,由于它不需要依赖libc
头文件,所以在生成cscope
数据库时可以加上-k
选项,
有两种配置写法:
- 使用
前置
脚本module-specific.pre.vim
:" 要写全整个变量的内容 let g:CSCOPE_VARIABLES = { \ 'cmd_search_src': [ '', s:DEFAULT_SRC_SEARCH_CMD ], \ 'cmd_create_db_from_list': [ '', 'cscope -bqk -i ' . s:LIST_FILE ], \ 'cmd_create_db_default_way': [ '', 'cscope -Rbqk' ], \ }
- 使用
后置
脚本module-specific.post.vim
:" 只需写有变动的部分,但最好在其之前先卸载cscope配置、之后再加载回来 call DisableCscopeConfig() let g:CSCOPE_VARIABLES['cmd_create_db_from_list'] = [ '', 'cscope -bqk -i ' . s:LIST_FILE ] let g:CSCOPE_VARIABLES['cmd_create_db_default_way'] = [ '', 'cscope -Rbqk' ] call EnableCscopeConfig()
7、总结与计划
- 核心需求归根究底只有两个重点:
YCM
、cscope
、ctags
该如何接力处理跳转请求?答案是根据底层组件的特性, 分别使用捕获执行结果
和利用VIM
异常机制(即try
-catch
)两种方式。cscope
、ctags
需要先生成数据库或索引文件才能正常工作,当源码发生变动时, 如何方便地刷新数据库或索引文件?答案是定义刷新函数并进行按键映射。 注意这里使用的是同步
刷新的方式,即只有当人去主动操作才会刷新, 而没有采用异步
、后台
、自动
刷新,因为实现难度大,资源的消耗很难控制, 搞不好还会重蹈VSCode
的覆辙(可到前文温习一下为何舍弃VSCode
而另立炉灶)。
-
支持
动态加载/卸载
和小至项目或模块粒度的个性化配置
是本项目的两个亮点。 - 需要注意的问题:
cscope
和ctags
的查找准确度取决于数据库的质量,继而追溯到命令选项的使用, 所以根据不同项目或模块的源码特点而使用不同的选项就显得很重要, 在此无法展开详细,读者可自行执行man cscope
和man ctags
来查看用法, 或者到其官方文档、网站查找资料。- 要测试准确度,可使用一些复杂项目,例如
Linux
、Qt
、libc
、Boost
等, 对了解cscope
和ctags
的用法甚至实现机制很有帮助。 - 一些个人经验:
cscope
对C++
支持得并不好,毕竟C++
的复杂有目共睹。 为C++
或C/C++
混合项目生成cscope
数据库时最好先生成源码列表文件, 然后再加上-i cscope.files
来生成数据库,这样可提高后面的查找准确度。cscope
有好几个子命令,用一个找不到想要的结果,可尝试另一个, 例如使用g
找不到函数定义,可尝试使用s
按符号的方式来查找, 若结果数目很多,可以在Quickfix
窗口输入/\.c|
或/\.cpp|
来定位, 只有实在找不到才考虑使用ctags
。cscope
的-R
选项似乎不能进入以符号链接的形式出现在项目内的目录
里 继续递归查找,这时就需要基于源码列表
来生成数据库了,详见前文。- 很多时候
ctags
的准确度似乎不如cscope
,而且每次执行刷新命令,ctags
都会不假思索地直接刷新索引文件,而cscope
则只有当代码有改动才会刷新数据库。 ctags
生成索引文件时一般采用相对路径
,但网上有案例说, 如果同时使用了FuzzyFinder
来作为查找项目文件的工具,在回跳时会有问题, 推荐在执行生成命令时加上项目根目录的绝对路径
。由于笔者不使用此类工具, 无法验证,仅在此提及一下。
- 改进计划:
- 目前使用的
ctags
跳转命令是模拟Ctrl
+]
按键,当函数有多个定义时, 首次查找会列出所有匹配项,而第二次查找则可能直接跳到上次选择的项,这个需要改进, 候选方案有g
+Ctrl
+]
按键、tjump
命令、tag
命令、ts
命令。 - 进一步浏览
VIM
的帮助手册时发现,cscope
其实内置了必要时借用ctags
命令及其索引文件的特性,与此相关的有cstag
命令及csto
、cst
选项值, 若能用上,脚本可能更简单。 - 在某次随意浏览
YCM
的日志时发现No Clangd executable found
的错误, 联想到前面那个GitHub
议题的回复提到YCM
依赖于clangd
,并且实测安装了clangd
之后YCM
的解析结果确实不一样,但仍未找出跨文件场景下跳转到函数定义的方法, 并且多了一些报错,有待进一步排查。值得一提的是,clangd
可通过LSP
(Language Server Protocol
, 语言服务器协议)向编辑器提供语法补全、跳转等功能——划重点:LSP
非常强大! 因为它完全了解目标源码的语法,所以提供的结果是精准的,不像ctags
只能模糊匹配。 其实之前想直接用LSP
,结果看了几眼觉得太复杂,才考虑YCM
,现在看来若YCM
基于LSP
, 也大有可为。如果配置正确了,可能不仅解决跨文件跳转到函数定义的问题, 还能实现查找某函数的所有引用情况
的功能,如此便能一个YCM
插件包揽所有代码编辑和浏览的任务, 值得尝试! - 后续改进只会修改脚本,不会再另外发文。
- 目前使用的
附:更新记录
2023-10-19
- 首发。
2023-10-23
- 解决了
YCM
跨文件场景下(不论在相同目录还是不同目录)跳转不到函数定义的问题, 方法是:- 安装
clangd
。 - 创建
.ycm_extra_conf.py
并定义一个与旧版FlagsForFile
函数相似的Settings
函数, 或借助工具生成compile_commands.json
,或两者并用(推荐,因为仅有后者而无前者, 虽然代码跳转可能没问题,但在界面显示方面可能会出现一些原因不明的错误或警告的底色或消息)。
- 安装
-
Settings
函数的写法可参考YCM
的项目主页, 或“懒编程秘笈”项目的python/ycm_conf_for_*.py
脚本。 - 至于
compile_commands.json
的生成,则分情况讨论:- 一般项目:根据项目的实际编译方式,可通过
cmake
、bear
等工具生成, 具体命令可自行搜索。 Linux
内核:经实测发现,使用bear
有可能生成不了正确、完整的结果的(只生成一个几十KB
、 十几条记录的文件),因为内核的编译过程非常复杂,编译输出内容不一定被bear
正确识别, 不过较新版本(例如6.2.16
)的内核源码树自带一个脚本,专门用来生成compile_commands.json
, 推荐的用法如下:- 首先,进行一次最小化编译(注意不能不指定目标直接
make
, 否则可能会编译所有内容,不仅耗时巨大,还生成几十G
的产物,非常夸张!):# 或zImage、vmlinux $ make bzImage -j $(grep -c processor /proc/cpuinfo)
- 然后,编译你感兴趣的模块驱动,例如:
# 对于驱动名称,可先查看其所在目录的Makefile里的obj-$(CONFIG_xxx)变量值, # 再将“.o”改为“.ko”即是。 $ make drivers/net/can/dev/can-dev.ko
- 最后,执行生成脚本:
# 若较旧版本(例如4.1.15)源码树无此脚本,可复制新版的过来使用 $ python3 scripts/clang-tools/gen_compile_commands.py
- 首先,进行一次最小化编译(注意不能不指定目标直接
- 其余复杂项目:暂未涉及。
- 一般项目:根据项目的实际编译方式,可通过
- 使用须知:
clangd
之所以那么强大,是因为其同时使用了以下两种索引:静态
索引:即compile_commands.json
或类似物,以及因此而来的产物, 例如缓存目录.cache
。动态
索引:在当前翻译单元
(Translation Unit
, 详见3.1
小节的关键回复
的内容)实时更新的符号信息。
静态
索引的痛点:compile_commands.json
并不能直接使用, 还要再次生成符合clangd
口味的索引文件,即.cache/clangd/index/*.idx
, 这就引出一个重要问题:首次建索引会耗费大量时间和处理器资源(可能还有磁盘读写速率), 且代码跳转、补全可能不正常,这就解释了文首的VSCode
异常现象。正确的做法是: 用VIM
随便打开一个源码文件,然后静置,同时观察系统的CPU
和IO
占用率, 正常情况下可观察到CPU
是多个核心占用率同时接近100%(不是前文一核有难、八核围观
的现象),IO
则是几M
十几M
以上的读写速率。此时只需耐心等一段时间(时间长短因机器性能而异), 当两个指标均已回落,就表明索引创建完毕。动态
索引的痛点:当首次打开一个源文件,要跳转到一个位于另一个源文件的函数定义时, 由于内存并没有该定义的位置信息,并且又未到相应的静态索引去读取, 所以可能只跳到头文件,要再跳一次才能跳到定义,这在YCM
的日志(/tmp/ycm*.log
) 和clangd
的日志(/tmp/clangd_*.log
)也略有体现。当内存和静态索引都更新后, 以后的跳转就会一跳即中。
- 最后,为免有人误会并引发争议,还要强调几个观点:
YCM
并不难装,也不难用,至少在较新版本的Ubuntu
(例如20.04
、22.04
等) 通过apt
安装是这样的情况。那些说难装难用的,可能是在看了粗制滥造甚至抄袭的旧文之后, 又在较旧系统(例如Ubuntu 16.04
以下)使用源码来编译安装。- 有人只知道盲目鼓吹
LSP
,还很享受不断安装插件和写脚本写配置造轮子的病态快感, 殊不知新版YCM
也是基于LSP
,并且由于它的封装,而使初阶码农更易上手, 懒癌民工(比如笔者)也得以解放生产力,投入到更重要、更有挑战性的工作。 如果直接使用LSP
,学习曲线陡峭不说,工作量也可想而知。因此,裸态LSP
不是不可以用, 只是更适合高阶选手,笔者以后有时间有需要时说不定也去研究一下,但绝不是现在。 - 现实场景中,在解决一个旧问题的同时,往往也会引发一个新问题。所以在有多个方案可供选择时,
最简单的方案往往是最合适的,因为简单意味着能引发问题的因素和环节也更少,因而更稳健。
如果一个
YCM
就能满足代码编写和浏览的需要,那就没理由再去理ctags
、cscope
甚至LSP
。 工作环境、基础设施的核心需求是稳定,复杂、过多的组件只会令问题层出不穷, 还有可能是已出现过的重复问题。而正常来说,无意义的劳动,做一次就好;无谓的苦,吃一次就够。 - 工具的选择与技术选型一样,都要与应用场景和已有的技术栈相匹配,合适的才是最好的,
这就是笔者放弃界面菜单花花绿绿、各路势力你方唱罢我登台的图形化
IDE
,转身投向VIM
的原因, 也是不乱装插件、不硬啃LSP
的原因,懂得从简,懂得折衷,才能身心欢愉!