如何基于LanguageServerProtocol来编写lint工具

TLDR: 本文并没有任何设计思想,只是为编写基于LSP协议的自定义lint工具留下一丝线索,以便未来使用。

在最近这两年里,我断断续续在项目内写过不少针对项目内go代码的lint工具和代码生成器。

每次在编写lint工具时,就会发现如果能有语义的辅助就可以将lint做得更完美。

然而语义分析的复杂度和开发成本都太高了,所以每次最终都做成了基于AST的简易lint工具。

我时常在想,其实我不需要开发复杂的语义分析功能,只要我可以像vs-code那样,可以查找所有实现, 查找函数定义, 查找符号的所有引用, 我们编写出的lint工具的上限就会大大提高。

幸运的是我们只要编写一个headless的LSP客户端,便可以拥有vs-code中所有关于语义的能力。

这个想法其实我已经萌生了一年多,但一直没有动力和时间去尝试。

直到最近,我终于下定决心尝试一下。

虽然LSP有完整完善的文档,但是有时太全了也是一种负担。

因此这里还是记录一下常用的几个textDocument请求参数:

  1. 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}"
    }
    }
  1. 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!\")"
      }
      ]
      }
  2. textDocument/didClose:

  • 用处: 当一个文件被关闭时,通知gopls来释放对该文件的语言服务支持。这有助于释放资源并清理掉不再使用的文档信息。
    {
    "textDocument": {
    "uri": "file:///path/to/your/file.go"
    }
    }
  1. textDocument/hover:

    • 用处: 请求文档中的某个位置的悬停信息,通常用于显示鼠标悬停在一个标识符上时的相关帮助内容,如类型信息、文档注释等。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "position": {
      "line": 3,
      "character": 5
      }
      }
  2. textDocument/signatureHelp:

    • 用处: 请求函数签名的帮助信息,当光标停在一个函数的定义上时,会返回函数签名的详细信息和参数描述。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "position": {
      "line": 5,
      "character": 10
      }
      }
  3. textDocument/documentSymbol:

    • 用处: 获取文件中的所有符号信息,包括函数、类型定义等,这对于导航代码结构非常有帮助。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      }
      }
  4. textDocument/references:

    • 用处: 查找文档中某个标识符的引用,以帮助开发者了解代码中的使用情况。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "position": {
      "line": 7,
      "character": 0
      }
      }
  5. textDocument/definition:

    • 用处: 请求一个标识符的定义位置,以便快速导航到其实现位置。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "position": {
      "line": 8,
      "character": 5
      }
      }
  6. textDocument/codeAction:

    • 用处: 提供可供用户选择的代码修复建议,如重构、添加注释、优化等。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "range": {
      "start": { "line": 10, "character": 0 },
      "end": { "line": 10, "character": 5 }
      },
      "context": {
      "diagnostics": []
      }
      }
  7. textDocument/codeLens:

    • 用处: 请求代码镜头(Code Lens)信息,如测试覆盖率、TODO 标记等,这些信息直接显示在代码行的旁边。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      }
      }
  8. 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(函数才会无脑返回,不然我们jsonRPCcall也会被一直阻塞。

发表评论

− one = 6