拓冰建站拓冰建站
首页 / 资讯中心 / 正文

vimls-go:给Vimscript配个LSP语言服务器

如果你写过 Vim 插件大概率和我一样经历过这种尴尬Vimscript 写起来一时爽重构起来火葬场。函数跳转得靠 grep补全基本没有想查某个内置函数的签名只能翻:help编辑器世界里的现代开发体验到了 Vimscript 这里直接归零。所以我自己动手写了一个针对 Vimscript 的 language server取名vimls-go。它基于 LSPLanguage Server Protocol协议用 Go 实现专门解决 Vimscript 开发时补全、跳转、诊断、悬停文档缺失的问题。你可以在 Neovim 内置 LSP、coc.nvim、vim-lsp 这类插件里直接接入把它当作 Vimscript 的语言后端来用。如果你日常会写 Vim 配置、维护自己的插件或者只是想让.vim文件的编辑体验稍微现代一点这篇东西就是为你准备的。这篇文章我尽量不写成项目 README 的复读机而是把设计取舍、实现细节、踩过的坑、以及当前版本的实际使用感受都摊开讲清楚。想深入了解 LSP 实现思路的、想直接上手用的、或者只是好奇给 Vimscript 写 language server 到底图什么的都能在里面找到自己需要的部分。1. 为什么 Vim 自己就是编辑器却还要给它的脚本语言配一个 language server先聊点背景。Vimscript 从诞生到现在几十年语法设计相当古老但它至今仍是 Vim/Neovim 个性化配置和插件开发的主要语言。问题在于这个语言的工具链很差没有官方语言服务没有像样的静态分析器社区里也没出现过能对标gopls或tsserver的成熟实现。1.1 Vimscript 开发的真实痛点我梳理了一下自己平时写.vim文件遇到的反人类场景函数跳转基本靠搜索。项目里 function 多了以后grep -n function Foo成了肌肉记忆效率低不说同名局部函数和脚本局部函数混在一起时还得人肉区分。补全基本为零。内置补全能给出一些 Vim 命令但函数名、变量名、自定义命令名这些大部分时候是看运气。内置函数签名记不住。setloclist()、win_execute()、getcompletion()这些函数的参数和返回值谁能全记住只能一遍遍:help。变量作用域混乱。b:、w:、t:、g:、s:、l:、v:前缀一大堆写快了根本分不清当前引用的是哪个作用域改错一个前缀排查半天。语法错误反馈慢。Vim 脚本是边解析边执行很多错误只有在 source 到那一行时才暴露不是编译型语言那种一保存就报错的爽快体验。1.2 LSP 能解决这些问题的底层逻辑LSP 的核心思路是把语言智能从编辑器里抽离出来变成一个独立的服务进程。编辑器只负责和用户交互语言相关的解析、分析、补全建议全部交给语言服务器去算。这样无论你用 Neovim、Vim 还是其他支持 LSP 的编辑器都能共享同一套补全和诊断逻辑。vims 这类服务本质上就是把源码当成一份不断变化的数据流每次改动都增量同步给语言服务器服务器维护一个完整的语法树和符号表然后基于这些结构化数据回答这个位置有什么可补全的这个符号定义在哪这行代码有什么问题。1.3 为什么不做成普通的 Vim 插件可能有人会问既然痛点明确为什么不直接写一个 Vim 插件在内部完成解析原因有几个跨编辑器复用。做成 language server 之后Neovim 能用、Vim 8.2 能用、其他编辑器也能用插件只需要薄薄一层 LSP client 粘合层。性能隔离。Vimscript 解析是 CPU 密集任务放进 Vim 进程里会阻塞 UI而独立进程天然隔离就算解析出错崩溃了也不影响编辑器本身。内存和缓存更好管理。语言服务器可以维护长期驻留的符号索引Vim 插件进程的生命周期和缓冲区强绑定做不了细粒度的增量索引。所以从第一天开始我的目标就很明确做一个不绑定具体编辑器的独立语言服务客户端只负责接入。2. vimls-go 的整体架构与技术选型思路这一节讲设计和实现层面的东西。没有特别深奥的算法但每一步选择背后都有具体的理由。2.1 为什么用 Go 写 language server语言服务器的实现语言可以选 TypeScript、Rust、Go、C。我最终选了 Go原因很实在静态编译单文件部署成本低。丢一个二进制到机器上就能跑不依赖运行时对 Vim 用户很多是运维和嵌入式开发者特别友好。并发模型契合 LSP。LSP 天然是一个多请求并发的服务端Go 的 goroutine 写起来比回调嵌套舒服得多。标准库和生态都有现成基础。go.lsp.dev/protocol这类库已经封装好了 JSON-RPC 的编解码不用自己手搓消息帧省掉一大截工作量。跨平台编译简单。Windows/Linux/macOS 一套代码全搞定GOOS... GOARCH... go build完事。当然 Rust 性能更好但开发速度是短板TypeScript 生态里 LSP 库最全但要跑 Node.js对纯 Vim 用户来说多一个依赖就多一分劝退。Go 是综合成本最低的选项。2.2 解析器的实现策略词法分析加语法分析而不是正则扫描写语言服务最核心的难点就是解析器。Vimscript 语法不算庞大但有着大量历史包袱命令名和变量名可以互相贴脸、奇怪的缩写匹配规则、一行内多个冒号分隔的命令、函数体内的花括号块…… 如果一开始就用正则去差不多得了后面补全和跳转的精度必然崩。vimls-go 的解析器分两层第一层是词法分析器。把源代码拆成 token 流token 类型包括标识符、数字、字符串、注释、关键字、操作符、命令名等。这里有个特殊处理Vimscript 的命令名是上下文相关的同一串字符在不同位置可能被解释成命令或表达式所以词法器只负责切块不做语义判断。第二层是语法分析器。采用手写递归下降recursive descent的方式配合一个上下文状态机来区分脚本上下文和表达式上下文。例如遇到function关键字就进入函数体解析模式直到匹配到endfunction才退出遇到if就记录分支结构直到endif闭合。用递归下降的好处是代码可读性强、调试方便状态都压在执行栈里。Vimscript 不需要像 C 那样的超复杂语法分析手写解析器比引入 parser generator 更可控。2.3 符号表与索引结构解析器产出的是一棵语法树但语言服务不能每次请求都重新遍历整棵树否则大文件会卡死。所以 vimls-go 在后台维护了一套符号索引核心数据结构包括函数表脚本所有function定义的名称、参数列表、起始行号、结束行号、所属脚本 ID。变量表变量名到定义位置的映射分全局和脚本级两个层级。命令表用户自定义command的名称和实现体位置。缓冲区状态每个打开的.vim文件对应一个独立的分析单元内容变更时只重新解析增量部分再合并进全局索引。这套设计参考了经典 IDE 的编译数据库思路只是量级轻很多。文件保存时全量索引一次编辑过程走增量更新基本能保证实时性。2.4 LSP 协议的对接细节LSP 协议层我用的是标准 JSON-RPC 2.0消息帧格式是Content-Length头加 JSON 主体。vimls-go 启动后无限循环读取 stdin解析出请求、响应和通知三类消息分发到对应的 handler 函数。已实现的方法包括方法类型作用initialize请求握手返回服务端能力列表initialized通知客户端通知服务端完成初始化textDocument/didOpen通知文件打开时全量解析textDocument/didChange通知文件变更时增量更新textDocument/didClose通知文件关闭时清理状态textDocument/completion请求返回补全候选textDocument/definition请求返回符号定义位置textDocument/hover请求返回悬停文档textDocument/documentSymbol请求返回文档内符号大纲textDocument/publishDiagnostics通知服务端推送诊断信息初始化时返回的能力列表里有个细节textDocumentSync我用的是Incremental而非Full。增量同步能显著减少大文件下的传输数据量实作起来也不复杂客户端每次变更会带上范围偏移量服务端按偏移量打到旧文本上即可。3. vimls-go 的核心功能与日常使用配置这部分是实打实的实操内容。先介绍功能再给接入方案最后说怎么验证它真的在工作。3.1 当前版本的功能矩阵vimls-go 目前实现了六个核心能力基本覆盖日常写脚本的高频需求函数补全输入call Fo或直接Fo时补全所有已知的函数名附带参数个数提示。变量补全根据当前作用域给出变量候选比如输入g:时列出所有全局变量输入s:时列出脚本局部变量。定义跳转光标放在函数调用处跳转到对应的function定义行。悬停文档悬浮在函数名或内置命令上时展示简要说明和参数签名。语法诊断保存文件时给出分析阶段的错误提示比如function和endfunction不匹配、函数参数格式错误、未闭合的if块。文档符号在编辑器的符号大纲窗口里列出当前脚本的全部函数方便开导航。3.2 在 Neovim 里接入 vimls-goNeovim 0.8 以上版本内置了 LSP client接入很简单。首先安装语言服务器二进制我用的是go installgo install github.com/yourname/vimls-golatest这会生成一个vimls-go可执行文件默认放在$GOPATH/bin下建议把它加到系统PATH中或在 Neovim 配置里指定完整路径。然后在 Neovim 配置文件里添加一个新的 language server 配置local lspconfig require(lspconfig) lspconfig.vimls_go { default_config { cmd { vimls-go }, filetypes { vim }, root_dir lspconfig.util.find_git_ancestor, single_file_support true, }, } lspconfig.vimls_go.setup {}filetypes必须设置为vim这样编辑.vim文件时才会自动启动。另开一个普通文件用:set ftvim也能强制激活。配置完成后重启 Neovim再打开一个.vim文件输入call MyFu就能看到补全弹窗。3.3 在纯 Vim 里接入 vimls-go不用 Neovim 的朋友可以用vim-lsp插件。安装插件后在.vimrc里加这段最小配置let g:lsp_diagnostics_enabled 1 let g:lsp_diagnostics_echo_cursor 1 if executable(vimls-go) augroup lsp_install_vimls_go au! autocmd User lsp_setup call lsp#register_server({ \ name: vimls-go, \ cmd: {server_info-[vimls-go]}, \ allowlist: [vim], \ workspace_config: {}, \ }) augroup END endifcoc.nvim用户则需要写一小段 JavaScript 配置来注册 server原理一致这里不展开。就接入成本而言Neovim 原生方案最省事纯 Vim 环境多一个 vim-lsp 插件也够轻量。3.4 如何确认语言服务器真的在工作配置完别急着写代码先验证链路是否通了。最直接的方式是在 Neovim 里执行:lua print(vim.lsp.get_active_clients()[1].name)如果输出vimls-go说明客户端已经成功握手。再打开一个.vim文件随便输入一个不存在的函数名比如call Foobar(观察是否有诊断提示。有提示说明服务端已经踢开了第一脚。命令行层面也可以手动验证模拟一段 JSON-RPC 请求printf Content-Length: 137\r\n\r\n{jsonrpc:2.0,id:1,method:initialize,params:{capabilities:{}}} | vimls-go正常会返回一个带capabilities字段的 JSON 响应。利用这种方法可以绕过编辑器直接调试服务器逻辑非常方便定位问题。4. 实测效果与常见问题排查任何工具到了真实场景里都会暴露问题。这一节我根据自己的使用体验和测试环境整理了一份实测报告和问题排查速查表。4.1 实际编码体验拿一个中等规模插件举例大概 2000 行的 Vimscript。在 Neovim 里打开全量索引耗时不到 100 毫秒输入函数名前几个字母补全列表弹出基本没有延迟。跳到定义位置准确率在 95% 以上出错的场景集中在重名脚本局部函数上——这也是 Vimscript 本身作用域设计导致的不同autoload目录下的同名函数脚本级变量没有统一命名空间跨文件跳转时会命中错误定义。插件根目录存在多个同名function Foo()定义时跳转默认选第一个需要手动用:lua vim.lsp.definition()的多个返回值逐个筛选。诊断功能在函数类型不匹配、括号不闭合这类错误上很好用类似忘写endfunction这种问题保存后 500 毫秒内就能标红。但 Vimscript 作为弱类型语言很多错误本质上只有运行时才能暴露静态诊断的天花板就在那插件用户别指望它像 TypeScript 检查那样严格。4.2 高频问题速查表我把自己在开发和使用中碰到的高频问题整理成了一张表按症状、原因、解决方案展开症状常见原因解决方案打开 .vim 文件没有任何反应没有触发 filetypes或 server 二进制不在 PATH 里检查:set ft?是否为vimwhich vimls-go确认路径补全弹窗出现但列表为空当前是空白脚本没有建立任何符号索引先保存一次文件:w触发 didSave 全量索引诊断信息延迟明显同步用的是全量同步文件过大时服务端压力大确认客户端支持增量同步将大文件拆分成多个小脚本跳转到了错误的函数同名函数在不同文件/脚本中冲突手动指定uri和line参数或重命名重复定义启动时端口或内存异常同时启动多个 vimls-go 实例检查是否有残留进程客户端配置启用单例模式Windows 下无法启动Go 编译的二进制依赖缺失或路径带空格把二进制放到无空格目录并检查客户端cmd数组参数的传法4.3 开发中踩过的一些代表性坑解析器这块坑最多。Vimscript 的:execute会把字符串拼接后当命令执行静态分析很难精确追踪这种动态命令我一开始试图完全解析它结果频繁误报错误后来干脆先跳过动态执行部分只保持基础诊断不误报再逐条优化。还有一个很容易翻车的点是注释处理。Vimscript 的注释符号是但双引号也能出现在字符串里。词法器如果状态切换不及时一行let s:foo hello . bar就可能把后面的内容全当成注释导致诊断乱飞。正确处理方式是先做词法层面的字符串和转义识别再做命令级拆分顺序不能反。增量同步也有不少细节。LSP 的didChange会给出每次编辑的行号和字符偏移如果客户端传错了范围服务端按错误偏移量做字符串切片很容易 panic 或生成乱码状态。我用了一个保险办法增量更新后对解析结果做一次文本总长度校验如果长度不匹配就直接回退到全量重解析宁可慢一点也不能让内部状态烂掉。4.4 关于性能的几个实测数据我自己测过的两个数据集供大家参考小型 vimrc约 300 行全量解析耗时 20ms补全请求平均响应 5ms基本可以认为是零延迟。中型插件项目约 20 个文件、每个文件 1000 行左右全量索引总耗时约 1.2 秒增量更新单次耗时 30~80ms常规编辑场景下没有明显卡顿。性能瓶颈主要聚集在重复全量解析那个环节所以我把保存后全量索引和输入过程增量更新分开处理输入过程中的缓存不参与诊断推送避免边打字边闪错误提示的烦人体验。5. 后续扩展和个人建议这个项目目前还没到完全成熟的状态但核心链路已经通了。如果你的痛点跟我的经历相似我非常建议自己动手在现有框架上继续迭代或者直接用起来。5.1 可以继续做的几个方向依赖 Vim 官方文档的:help数据源给内置函数和命令做完整签名库是短时间收益最大的一件事。Vim 的 runtime 其实自带 doc 文本解析出来以后hover 和补全的体验会瞬间从能用变成好用到哭。代码格式化也是个大需求。Vimscript 的缩进风格因人而异社区没有统一规范但如果能出一个基于 AST 的格式化器至少在我这种强迫症群体里会有市场。自动补全autocmd事件名和highlight组名也是高频场景这两种名字没有固定模式几乎只能靠查文档现在就缺一个能和运行时数据对接的索引库。5.2 使用建议如果你是第一次接语言服务器先从小配置开始别一上来就在大型插件项目里强上。先把 vimrc 拆成按插件分类的若干个小文件每个文件控制在 300 行以内体验会顺畅很多。Vimscript 本身的动态特性决定了它不可能有 100% 准确的静态分析用的时候心态要放平遇到跳转不准的符号直接飞过去瞄一眼比折腾配置文件值多了。工具是给人省事的不是给人添堵的。5.3 一些真实体会从开始写 vimls-go 到现在一个很深的感受是语言服务器并没有想象中那么神秘核心就是解析器 索引 JSON-RPC三件套。真正的复杂度来自各种语法边角料的处理和客户端差异适配这些只能靠真实的项目来磨。写这个项目的过程中我反而把 Vimscript 的前世今生重新摸了一遍。以前只知道:help查命令现在连各种历史遗留语法特性和解析歧义都能说出个所以然来这种靠写工具彻底搞懂一门语言的体验我觉得很值。如果你也想找一个能让自己把某个冷门领域吃透的练手项目给旧语言写 language server 这个方向不会让你失望的。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门