从 nvim 插件到给 go 官方贡献源码

2024-09-05 ⏳3.5分钟(1.4千字)

之前写了一个 nvim 签名插件, 但是实现方式感觉有些曲线救国, 正好自己对 gopls 的实现也有兴趣, 所以有了这次代码贡献。

问题

看过上述的插件小伙伴们应该比较熟, 我们需要能获取光标所在位置的函数签名, 但是 gopls 仅支持在括号内时才能通过textDocument/signatureHelp获取到函数签名:

fmt.Println() // 光标在括号内 ✔

fmt.Printl‸n() // 光标在函数名上 ✗

fmt.Printl‸n  // 同上 ✗

fmt.Printl‸n. // 同上 ✗ 

fmt.Println.// 光标在. 后 ✗

所以无奈之下只能使用 textDocument/hover 来查询签名, hover 获取的是签名+注释, 这是我们对 time package 下的 IsZero 执行 hover 查询的结果, 如下:

 func (t time.Time) IsZero() bool


 IsZero reports whether t represents the zero time instant, January 1, year 1, 00:00:00 UTC.

 (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'},
}
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
	path, _ := astutil.PathEnclosingInterval(pgf.File, pos, pos)
	if path == nil {
		return nil, 0, fmt.Errorf("cannot find node enclosing position")
	}
FindCall:
	for _, node := range path {
		switch node := node.(type) {
		case *ast.CallExpr:
			if pos >= node.Lparen && pos <= node.Rparen {
				callExpr = node
				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 字符串
request_json = json.dumps(request_data)

# 使用 socket 连接 gopls
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect(("localhost", 8080))
    
    # 发送 LSP 请求,需添加消息长度头部
    request = f"Content-Length: {len(request_json)}\r\n\r\n{request_json}"
    print("====", request)
    s.sendall(request.encode('utf-8'))
    
    # 接收响应
    response = s.recv(14490)
    print("Response:", response.decode('utf-8'))

给 gopls 提 pr

跟这种大型开源项目提 PR 还是有很多点需要注意的:

  1. 你需要先在 gopls 官方仓库提一个 issues, 表达你遇到了什么问题, 期望的一个逻辑.
  2. 提供的 PR 后不要在 github 里去讨论问题, 要在 Gerrit 里(如何使用后面会说).
  3. 你需要 2 个及以上的贡献者来 approve 你的代码, 必须有一个 google 官方开发者!
  4. 你的代码风格要严格按照他们的来, 包含不仅限于代码注释.
  5. github PR 的标题和内容也要符合规范.
  6. 需要同意 Contributor License Agreements, 说明看这里FAQ
  7. 有耐心阅读下Contribution Guide
  8. 需要相关测试用例.

初次使用 Gerrit 的同学要注意, 你在跟其他贡献者讨论的时候不是简单点回复就可以, 还需要提交, 可以按快捷键 ‘a’.

总结

期间也遇到了很多问题, 我感觉在代码处理上已经接受过前老板的毒打, 已经有一定的保准, 没想到还是被揪了不少问题, 小伙伴们在这方面也要注意。

在提 pr 时不多写一句注释, 不多写一行代码.

这个功能最快也要等 gopls v0.17.0, 所以暂时还吃不上.