简单调试语言服务器

在上一章中,我们实现了一个最小的语言服务器的客户端和服务端,它们没有任何功能,但是我们可以用它们来简单调试其他实际的语言服务器。

语言服务器的实现列表 中找到了 typescript-language-server,我们来尝试调试它。

min-lsp-example 项目中,执行:

npm i typescript typescript-language-server

然后修改 client.js:

-const ls = spawn('node', ['server.js']);
+const ls = spawn('node', ['node_modules/typescript-language-server/lib/cli.mjs', '--stdio']);

运行:

node client.js

会产生如下输出:

===> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":53519,"rootUri":null,"capabilities":{}}}
<=== {"jsonrpc":"2.0","method":"window/logMessage","params":{"type":3,"message":"Using Typescript version (bundled) 5.5.2 from path \"/Users/renwei/Code/min-lsp-example/node_modules/typescript/lib/tsserver.js\""}}
<=== {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"textDocumentSync":2,"completionProvider":{"triggerCharacters":[".","\"","'","/","@","<"],"resolveProvider":true},"codeActionProvider":true,"codeLensProvider":{"resolveProvider":true},"definitionProvider":true,"documentFormattingProvider":true,"documentRangeFormattingProvider":true,"documentHighlightProvider":true,"documentSymbolProvider":true,"executeCommandProvider":{"commands":["_typescript.applyWorkspaceEdit","_typescript.applyCodeAction","_typescript.applyRefactoring","_typescript.configurePlugin","_typescript.organizeImports","_typescript.applyRenameFile","_typescript.goToSourceDefinition"]},"hoverProvider":true,"inlayHintProvider":true,"linkedEditingRangeProvider":false,"renameProvider":true,"referencesProvider":true,"selectionRangeProvider":true,"signatureHelpProvider":{"triggerCharacters":["(",",","<"],"retriggerCharacters":[")"]},"workspaceSymbolProvider":true,"implementationProvider":true,"typeDefinitionProvider":true,"foldingRangeProvider":true,"semanticTokensProvider":{"documentSelector":null,"legend":{"tokenTypes":["class","enum","interface","namespace","typeParameter","type","parameter","variable","enumMember","property","function","member"],"tokenModifiers":["declaration","static","async","readonly","defaultLibrary","local"]},"full":true,"range":true},"workspace":{"fileOperations":{"willRename":{"filters":[{"scheme":"file","pattern":{"glob":"**/*.{ts,js,jsx,tsx,mjs,mts,cjs,cts}","matches":"file"}},{"scheme":"file","pattern":{"glob":"**","matches":"folder"}}]}}}}}}
===> {"jsonrpc":"2.0","method":"initialized","params":{}}
<=== {"jsonrpc":"2.0","method":"$/typescriptVersion","params":{"version":"5.5.2","source":"bundled"}}
===> {"jsonrpc":"2.0","id":2,"method":"shutdown"}
<=== {"jsonrpc":"2.0","id":2,"result":null}
===> {"jsonrpc":"2.0","method":"exit"}
child process exited with code 0

我们的客户端与 typescript-language-server 服务器成功完成了一个完整的生命周期。

向服务器发送 textDocument/didOpen:

function dealMessage(data) {
    let response = JSON.parse(data)
    console.log("<===", data);
    if (response.id === 1) {
        send(protocol.getNotification("initialized", {}))
        setTimeout(() => {
            send(protocol.getRequest("shutdown"))
            shutdownId = protocol.getId();
        }, 1000);
+        send(protocol.getNotification("textDocument/didOpen", {
+            textDocument: {
+                uri: "file://test.ts",
+                languageId: "typescript",
+                version: 1,
+                text: "/** This is a function */\nfunction test(first: string) {}"
+            }
+        }))
    } else if (response.id === shutdownId) {
        send(protocol.getNotification("exit"))
    }
}

下面我们展示向服务器发送一个 hover 请求并演示如何阅读文档:

  1. 找到 Hover 请求 的文档。

  2. 找到其 客户端能力(Client capability) 部分,我们看到属性路径为 textDocument.hover,属性类型为 HoverClientCapabilities,在初始化请求中加入此能力的支持:

