Language Server Protocol 的基本实现(脱离 vscode api)

参考资料

Language Server Protocol

什么是 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

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 会将多条消息拼接在一起进行返回,这样会增加解析的难度


为什么我觉得vue难
新手也可以看懂无感 FOC(一)