View on GitHub

富乎 · 地问


avatar
辗转探寻为富乎?《天问》无解向地问!

<<< 返回主页

忙里偷闲打磨代码编辑环境

1、背景

2、需求梳理

2.1 核心需求

2.2、进阶需求

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语言的的翻译单元机制的限制,YCMGoToDefinition子命令当前并不支持跨文件的函数定义跳转。 而当提问者询问是否考虑实现这样的特性时,回复如下:

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

简单地说,就是执行YCMGoToDefinitionElseDeclaration子命令,捕获其输出结果并进行判断, 若能成功跳转则皆大欢喜,否则就交由cscope来作进一步处理,即调用GoToDefinitionViaCscopeOrCtags(), 该函数的实现详见后面的cscope章节。值得一提的是,应执行的YCM子命令原本是GoToDefinition, 但由于想在YCM跳转失败时能有所察觉并顺便知道函数声明的位置, 而GoToDeclaration子命令又没想好与哪个顺手又好记的键进行绑定, 所以就暂时这样安排。

至于快捷键映射,则由原来的:

nnoremap <Leader>d :YcmCompleter GoToDefinitionElseDeclaration<CR>

改为:

nnoremap <Leader>d :call GoToDefinitionIfPossible()<CR>

其中<Leader>Linux下默认为反斜线(\)。并且,由于动态加载/卸载的需求, 这个语句也不会写得这么直接,详见后文。

3.3 实现进阶需求:动态加载/卸载

所谓加载,就是将一个VIM脚本导入到当前编辑环境,便可使用里面的全局变量、函数和按键映射等, 与Shellsource命令作用一样且同名。卸载则相反,将以上这些抹除掉或回复原值/状态。 所谓动态则是指不限于用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:是作用域修饰符,sstatic,与C/C++的同名关键字作用一样, 被其修饰的变量或函数只能在该脚本范围内使用。 而前面的GoToDefinitionIfPossible()内被l:修饰的变量的作用域则只局限在函数内, llocal之意。之所以解释这个,是想强调无论是写VIMShell脚本, 还是CC++Java代码,作用域都是个很重要的概念!除了对外提供的接口是全局作用域, 其余函数及变量的作用域越小越好,除了能防止名称冲突,还能将代码复杂度限制在最小范围内, 还不会到处交叉引用,给人一种随地大小便的感觉,这是最基本的代码修养!

各个子函数的分析如下:

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选项, 有两种配置写法:

7、总结与计划

附:更新记录

2023-10-19

2023-10-23