send(protocol.getRequest("initialize", {
    processId: process.pid,
    rootUri: null,
    capabilities: {
+        textDocument: { hover: {} }
    }
}))

如果你要开发的是服务端,那么同样找到 服务端能力(Server capability) 部分,我们看到属性路径为 hoverProvider, 属性类型为 boolean | HoverOptions,在初始化请求的响应中为服务端添加此能力的支持:

    if (message.method === 'initialize') {
        process.stdout.write(protocol.getResponse(message.id, {
            capabilities: {
+                hoverProvider: true
            }
        }))
    }
  1. 请求(Request) 部分,可以找到,方法的参数 textDocument/hover 和类型 HoverParams,此处我们在发送 textDocument/didOpen 通知之后立即发送 hover 请求:
function dealMessage(data) {
    let response = JSON.parse(data)
    console.log("<===", data);
    if (response.id === 1) {
        send(protocol.getNotification("initialized", {}))
        setTimeout(() => {
            send(protocol.getRequest("shutdown"))
            shutdownId = protocol.getId();
        }, 1000);
        send(protocol.getNotification("textDocument/didOpen", {
            textDocument: {
                uri: "file://test.ts",
                languageId: "typescript",
                version: 1,
                text: "/** This is a function */\nfunction test(first: string) {}"
            }
        }))
+        send(protocol.getRequest("textDocument/hover", {
+            textDocument: { uri: "file://test.ts" },
+            position: { line: 1, character: 10 }
+        }))
    } else if (response.id === shutdownId) {
        send(protocol.getNotification("exit"))
    }
}

同样的,对于开发服务器,在 响应(Response) 部分,定义了返回值的类型 Hover | null,在这里,我们直接返回固定值:

function dealMessage(data) {
    let message = JSON.parse(data)
    if (message.method === 'initialize') {
        process.stdout.write(protocol.getResponse(message.id, {
            capabilities: {
                hoverProvider: true
            }
        }))
+    } else if (message.method === 'textDocument/hover') {
+        process.stdout.write(protocol.getResponse(message.id, {
+            contents: {
+                kind: "plaintext",
+                value: "test"
+            },
+        }))
    } else if (message.method === 'shutdown') {
        shutdown = true;
        process.stdout.write(protocol.getResponse(message.id, null))
    } else if (message.method === 'exit') {
        if (shutdown) {
            process.exit(0)
        } else {
            process.exit(1)
        }
    }
}

对于基础类型的定义,可以在 基础 JSON 结构 中找到,例如 TextDocumentPositionParams, MarkupContent

最后,下面是完整的修改后的客户端代码:

const { spawn } = require('child_process');
const protocol = require('./protocol');
const ls = spawn('node', ['node_modules/typescript-language-server/lib/cli.mjs', '--stdio']);

send(protocol.getRequest("initialize", {
    processId: process.pid,
    rootUri: null,
    capabilities: {
        textDocument: { hover: {} }
    }
}))

let length = 0

ls.stdout.on('data', (data) => {
    data = data.toString()
    let arr = data.split("Content-Length: ")
    if (arr[0] && arr[0].length === length) {
        dealMessage(arr[0])
    }

    for (let i = 1; i < arr.length; i++) {
        let value = arr[i].split("\r\n\r\n")[1]
        if (value) {
            dealMessage(value)
        } else {
            length = Number(arr[i].split("\r\n\r\n")[0])
        }
    }
});

ls.stderr.on("data", (data) => {
    console.error(`${data}`);
})

ls.on('close', (code) => {
    console.log(`child process exited with code ${code}`);
});


function send(data) {
    console.log("===>", data.split("\r\n\r\n")[1]);
    ls.stdin.write(data)
}

let shutdownId = 0;

