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

参考资料

什么是 LSP

LSP 是一个基于 JSON-RPC 2.0 的协议,用于提供 IDE 功能,如代码补全、语法检查、跳转到定义等。

为什么要做

最近的一个项目里面基于 ace.js 提供了 C 和 C++ 代码的编辑器,需要提供变量、函数的定义和引用跳转,项目原本的方案是基于字符串匹配,但是随着代码量的增加,匹配的效率越来越低,重名变量也带来了准确性问题,所以需要一个更高效的方法来实现跳转。clangd 作为一个开源的 C++ 语言服务器,可以提供这些功能,所以需要基于 clangd 来实现一个简单的 LSP。

实现 LSP 的基本步骤

  1. 实现一个服务器,监听指定的端口,接收客户端的请求。
  2. 解析请求,执行相应的操作,并将结果返回给客户端。
  3. 实现一个客户端,发送请求给服务器,并解析服务器的响应。
  4. 实现一个编辑器,接收用户的输入,并将输入发送给服务器。
  5. 实现一个语言服务器,提供 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

注意事项

  1. 需要用 spawn 启动 clangd,不能用 fork,因为 fork 出来的子进程无法与父进程共享内存,会导致一些问题

  2. 需要用 Content-Length 指定消息的长度,因为 clangd 需要根据消息的长度来解析消息

  3. 最好是用队列的形式跟 clangd 进行通信,在接收到一次返回后再发送新的操作消息,不然可能会出现乱序的情况,而且 clangd 会将多条消息拼接在一起进行返回,这样会增加解析的难度

posted @ 2024-03-25 10:59  彩铅  阅读(625)  评论(1编辑  收藏  举报