nvim 函数签名补全插件
曦子一个获取函数签名的小插件.
缘起
最近在写 go
测试用例的时候遇到了比较头疼的问题, 一般情况下我们写测试用例是免不去 mock 函数的,
举个🌰, 看以下代码:
func GetUserBooks(ctx context.Context, uid int) (books []Book, err error) {
, err := GetFromCache(ctx, uid)
bsif err != nil {
return
}
if uid%2 == 0 {
(bs)
SortByName}
(...)
UploadToBerserk}
很明显, 我们不必要去真的调用 GetFromCache 或者 SortByName, 这些函数只要被 mock 掉就行了, 也就是说我们只负责测试 GetUserBooks 本身的逻辑。go 语言不像其他语言, 比如 swift 支持函数勾子, 对函数 mock 并没有那么方便.
好在有 monkey 这样的开源框架, 我现在用的是来自字节的 mockey. 在业务开发中可能函数本身就比较复杂, 调用的函数多, 那需要 mock 的函数也就多了起来, 我们 mock 一个函数免不了去复制一个函数的函数签名, 比如用 mockey 去 mock 上述 GetFromCache 那么就要这样做:
mockey.Mock(GetFromCache).To(func (ctx context.Context, uid int) (bs []Book, err error) {
...
return
}).Build()
这里麻烦的点就是要获取 GetFromCache 的函数签名, 作为一个把 neovim
作为 ide 的同学来说, 需要下面几个步骤:
- 光标移到 GetFromCache
- ctrl+] 跳转到函数声明
- yy 复制函数签名
- ctrl+o 回到编辑点
- f+( 行内跳转到编辑点
- p 粘贴
- 删除函数名
- 补充剩下的 } 之类的(这步取决于上面的步骤3)
- 如果跨 package 可能还要处理返回值的命名, 比如 book.Book, 而不是 Book
你们头大不大, 作为一个 nvim
熟练工反正我头大, 如果你们有更好更快速的编辑方式, 请告诉我.
你看, 当思路理好之后,写代码完全变成了文书工作.
我们知道 gopls 本身也支持 postfix_snippets
, 比如 gopls, 当我们输入 foo.var
那么就能得到f := foo()
.
- 那我们能不能给 foo 加一个 .sign 呢?
- 能不能只要我们输入
funcname.sign
就直接得到func_sign
呢?
历程
现在的 nvim 插件使用了 nvim-cmp, :h cmp
可以找到如何写一个自定义的补全插件.
NOTE: 1. The
complete
method is required. Others can be omitted.
The
callback
function must always be called.You can use only
require('cmp')
in custom source.If the LSP spec was changed, nvim-cmp may implement it without any announcement (potentially introducing breaking changes).
You should read ./lua/cmp/types and https://microsoft.github.io/language-server-protocol/specifications/specification-current.
Please add your source to the list of sources in the Wiki (https://github.com/hrsh7th/nvim-cmp/wiki/List-of-sources) and if you publish it on GitHub, add the
nvim-cmp
topic so users can find it more easily.
重点在第五条, 其他的不怎么关键, 我们看下关键函数:
---Invoke completion (required).
---@param params cmp.SourceCompletionApiParams
---@param callback fun(response: lsp.CompletionResponse|nil)
function source:complete(params, callback)
({
callback{ label = 'January' },
{ label = 'February' },
{ label = 'March' },
{ label = 'April' },
{ label = 'May' },
{ label = 'June' },
{ label = 'July' },
{ label = 'August' },
{ label = 'September' },
{ label = 'October' },
{ label = 'November' },
{ label = 'December' },
})
end
如果我们实现了这个函数, 那么补全菜单上就会出现我们的 label !!! 直接就铆钉了入口.当然 lua 不是一个强类型语言, 要自己去看下 lsp.CompletionResponse 是什么.
那我们如何获取函数签名呢, 我看了下 nvim-cmp
的插件列表, 恰好有个插件叫hrsh7th/cmp-nvim-lsp-signature-help, 甚为开心, 又进步一大步, 改插件也比较简单获取函数签名使用以下代码:
local request = vim.lsp.util.make_position_params(0, self:_get_client().offset_encoding)
request.context = {
triggerKind = 2,
triggerCharacter = trigger_character,
isRetrigger = not not self.signature_help,
activeSignatureHelp = self.signature_help,
}
client.request('textDocument/signatureHelp', request, function(_, signature_help)
self.signature_help = signature_help
if not signature_help then
return callback({ isIncomplete = true })
end
self.signature_help.activeSignature = self.signature_help.activeSignature or 0
({
callbackisIncomplete = true,
items = self:_items(self.signature_help),
})
也就是说按照微软的 lsp 协议发送textDocument/signatureHelp
请求即可, 自己代码 copy 下来果然可行!
第一折
按照同样的方式构造请求, 发现用 “.” 触发的 response 死活为 nil
, 只有用 foo(
可以触发, 这不是离大谱么, 无奈之下 gpt 跟我说你不如 textDocument/hover
试试. 改成 textDocument/hover
的 response 变得复杂多了, 说白了返回一个 markdown
模式的 doc 文档.
虽然这样会依赖于文档的罗列, 考虑到先跑通, 先匹配出函数签名, 下面是一个样例:
```go\nfunc db.SQLUpdate(table string, sql string) (d db.Query)\n```\n\nSQLUpdate
构造 update 查询 git.bilibi li.co/go/util@v0.0.0-20240724093959-7d4ee28580c2/db#SQLUpdate)
本来想用正则, 发现 lua 对正则的支持真的 一言难尽, 无奈退一步根据\n
来 split。
这个时候函数签名已经获取到了, 成功了一大半.(不过这留个 TODO, 应该有更好的方案获取函数签名).
第二折
根据lsp.CompletionResponse, 我们要构造 lsp.CompletionList, 文档如下:
---@class lsp.CompletionItem
---@field public label string
---@field public labelDetails? lsp.CompletionItemLabelDetails
---@field public kind? lsp.CompletionItemKind
---@field public tags? lsp.CompletionItemTag[]
---@field public detail? string
---@field public documentation? lsp.MarkupContent|string
---@field public deprecated? boolean
---@field public preselect? boolean
---@field public sortText? string
---@field public filterText? string
---@field public insertText? string
---@field public insertTextFormat? lsp.InsertTextFormat
---@field public insertTextMode? lsp.InsertTextMode
---@field public textEdit? lsp.TextEdit|lsp.InsertReplaceTextEdit
---@field public textEditText? string
label
我们都已经知道了, 就是补全菜单的选项名, 看起来我们选择 insertText 和 insertTextFormat 就行, 如下:
local item = {
label = 'sign',
insertText = func_sign,
insertTextFormat = 1, -- Snippet format
}
这个时候我们输入 db.SQLUpdate.sign 得到了 db.SQLUpdatefunc db.SQLUpdate(table string, sql string) (d db.Query)
, 这个时候我们只要把 db.SQLUpdate
替换掉, 而不是简单的 insert 不就可以了么?
发现 lsp.CompletionItem 有个 textEdit 属性, 而且他提供了一个参数 range
和一个 newText
, 相必就是把 range 替换为 newText 但是很遗憾, 如果我们手动指定 range 那么补全菜单的 sign 补全项就会消失, 试了半天只有 range 包含触发字符”.”的时候才能出现补全项, 再去翻文档:
- Note 1: The text edit’s range as well as both ranges from an insert
- replace edit must be a [single line] and they must contain the position
- at which completion has been requested.
- Note 2: If an
InsertReplaceEdit
is returned the edit’s insert range- must be a prefix of the edit’s replace range, that means it must be
- contained and starting at the same position.
这意思就是包含请求时候的字符, 但是很遗憾, 只要不是仅限触发字符就不行.
github 有用户有同样问题, 找了下他的源码, 发现他用 additionalTextEdits 来变成其他字符, 试了下果然有效!现在输入 db.SQLUpdate.sign
得到 func db.SQLUpdate(table string, sql string) (d db.Query)sign
第三折
我们发现不仅后面多了个sign, 就是补全项的 label, 当光标走到 sign 选项时会自动补全, 坏菜, 这不难调试了么, 好在 ChatGPT 终于发挥了一次作用, 当我们 insertTextFormat 为 1 的时候会考虑文本中的)
, 导致触发补全。
那多个 sign 怎么回事? 想起使用 insertText 的时候是没有多余的 sign 的, 那么方案就是同时使用 insert 插入文本, 然后使用 additionalTextEdits 替换旧文本.
如何食用
cmp.setup {
-- ... Your other configuration ...
sources = {
{ name = "nvim_cmp_sign", group_index = 2 },
},
}
结束
在开发 neovim
插件的时候遇到了很多痛点, 但是好在都拿到了解决方案, 遇到问题还是要多搜一搜, 大概率有同样的 case.
现在输入 db.SQLUpdate.mockey
得到如下代码:
.Mock(db.SQLUpdate).To(func (table string, sql string) db.Query {
mockeyreturn
}).Build()