function dealMessage(data) {
    let response = JSON.parse(data)
    console.log("<===", data);
    if (response.id === 1) {
        send(protocol.getNotification("initialized", {}))
        setTimeout(() => {
            send(protocol.getRequest("shutdown"))
            shutdownId = protocol.getId();
        }, 1000);
        send(protocol.getNotification("textDocument/didOpen", {
            textDocument: {
                uri: "file://test.ts",
                languageId: "typescript",
                version: 1,
                text: "/** This is a function */\nfunction test(first: string) {}"
            }
        }))
        send(protocol.getRequest("textDocument/hover", {
            textDocument: { uri: "file://test.ts" },
            position: { line: 1, character: 10 }
        }))
    } else if (response.id === shutdownId) {
        send(protocol.getNotification("exit"))
    }
}

现在,我们可以运行客户端:

node client.js

输出:

===> {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":56553,"rootUri":null,"capabilities":{"textDocument":{"hover":{}}}}}
<=== {"jsonrpc":"2.0","method":"window/logMessage","params":{"type":3,"message":"Using Typescript version (bundled) 5.5.2 from path \"/Users/renwei/Code/min-lsp-example/node_modules/typescript/lib/tsserver.js\""}}
<=== {"jsonrpc":"2.0","id":1,"result":{"capabilities":{"textDocumentSync":2,"completionProvider":{"triggerCharacters":[".","\"","'","/","@","<"],"resolveProvider":true},"codeActionProvider":true,"codeLensProvider":{"resolveProvider":true},"definitionProvider":true,"documentFormattingProvider":true,"documentRangeFormattingProvider":true,"documentHighlightProvider":true,"documentSymbolProvider":true,"executeCommandProvider":{"commands":["_typescript.applyWorkspaceEdit","_typescript.applyCodeAction","_typescript.applyRefactoring","_typescript.configurePlugin","_typescript.organizeImports","_typescript.applyRenameFile","_typescript.goToSourceDefinition"]},"hoverProvider":true,"inlayHintProvider":true,"linkedEditingRangeProvider":false,"renameProvider":true,"referencesProvider":true,"selectionRangeProvider":true,"signatureHelpProvider":{"triggerCharacters":["(",",","<"],"retriggerCharacters":[")"]},"workspaceSymbolProvider":true,"implementationProvider":true,"typeDefinitionProvider":true,"foldingRangeProvider":true,"semanticTokensProvider":{"documentSelector":null,"legend":{"tokenTypes":["class","enum","interface","namespace","typeParameter","type","parameter","variable","enumMember","property","function","member"],"tokenModifiers":["declaration","static","async","readonly","defaultLibrary","local"]},"full":true,"range":true},"workspace":{"fileOperations":{"willRename":{"filters":[{"scheme":"file","pattern":{"glob":"**/*.{ts,js,jsx,tsx,mjs,mts,cjs,cts}","matches":"file"}},{"scheme":"file","pattern":{"glob":"**","matches":"folder"}}]}}}}}}
===> {"jsonrpc":"2.0","method":"initialized","params":{}}
===> {"jsonrpc":"2.0","method":"textDocument/didOpen","params":{"textDocument":{"uri":"file://test.ts","languageId":"typescript","version":1,"text":"/** This is a function */\nfunction test(first: string) {}"}}}
===> {"jsonrpc":"2.0","id":2,"method":"textDocument/hover","params":{"textDocument":{"uri":"file://test.ts"},"position":{"line":1,"character":10}}}
<=== {"jsonrpc":"2.0","method":"$/typescriptVersion","params":{"version":"5.5.2","source":"bundled"}}
<=== {"jsonrpc":"2.0","id":2,"result":{"contents":{"kind":"markdown","value":"\n```typescript\nfunction test(first: string): void\n```\nThis is a function"},"range":{"start":{"line":1,"character":9},"end":{"line":1,"character":13}}}}
===> {"jsonrpc":"2.0","id":3,"method":"shutdown"}
<=== {"jsonrpc":"2.0","id":3,"result":null}
===> {"jsonrpc":"2.0","method":"exit"}
child process exited with code 0

我们在第 6 行,找到了 hover 请求, 在第 8 行可以找到它的响应。

现在,我们完成了一个简单的调试,你也可以为 client.js 增加更多的功能来调试你自己的语言服务器。

你也可以使用现存的库来调试: lsp-tester