Language Server Protocol 的基本实现(脱离 vscode api)
参考资料
什么是 LSP
LSP 是一个基于 JSON-RPC 2.0 的协议,用于提供 IDE 功能,如代码补全、语法检查、跳转到定义等。
为什么要做
最近的一个项目里面基于 ace.js 提供了 C 和 C++ 代码的编辑器,需要提供变量、函数的定义和引用跳转,项目原本的方案是基于字符串匹配,但是随着代码量的增加,匹配的效率越来越低,重名变量也带来了准确性问题,所以需要一个更高效的方法来实现跳转。clangd 作为一个开源的 C++ 语言服务器,可以提供这些功能,所以需要基于 clangd 来实现一个简单的 LSP。
实现 LSP 的基本步骤
- 实现一个服务器,监听指定的端口,接收客户端的请求。
- 解析请求,执行相应的操作,并将结果返回给客户端。
- 实现一个客户端,发送请求给服务器,并解析服务器的响应。
- 实现一个编辑器,接收用户的输入,并将输入发送给服务器。
- 实现一个语言服务器,提供 IDE 功能,如代码补全、语法检查、跳转到定义等。
基于 clangd 实现简单的 LSP
-
下载解压 clangd 到一个文件夹下:D:/clangd/bin/clangd.exe
-
新建一个简单的 cpp 工程用作测试
main.cpp
int main() {
int a = 10;
loopPrint(a);
}
loopPrint.cpp
#include <iostream>
void loopPrint(int a) {
for (int i = 0; i < a; i++) {
std::cout << "Hello, World!" << std::endl;
}
}
- 创建一个 nodejs 项目并编码
连接 clangd 很简单,只需要用 spawn 启动即可
const { spawn } = require('child_process');
const clangdPath = 'D:/clangd/bin/clangd.exe';
const worker = spawn(clangdPath, ['--log=info']);
process.on('exit', () => {
worker.kill(0);
});
// print 是封装的一个用于输出的高阶函数
worker.stderr.on('data', print('stderr data'));
worker.stdout.on('data', print('stdout data'));
每条消息发送时都需要以下面的格式进行拼接
const message = {
jsonrpc: '2.0',
// id 并不是所有命令都要传的,具体可以看文档或者自己试一试
id: 1,
method: 'initialize',
// 即使是传一个空对象也是要传一个 params 的
params: {},
};
const data = JSON.stringify(message);
worker.stdin.write(`Content-Length: ${data.length}\r\n\r\n${data}`);
通过 initialize、initialized 两个消息来建立连接并完成初始化后,就可以开始发送其他命令了
在使用正式的操作命令前,需要用 didOpen 打开所有的项目文件,不然无法进行跳转等操作
const fs = require('fs');
const path = require('path');
const projectPath = 'D:/test';
const message = {
jsonrpc: '2.0',
// id 都必须是唯一的,所以这里我指定为 2,以后所有的 didOpen 操作都用这个 id
id: 2,
method: 'textDocument/didOpen',
params: {
textDocument: {
languageId: 'cpp',
uri: path.pathToFileURL(`${projectPath}/main.cpp`),
// 这里需要把文件内容也传过去
content: fs.readFileSync(`${projectPath}/main.cpp`, 'utf8'),
version: 1,
},
},
};
const data = JSON.stringify(message);
worker.stdin.write(`Content-Length: ${data.length}\r\n\r\n${data}`);
// 其他文件的打开也一样,我这里就不写了
这里项目也完成了初始化,可以尝试跳转了,这里查找 main.cpp 的中函数 loopPrint 的定义
const message = {
jsonrpc: '2.0',
id: 3,
method: 'textDocument/definition',
params: {
textDocument: {
uri: path.pathToFileURL(`${projectPath}/main.cpp`),
},
// 这里需要指定一下要跳转变量的位置
// 行列都是从 0 开始计数的
position: {
line: 2,
character: 2,
},
},
};
const data = JSON.stringify(message);
worker.stdin.write(`Content-Length: ${data.length}\r\n\r\n${data}`);
- 代码编写完成,运行 nodejs 项目,就可以看到跳转的结果了
我这里忽略其他的消息,主要是看跳转的这条返回消息,clangd 返回的消息也会携带 Content-Length 信息,注意字符串的处理
{
"id": 6,
"jsonrpc": "2.0",
"result": [
{
"range": {
"end": { "character": 14, "line": 2 },
"start": { "character": 5, "line": 2 }
},
"uri": "file:///D:/test/loopPrint.cpp"
}
]
}
自此后端操作 clangd 的实现就基本完成了,只需启动一个 websocket 服务,根据前端的需要自行组织数据进行前后端通信,即可实现一个简单的 LSP
注意事项
-
需要用 spawn 启动 clangd,不能用 fork,因为 fork 出来的子进程无法与父进程共享内存,会导致一些问题
-
需要用 Content-Length 指定消息的长度,因为 clangd 需要根据消息的长度来解析消息
-
最好是用队列的形式跟 clangd 进行通信,在接收到一次返回后再发送新的操作消息,不然可能会出现乱序的情况,而且 clangd 会将多条消息拼接在一起进行返回,这样会增加解析的难度