TLDR: 本文并没有任何设计思想,只是为编写基于LSP
协议的自定义lint
工具留下一丝线索,以便未来使用。
在最近这两年里,我断断续续在项目内写过不少针对项目内go
代码的lint
工具和代码生成器。
每次在编写lint
工具时,就会发现如果能有语义
的辅助就可以将lint
做得更完美。
然而语义分析的复杂度和开发成本都太高了,所以每次最终都做成了基于AST
的简易lint
工具。
我时常在想,其实我不需要开发复杂的语义分析功能,只要我可以像vs-code
那样,可以查找所有实现
, 查找函数定义
, 查找符号的所有引用
, 我们编写出的lint
工具的上限就会大大提高。
幸运的是我们只要编写一个headless的LSP客户端,便可以拥有vs-code中所有关于语义的能力。
这个想法其实我已经萌生了一年多,但一直没有动力和时间去尝试。
直到最近,我终于下定决心尝试一下。
虽然LSP
有完整完善的文档,但是有时太全了也是一种负担。
因此这里还是记录一下常用的几个textDocument
请求参数:
textDocument/didOpen
:
- 用处: 当一个文件被打开时,通知
gopls
来初始化对该文件的语言服务支持。这包括加载文件内容,进行语法检查,并准备相关的编辑操作支持。 - 参数:
{ "textDocument": { "uri": "file:///path/to/your/file.go", "languageId": "go", "version": 1, "text": "package main\n\nfunc main() {\n // your code here\n}" } }
-
textDocument/didChange
:- 用处: 当文件内容发生变化时,通知
gopls
来更新对该文件的语言服务支持。这包括处理文本的增量变化,如添加、删除和修改代码行。 - 参数:
{ "textDocument": { "uri": "file:///path/to/your/file.go", "version": 2 }, "contentChanges": [ { "range": { "start": { "line": 2, "character": 0 }, "end": { "line": 2, "character": 0 } }, "text": " fmt.Println(\"Hello, world!\")" } ] }
- 用处: 当文件内容发生变化时,通知
-
textDocument/didClose
:
- 用处: 当一个文件被关闭时,通知
gopls
来释放对该文件的语言服务支持。这有助于释放资源并清理掉不再使用的文档信息。{ "textDocument": { "uri": "file:///path/to/your/file.go" } }
-
textDocument/hover
:- 用处: 请求文档中的某个位置的悬停信息,通常用于显示鼠标悬停在一个标识符上时的相关帮助内容,如类型信息、文档注释等。
{ "textDocument": { "uri": "file:///path/to/your/file.go" }, "position": { "line": 3, "character": 5 } }
- 用处: 请求文档中的某个位置的悬停信息,通常用于显示鼠标悬停在一个标识符上时的相关帮助内容,如类型信息、文档注释等。
-
textDocument/signatureHelp
:- 用处: 请求函数签名的帮助信息,当光标停在一个函数的定义上时,会返回函数签名的详细信息和参数描述。
{ "textDocument": { "uri": "file:///path/to/your/file.go" }, "position": { "line": 5, "character": 10 } }
- 用处: 请求函数签名的帮助信息,当光标停在一个函数的定义上时,会返回函数签名的详细信息和参数描述。
-
textDocument/documentSymbol
:- 用处: 获取文件中的所有符号信息,包括函数、类型定义等,这对于导航代码结构非常有帮助。
{ "textDocument": { "uri": "file:///path/to/your/file.go" } }
- 用处: 获取文件中的所有符号信息,包括函数、类型定义等,这对于导航代码结构非常有帮助。
-
textDocument/references
:- 用处: 查找文档中某个标识符的引用,以帮助开发者了解代码中的使用情况。
{ "textDocument": { "uri": "file:///path/to/your/file.go" }, "position": { "line": 7, "character": 0 } }
- 用处: 查找文档中某个标识符的引用,以帮助开发者了解代码中的使用情况。
-
textDocument/definition
:- 用处: 请求一个标识符的定义位置,以便快速导航到其实现位置。
{ "textDocument": { "uri": "file:///path/to/your/file.go" }, "position": { "line": 8, "character": 5 } }
- 用处: 请求一个标识符的定义位置,以便快速导航到其实现位置。
-
textDocument/codeAction
:- 用处: 提供可供用户选择的代码修复建议,如重构、添加注释、优化等。
{ "textDocument": { "uri": "file:///path/to/your/file.go" }, "range": { "start": { "line": 10, "character": 0 }, "end": { "line": 10, "character": 5 } }, "context": { "diagnostics": [] } }
- 用处: 提供可供用户选择的代码修复建议,如重构、添加注释、优化等。
-
textDocument/codeLens
:- 用处: 请求代码镜头(Code Lens)信息,如测试覆盖率、TODO 标记等,这些信息直接显示在代码行的旁边。
{ "textDocument": { "uri": "file:///path/to/your/file.go" } }
- 用处: 请求代码镜头(Code Lens)信息,如测试覆盖率、TODO 标记等,这些信息直接显示在代码行的旁边。
-
textDocument/documentFormatting
:- 用处: 对文档进行格式化,以符合编程语言的规范。例如,调整缩进、空格、行末等。
参数:{ "textDocument": { "uri": "file:///path/to/your/file.go" }, "options": { "tabSize": 4, "insertSpaces": true } }
- 用处: 对文档进行格式化,以符合编程语言的规范。例如,调整缩进、空格、行末等。
下面我将使用上述textDocument
请求,编写一个简易的headless
客户端,来查找某个函数的所有引用。
首先确保已安装 Go 语言并配置好环境。然后安装 gopls
:
go install golang.org/x/tools/gopls@latest
在这个工具中,我们使用gopls
来查询所有调用了rpc.Call
的函数体。
代码如下:
package main
import (
"context"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/sourcegraph/jsonrpc2"
)
type SymbolKind int
type SymbolTag int
type DocumentSymbol struct {
Kind SymbolKind `json:"kind"`
Name string `json:"name"`
Location Location `json:"location"`
}
type Location struct {
URI string `json:"uri"`
Range Range `json:"range"`
}
type Range struct {
Start Position `json:"start"`
End Position `json:"end"`
}
type Position struct {
Line int `json:"line"`
Character int `json:"character"`
}
type LSPClient struct {
cmd *exec.Cmd
conn *jsonrpc2.Conn
rootURI string
}
type stdioReadWriteCloser struct {
stdin io.WriteCloser
stdout io.ReadCloser
}
var _ io.ReadWriteCloser = (*stdioReadWriteCloser)(nil)
func (c stdioReadWriteCloser) Read(p []byte) (n int, err error) {
n, err = c.stdout.Read(p)
return n, err
}
func (c stdioReadWriteCloser) Write(p []byte) (n int, err error) {
return c.stdin.Write(p)
}
func (c stdioReadWriteCloser) Close() error {
return nil
}
// 简单的handler实现
type handler struct{}
func (h *handler) Handle(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) {
if req.Method == "workspace/configuration" {
// 返回空数组表示不支持配置
conn.Reply(ctx, req.ID, []interface{}{})
return
}
conn.Reply(ctx, req.ID, []interface{}{})
// 可以在这里处理服务器推送的消息
}
func newLSPClient(projectPath string) (*LSPClient, error) {
// 启动gopls作为语言服务器
cmd := exec.Command("gopls", "serve")
cmd.Stderr = os.Stderr
// 创建管道用于JSON-RPC通信
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, fmt.Errorf("创建stdin管道失败: %v", err)
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, fmt.Errorf("创建stdout管道失败: %v", err)
}
// 启动gopls服务器
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("启动gopls服务器失败: %v", err)
}
// 创建JSON-RPC连接
// 创建连接,传入完整的参数
conn := jsonrpc2.NewConn(
context.Background(),
jsonrpc2.NewBufferedStream(stdioReadWriteCloser{
stdin: stdin,
stdout: stdout,
}, jsonrpc2.VSCodeObjectCodec{}),
&handler{},
)
// 转换项目路径为URI
absPath, err := filepath.Abs(projectPath)
if err != nil {
return nil, fmt.Errorf("获取绝对路径失败: %v", err)
}
rootURI := fmt.Sprintf("%s", absPath)
return &LSPClient{
cmd: cmd,
conn: conn,
rootURI: rootURI,
}, nil
}
func (c *LSPClient) initialize() error {
params := map[string]interface{}{
"processId": os.Getpid(),
"clientInfo": map[string]string{
"name": "Lint",
"version": "0.1.0",
},
"locale": "zh-cn",
"rootPath": c.rootURI,
"rootUri": "file://" + c.rootURI,
"trace": "off",
}
var result interface{}
err := c.conn.Call(context.Background(), "initialize", params, &result)
if err != nil {
return fmt.Errorf("初始化失败: %v", err)
}
log.Println("LSP服务器初始化1")
params = map[string]interface{}{}
err = c.conn.Call(context.Background(), "initialized", params, nil)
if err != nil {
return fmt.Errorf("初始化失败: %v", err)
}
log.Println("LSP服务器初始化2")
return nil
}
func (c *LSPClient) findReferences(filename string, line, character int) ([]Location, error) {
// 确保文件路径是绝对路径
absFilename, err := filepath.Abs(filename)
if err != nil {
return nil, fmt.Errorf("获取文件绝对路径失败: %v", err)
}
params := map[string]interface{}{
"textDocument": map[string]string{
"uri": fmt.Sprintf("file://%s", absFilename),
},
"position": map[string]int{
"line": line,
"character": character,
},
"context": map[string]bool{
"includeDeclaration": true,
},
}
var locations []Location
fmt.Println("findReferences start")
err = c.conn.Call(context.Background(), "textDocument/references", params, &locations)
fmt.Println("findReferences finish")
if err != nil {
return nil, fmt.Errorf("查找引用失败: %v", err)
}
return locations, nil
}
func (c *LSPClient) shutdown() error {
return c.conn.Call(context.Background(), "shutdown", nil, nil)
}
func main() {
// 项目根目录
projectPath := "/home/xxx/app"
filename := "/home/xxx/app/common/rpc/rpc.go"
line := 113 // 方法所在行
character := 15 // 方法所在列
// 创建LSP客户端
client, err := newLSPClient(projectPath)
if err != nil {
log.Fatalf("创建LSP客户端失败: %v", err)
}
defer func() {
if err := client.shutdown(); err != nil {
log.Printf("关闭LSP服务器失败: %v", err)
}
}()
fmt.Println("newLSPClient finish")
// 初始化语言服务器
if err := client.initialize(); err != nil {
log.Fatalf("初始化LSP服务器失败: %v", err)
}
// 查找引用
references, err := client.findReferences(filename, line, character)
if err != nil {
log.Fatalf("查找引用失败: %v", err)
}
// 打印引用位置
fmt.Println("找到的引用数量:", len(references))
for _, ref := range references {
//根据行号获取函数定义
req := map[string]interface{}{
"textDocument": map[string]string{
"uri": ref.URI,
},
}
var syms []DocumentSymbol
err = client.conn.Call(context.Background(), "textDocument/documentSymbol", req, &syms)
if err != nil {
log.Fatalf("查找引用失败: %v", err)
}
for _, sym := range syms {
if sym.Location.Range.Start.Line <= ref.Range.Start.Line && sym.Location.Range.End.Line >= ref.Range.Start.Line {
content, err := ioutil.ReadFile(sym.Location.URI[7:])
if err != nil {
log.Printf("无法读取文件 %s: %v", sym.Location.URI, err)
continue
}
lines := strings.Split(string(content), "\n")
fmt.Printf("函数定义: %s\n", sym.Name)
for i := sym.Location.Range.Start.Line; i <= sym.Location.Range.End.Line && i < len(lines); i++ {
fmt.Printf("行 %d: %s\n", i+1, lines[i])
}
fmt.Println("---")
}
}
}
}
这个示例中,我们的headless
将会查找所有调用rpc.Call
的函数的函数体。
程序的输出大概如下:
行 137: func (m *Manager) xxx() {
//此处省略51行
行 188: rpc.Call(...) {
//此处省略15行
行 203: }
当我们要编写lint
工具时,可以再基于这些函数体来构造AST语法树进行lint
。
这会大大节省我们开发lint
工具的难度,并提升lint
工具的开发效率。
ps. 在我们使用jsonRPC
来调用language server
的过程中,还有可能会被language server
调用,因此func (h *handler) Handle(
函数才会无脑返回,不然我们jsonRPC
的call
也会被一直阻塞。