从 nvim 插件到给 go 官方贡献源码
曦子之前写了一个 nvim 签名插件, 但是实现方式感觉有些曲线救国, 正好自己对 gopls 的实现也有兴趣, 所以有了这次代码贡献。
问题
看过上述的插件小伙伴们应该比较熟, 我们需要能获取光标所在位置的函数签名, 但是 gopls 仅支持在括号内时才能通过textDocument/signatureHelp
获取到函数签名:
.Println(‸) // 光标在括号内 ✔
fmt
.Printl‸n() // 光标在函数名上 ✗
fmt
.Printl‸n // 同上 ✗
fmt
.Printl‸n. // 同上 ✗
fmt
.Println.‸ // 光标在. 后 ✗ fmt
所以无奈之下只能使用 textDocument/hover
来查询签名, hover 获取的是签名+注释, 这是我们对 time package 下的 IsZero 执行 hover 查询的结果, 如下:
func (t time.Time) IsZero() bool
, January 1, year 1, 00:00:00 UTC.
IsZero reports whether t represents the zero time instant
(time.Time).IsZero on pkg.go.dev
等等, 什么是 textDocument/hover
, 什么又是 textDocument/signatureHelp
?
这两个都是 Language Server Protocol 的一种具体协议, 简单来说前者是一个对代码进行说明的文本文档, 支持 md, 这里不过多做介绍, 后者请求的时候会只返回一个标识的函数签名, 没有就返回 null.
之前用 hover 的结果, 那么需要进行正则匹配出签名, 如果需要处理参数的话还要进行拆解成员、函数名、参数列表、返回值等。
很明显仅在函数参数所在的括号里才能查询函数签名是不合理的, 所以去microsoft lsp 查看对 textDocument/signatureHelp
的定义, 发现陈述也很直接:
The signature help request is sent from the client to the server to request signature information at a given cursor position.
为什么只能在参数所在的括号里才能获取到函数签名呢?
定位问题
先确认是不是 gopls 问题.
nvim 的 lspconfig 提供了对 gopls 的调试功能, 只要在启动的时候把日志写入到任意位置即可, 如下:
require'lspconfig'.gopls.setup {
cmd = {'gopls', 'serve','--debug=localhost:6060', '-rpc.trace', '-logfile=/tmp/1.txt'},
}
- 然后执行
tail -f /tmp/1.txt
- 重新启动 nvim 找到任意代码, 执行
lua vim.lsp.buf.signature_help()
我们以下面的代码进行示范:
18 package main
17
16 import (
15 "fmt"
14 "go/ast"
13 "go/parser"
12 "go/token"
11 "log"
10 "os"
9
8 "golang.org/x/tools/go/ast/astutil"
7 )
6
5 func main() {
4 // 创建一个新的文件位置集
3 fset := token.NewFileSet()
2
1 // 打开需要解析的文件
19 file, err := os.Open("./bar/bar.go")
------------------------^------ cursor here ---
这样就可以看到类似下面的日志:
[Trace - 20:05:49.124 PM] Sending request 'textDocument/signatureHelp - (5)'.
Params: {"position":{"line":18,"character":18},"textDocument":{"uri":"file:\/\/\/Users\/bilibili\/workspace\/go\/ast\/main.go"}}
[Trace - 20:05:49.125 PM] Received response 'textDocument/signatureHelp - (5)' in 0ms.
Result: null
看起来是 gopls 没有返回结果, 如果我们调整光标: file, err := os.Open(‸"./bar/bar.go")
(光标在括号上) 会得到以下日志.
[Trace - 20:22:50.944 PM] Sending request 'textDocument/signatureHelp - (21)'.
Params: {"position":{"character":21,"line":18},"textDocument":{"uri":"file:\/\/\/Users\/bilibili\/workspace\/go\/ast\/main.go"}}
[Trace - 20:22:50.945 PM] Received response 'textDocument/signatureHelp - (21)' in 0ms.
Result: {"signatures":[{"label":"Open(name string) (*os.File, error)","documentation":{"kind":"markdown","value":"Open opens the named file for reading. If successful, methods on the returned file can be used for reading; the associated file descriptor has mode O\\_RDONLY. If there is an error, it will be of type \\*PathError."},"parameters":[{"label":"name string"}]}]}
下面就是源码的阅读和分析了。
阅读 gopls
在 gopls 仓库我们可以通过检索 textDocument/signatureHelp 关键字来找到相关的源码位置, 我们关注这个文件 tools/gopls/internal/golang/signature_help.go
.
// 仅摘取了关键代码
var callExpr *ast.CallExpr
, _ := astutil.PathEnclosingInterval(pgf.File, pos, pos)
pathif path == nil {
return nil, 0, fmt.Errorf("cannot find node enclosing position")
}
:
FindCallfor _, node := range path {
switch node := node.(type) {
case *ast.CallExpr:
if pos >= node.Lparen && pos <= node.Rparen {
= node
callExpr break FindCall
}
}
}
这里去查询了 **ast.CallExpr*, 没有去识别 **ast.Ident*、**ast.SelectorExpr*. 而且判断了光标是否在括号内, 只有在括号内才会返回函数签名。到这里我们好像找到了关键位置.
修改 gopls 源码
回到我的诉求上来, 我们期望只要一个标识是函数, 那么就应该能查询到函数签名, 不过是 _ = fmt.Sprint
还是 fmt.Sprint
又或者是 fmt.Sprint()
, 这里有什么区别呢?
这里先解释下astutil.PathEnclosingInterval
, 当然小伙伴可以直接去看函数的文档, 它返回跟当前光标所有的语法标识, 可以看下面的例子:
package bar
import "main/foo"
func Bar() string {
var fo foo.Foo
= Foo // 光标在 o 上
_ return ""
}
func Foo() string {
return ""
}
我们会一次得到下面的对象(按数组下标列出):
0, *ast.Ident
1, *ast.AssignStmt
2, *ast.BlockStmt
3, *ast.FuncDecl
4, *ast.File
看到这里相必大家都看明白了, 它会从光标开始扩散, 用最小原则来表达标识, 最后就是一个代码源文件. 而我们要处理的就是在 *ast.Indet 的时候也要检查他有没有函数签名, 如果有那么就要展示出来. 源文件的修改就不再赘述, 感兴趣的小伙伴看这里, 如何检验我们的代码是否符合预期呢?
像是在前面所说的, 只要我们编译好 gopls 后, 在 lspconfig 里配置上绝对路径即可.
require'lspconfig'.gopls.setup {
-- 这里写真实路径
cmd = {'xxxx/gopls', 'serve','--debug=localhost:6060', '-rpc.trace', '-logfile=/tmp/1.txt'},
}
如何调试 gopls
gopls 通信使用的是 jsonrpc 协议, 不是常规的 http 协议所以简单的 curl 是行不通的. 我们可以每次编译好后, 重启 vim 来看日志是不是符合预期, 但是效率是真的低。
当然我们也可以直接构造测试用例来检验是否符合预期, 不过当时我看了他们的测试用例构造, 只能说也不简单, 有兴趣的小伙伴可以关注
tools/gopls/internal/test/integration/misc/signature_help_test.go tools/gopls/internal/test/marker/marker_test.go
我之前用的前者, 被贡献者告知前者是非工作区的一些冒烟测试😂.
改好源码重新编译 gopls:
# 注意 Content-Length 下面的换行是必须的
gopls -logfile=/tmp/1.txt serve -rpc.trace
Content-Length: 147
{"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"processId": null, "rootUri": "file:///path/to/your/project", "capabilities": {}}}
Content-Length: 4484
{"jsonrpc":"2.0","result":{"capabilities":{"textDocumentSync":{"openClose":true,"change":2,"save":{}},"completionProvider":{"triggerCharacters":["."]},"hoverProvider":true,"signatureHelpProvider":{"triggerCharacters":["(",","]},"definitionProvider":true,"typeDefinitionProvider":true,"implementationProvider":true,"referencesProvider":true,"documentHighlightProvider":true,"documentSymbolProvider":true,"codeActionProvider":true,"codeLensProvider":{},"documentLinkProvider":{},"workspaceSymbolProvider":true,"documentFormattingProvider":true,"renameProvider":true,"foldingRangeProvider":true,"selectionRangeProvider":true,"executeCommandProvider":{"commands":["gopls.add_dependency","gopls.add_import","gopls.add_telemetry_counters","gopls.apply_fix","gopls.assembly","gopls.change_signature","gopls.check_upgrades","gopls.diagnose_files","gopls.doc","gopls.edit_go_directive","gopls.fetch_vulncheck_result","gopls.free_symbols","gopls.gc_details","gopls.generate","gopls.go_get_package","gopls.list_imports","gopls.list_known_packages","gopls.maybe_prompt_for_telemetry","gopls.mem_stats","gopls.regenerate_cgo","gopls.remove_dependency","gopls.reset_go_mod_diagnostics","gopls.run_go_work_command","gopls.run_govulncheck","gopls.run_tests","gopls.scan_imports","gopls.start_debugging","gopls.start_profile","gopls.stop_profile","gopls.test","gopls.tidy","gopls.toggle_gc_details","gopls.update_go_sum","gopls.upgrade_dependency","gopls.vendor","gopls.views","gopls.workspace_stats"]},"callHierarchyProvider":true,"semanticTokensProvider":{"legend":{"tokenTypes":[],"tokenModifiers":[]},"range":true,"full":true},"inlayHintProvider":{},"workspace":{"workspaceFolders":{"supported":true,"changeNotifications":"workspace/didChangeWorkspaceFolders"}}},"serverInfo":{"name":"gopls","version":"{\"GoVersion\":\"go1.23.0\",\"Path\":\"golang.org/x/tools/gopls\",\"Main\":{\"Path\":\"golang.org/x/tools/gopls\",\"Version\":\"v0.16.2\",\"Sum\":\"h1:K1z03MlikHfaMTtG01cUeL5FAOTJnITuNe0TWOcg8tM=\",\"Replace\":null},\"Deps\":[{\"Path\":\"github.com/BurntSushi/toml\",\"Version\":\"v1.2.1\",\"Sum\":\"h1:9F2/+DoOYIOksmaJFPw1tGFy1eDnIJXg+UHjuD8lTak=\",\"Replace\":null},{\"Path\":\"github.com/google/go-cmp\",\"Version\":\"v0.6.0\",\"Sum\":\"h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=\",\"Replace\":null},{\"Path\":\"golang.org/x/exp/typeparams\",\"Version\":\"v0.0.0-20221212164502-fae10dda9338\",\"Sum\":\"h1:2O2DON6y3XMJiQRAS1UWU+54aec2uopH3x7MAiqGW6Y=\",\"Replace\":null},{\"Path\":\"golang.org/x/mod\",\"Version\":\"v0.20.0\",\"Sum\":\"h1:utOm6MM3R3dnawAiJgn0y+xvuYRsm1RKM/4giyfDgV0=\",\"Replace\":null},{\"Path\":\"golang.org/x/sync\",\"Version\":\"v0.8.0\",\"Sum\":\"h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=\",\"Replace\":null},{\"Path\":\"golang.org/x/telemetry\",\"Version\":\"v0.0.0-20240829154258-f29ab539cc98\",\"Sum\":\"h1:Wm3cG5X6sZ0RSVRc/H1/sciC4AT6HAKgLCSH2lbpR/c=\",\"Replace\":null},{\"Path\":\"golang.org/x/text\",\"Version\":\"v0.16.0\",\"Sum\":\"h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=\",\"Replace\":null},{\"Path\":\"golang.org/x/tools\",\"Version\":\"v0.22.1-0.20240829175637-39126e24d653\",\"Sum\":\"h1:6bJEg2w2kUHWlfdJaESYsmNfI1LKAZQi6zCa7LUn7eI=\",\"Replace\":null},{\"Path\":\"golang.org/x/vuln\",\"Version\":\"v1.0.4\",\"Sum\":\"h1:SP0mPeg2PmGCu03V+61EcQiOjmpri2XijexKdzv8Z1I=\",\"Replace\":null},{\"Path\":\"honnef.co/go/tools\",\"Version\":\"v0.4.7\",\"Sum\":\"h1:9MDAWxMoSnB6QoSqiVr7P5mtkT9pOc1kSxchzPCnqJs=\",\"Replace\":null},{\"Path\":\"mvdan.cc/gofumpt\",\"Version\":\"v0.6.0\",\"Sum\":\"h1:G3QvahNDmpD+Aek/bNOLrFR2XC6ZAdo62dZu65gmwGo=\",\"Replace\":null},{\"Path\":\"mvdan.cc/xurls/v2\",\"Version\":\"v2.5.0\",\"Sum\":\"h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=\",\"Replace\":null}],\"Settings\":[{\"Key\":\"-buildmode\",\"Value\":\"exe\"},{\"Key\":\"-compiler\",\"Value\":\"gc\"},{\"Key\":\"DefaultGODEBUG\",\"Value\":\"asynctimerchan=1,gotypesalias=0,httplaxcontentlength=1,httpmuxgo121=1,httpservecontentkeepheaders=1,panicnil=1,tls10server=1,tls3des=1,tlskyber=0,tlsrsakex=1,tlsunsafeekm=1,winreadlinkvolume=0,winsymlink=0,x509keypairleaf=0,x509negativeserial=1\"},{\"Key\":\"CGO_ENABLED\",\"Value\":\"1\"},{\"Key\":\"CGO_CFLAGS\",\"Value\":\"\"},{\"Key\":\"CGO_CPPFLAGS\",\"Value\":\"\"},{\"Key\":\"CGO_CXXFLAGS\",\"Value\":\"\"},{\"Key\":\"CGO_LDFLAGS\",\"Value\":\"\"},{\"Key\":\"GOARCH\",\"Value\":\"arm64\"},{\"Key\":\"GOOS\",\"Value\":\"darwin\"},{\"Key\":\"GOARM64\",\"Value\":\"v8.0\"}],\"Version\":\"v0.16.2\"}"}},"id":1}
或者也可以使用 python 脚本构建 socket 请求:
import json
import socket
# JSON-RPC 请求数据
= {
request_data "jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"processId": None,
"rootUri": "file:///path/to/your/project",
"capabilities": {}
}
}
# 将 JSON-RPC 请求转换为 JSON 字符串
= json.dumps(request_data)
request_json
# 使用 socket 连接 gopls
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
connect(("localhost", 8080))
s.
# 发送 LSP 请求,需添加消息长度头部
= f"Content-Length: {len(request_json)}\r\n\r\n{request_json}"
request print("====", request)
'utf-8'))
s.sendall(request.encode(
# 接收响应
= s.recv(14490)
response print("Response:", response.decode('utf-8'))
给 gopls 提 pr
跟这种大型开源项目提 PR 还是有很多点需要注意的:
- 你需要先在 gopls 官方仓库提一个 issues, 表达你遇到了什么问题, 期望的一个逻辑.
- 提供的 PR 后不要在 github 里去讨论问题, 要在 Gerrit 里(如何使用后面会说).
- 你需要 2 个及以上的贡献者来 approve 你的代码, 必须有一个 google 官方开发者!
- 你的代码风格要严格按照他们的来, 包含不仅限于代码注释.
- github PR 的标题和内容也要符合规范.
- 需要同意 Contributor License Agreements, 说明看这里FAQ
- 有耐心阅读下Contribution Guide
- 需要相关测试用例.
初次使用 Gerrit 的同学要注意, 你在跟其他贡献者讨论的时候不是简单点回复就可以, 还需要提交, 可以按快捷键 ‘a’.
总结
期间也遇到了很多问题, 我感觉在代码处理上已经接受过前老板的毒打, 已经有一定的保准, 没想到还是被揪了不少问题, 小伙伴们在这方面也要注意。
在提 pr 时不多写一句注释, 不多写一行代码.
这个功能最快也要等 gopls v0.17.0, 所以暂时还吃不上.