LSP 开发指南 / 第 2 章:协议基础
2.1 JSON-RPC 2.0 基础
LSP 建立在 JSON-RPC 2.0 协议之上。理解 JSON-RPC 是掌握 LSP 的前提。
2.1.1 JSON-RPC 2.0 核心概念
JSON-RPC 是一种轻量级的远程过程调用(RPC)协议,使用 JSON 作为数据格式:
| 特性 | 说明 |
|---|---|
| 消息格式 | 纯 JSON 文本 |
| 传输层 | 无限制(TCP、HTTP、stdio、WebSocket 等) |
| 版本标识 | jsonrpc: "2.0" |
| 请求 ID | 用于关联请求与响应 |
2.1.2 三种消息类型
Request(请求):需要对方返回响应
{
"jsonrpc": "2.0",
"id": 1,
"method": "textDocument/completion",
"params": {
"textDocument": { "uri": "file:///home/user/main.py" },
"position": { "line": 10, "character": 15 }
}
}
Response(响应):对请求的回复
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"isIncomplete": false,
"items": [
{
"label": "print",
"kind": 3,
"detail": "built-in function print"
}
]
}
}
Notification(通知):单向消息,无需响应(无 id 字段)
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": "file:///home/user/main.py",
"diagnostics": []
}
}
2.2 LSP 消息格式
2.2.1 消息头(Header)
LSP 消息在 JSON-RPC 基础上增加了 HTTP 风格的消息头,用于声明消息体长度:
Content-Length: 125\r\n
\r\n
{"jsonrpc":"2.0","id":1,"method":"textDocument/completion","params":{...}}
关键规则:
| 规则 | 说明 |
|---|---|
Content-Length | 必须,消息体的字节数(不是字符数) |
Content-Type | 可选,默认 application/vscode-jsonrpc; charset=utf-8 |
| 分隔符 | 头部与消息体之间使用 \r\n\r\n 分隔 |
| 编码 | 消息体必须是 UTF-8 编码 |
2.2.2 消息解析示例
以下 TypeScript 代码演示如何从原始字节流中解析 LSP 消息:
import { createInterface } from "readline";
interface LSPMessage {
id?: number | string;
method?: string;
params?: any;
result?: any;
error?: any;
}
/**
* 从 stdin 读取并解析 LSP 消息
*/
function createMessageReader(input: NodeJS.ReadStream) {
let buffer = Buffer.alloc(0);
let contentLength = -1;
const listeners: Array<(msg: LSPMessage) => void> = [];
input.on("data", (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
while (true) {
// 尝试解析 Content-Length 头
if (contentLength === -1) {
const headerEnd = buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) break; // 头部不完整
const header = buffer.slice(0, headerEnd).toString("utf-8");
const match = header.match(/Content-Length:\s*(\d+)/);
if (!match) {
console.error("Invalid header:", header);
break;
}
contentLength = parseInt(match[1], 10);
buffer = buffer.slice(headerEnd + 4); // 跳过 \r\n\r\n
}
// 检查消息体是否完整
if (buffer.length < contentLength) break;
const body = buffer.slice(0, contentLength).toString("utf-8");
buffer = buffer.slice(contentLength);
contentLength = -1;
try {
const message: LSPMessage = JSON.parse(body);
listeners.forEach((fn) => fn(message));
} catch (e) {
console.error("Failed to parse message:", e);
}
}
});
return {
onMessage(fn: (msg: LSPMessage) => void) {
listeners.push(fn);
},
};
}
// 使用
const reader = createMessageReader(process.stdin);
reader.onMessage((msg) => {
console.log("Received:", msg.method ?? `response id=${msg.id}`);
});
2.2.3 消息发送
function sendMessage(output: NodeJS.WriteStream, message: object): void {
const body = JSON.stringify(message);
const header = `Content-Length: ${Buffer.byteLength(body, "utf-8")}\r\n\r\n`;
output.write(header + body);
}
// 发送请求
sendMessage(process.stdout, {
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
processId: process.pid,
rootUri: "file:///home/user/project",
capabilities: {},
},
});
// 发送通知
sendMessage(process.stdout, {
jsonrpc: "2.0",
method: "initialized",
params: {},
});
2.3 传输层
LSP 不绑定特定传输方式,官方规范支持以下几种:
2.3.1 传输方式对比
| 传输方式 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| stdio | 桌面编辑器(VS Code、Neovim) | 最简单、零配置 | 只能单客户端 |
| TCP | 远程开发、调试 | 支持远程连接 | 需要端口管理 |
| WebSocket | Web IDE(Monaco、CodeMirror) | 浏览器兼容 | 需要 ws 库 |
| Pipe(命名管道) | Windows 环境 | 跨进程通信 | 平台相关 |
| Node IPC | VS Code 扩展 | 高性能 | 仅限 Node.js |
2.3.2 stdio 模式
最常用的传输方式。Server 从 stdin 读取消息,向 stdout 写入消息:
// my-lsp-server.ts
import * as rpc from "vscode-jsonrpc/node";
// 创建基于 stdio 的连接
const connection = rpc.createMessageConnection(
new rpc.StreamMessageReader(process.stdin),
new rpc.StreamMessageWriter(process.stdout)
);
// 注册请求处理器
connection.onRequest("initialize", (params) => {
console.error("Client connected!"); // 调试信息输出到 stderr
return {
capabilities: {
textDocumentSync: 1,
completionProvider: { triggerCharacters: ["."] },
},
};
});
// 启动连接
connection.listen();
⚠️ 关键注意事项:stdio 模式下,
stdout用于协议通信,调试日志必须输出到stderr,否则会破坏协议消息流。
2.3.3 TCP 模式
Server 监听一个 TCP 端口,Client 通过 TCP 连接通信:
import * as net from "net";
import * as rpc from "vscode-jsonrpc/node";
const server = net.createServer((socket) => {
const connection = rpc.createMessageConnection(
new rpc.StreamMessageReader(socket),
new rpc.StreamMessageWriter(socket)
);
connection.onRequest("initialize", (params) => ({
capabilities: { textDocumentSync: 1 },
}));
connection.listen();
});
server.listen(2087, "127.0.0.1", () => {
console.error("LSP server listening on port 2087");
});
2.3.4 WebSocket 模式
适合 Web IDE 场景:
import { WebSocketServer } from "ws";
import * as rpc from "vscode-jsonrpc/node";
const wss = new WebSocketServer({ port: 3000 });
wss.on("connection", (ws) => {
// 将 WebSocket 包装为可读/可写流
const reader = new rpc.WebSocketMessageReader(ws as any);
const writer = new rpc.WebSocketMessageWriter(ws as any);
const connection = rpc.createMessageConnection(reader, writer);
connection.onRequest("initialize", (params) => ({
capabilities: { textDocumentSync: 1 },
}));
connection.listen();
});
2.4 请求、响应与通知的生命周期
2.4.1 交互流程图
Client Server
│ │
│──── initialize (Request) ────────▶│
│ │
│◀─── InitializeResult (Response) ──│
│ │
│──── initialized (Notification) ──▶│
│ │
│──── textDocument/didOpen ────────▶│
│ │
│◀─── publishDiagnostics ───────────│
│ │
│──── textDocument/completion ─────▶│
│ │
│◀─── CompletionList (Response) ────│
│ │
│──── shutdown (Request) ──────────▶│
│ │
│◀─── null (Response) ─────────────│
│ │
│──── exit (Notification) ─────────▶│
│ │
2.4.2 错误响应格式
当请求处理失败时,Server 返回错误响应:
{
"jsonrpc": "2.0",
"id": 1,
"error": {
"code": -32600,
"message": "Invalid Request",
"data": {
"detail": "Missing required parameter: textDocument"
}
}
}
2.4.3 标准错误码
LSP 继承了 JSON-RPC 的错误码,并扩展了自定义错误码:
| 错误码 | 名称 | 说明 |
|---|---|---|
| -32700 | ParseError | JSON 解析失败 |
| -32600 | InvalidRequest | 请求格式无效 |
| -32601 | MethodNotFound | 方法不存在 |
| -32602 | InvalidParams | 参数无效 |
| -32603 | InternalError | 内部错误 |
| -32800 | RequestCancelled | 请求已被取消 |
| -32801 | ContentModified | 内容已被修改 |
2.5 基础设施类型
LSP 定义了大量基础设施类型(Base Protocol Types),所有消息都建立在这些类型之上:
2.5.1 常用基础类型
// URI 字符串(统一资源标识符)
type DocumentUri = string; // "file:///path/to/file"
// 位置(文档中的一个点)
interface Position {
line: number; // 行号(从 0 开始)
character: number; // 列号(从 0 开始,UTF-16 偏移)
}
// 范围(文档中的一段区域)
interface Range {
start: Position;
end: Position;
}
// 位置与 URI
interface Location {
uri: DocumentUri;
range: Range;
}
// 文本编辑
interface TextEdit {
range: Range;
newText: string;
}
⚠️ 注意:LSP 中行号和列号都是 从 0 开始 的,而很多编辑器(如 Vim 默认配置)从 1 开始。Client 需要负责转换。
2.5.2 Position 的 UTF-16 偏移问题
LSP 规范中 character 使用 UTF-16 code unit 作为偏移单位,这对于多字节字符(如中文、emoji)会有影响:
字符串: "你好world"
字节偏移: 0 3 6 7 8 9 10 11 12 (UTF-8)
UTF-16: 0 1 2 3 4 5 6 7 8
字符: '你' '好' 'w' 'o' 'r' 'l' 'd'
许多 Server 端库已经处理了这个差异,但自己实现时需要注意。
💻 完整示例:最小 LSP Server
以下是一个仅使用 Node.js 内置模块的最小 LSP Server:
#!/usr/bin/env node
/**
* 最小 LSP Server(零依赖)
* 功能:响应 initialize 请求
*/
interface Message {
jsonrpc: "2.0";
id?: number | string;
method?: string;
params?: any;
result?: any;
error?: any;
}
let buffer = "";
process.stdin.setEncoding("utf-8");
process.stdin.on("data", (chunk: string) => {
buffer += chunk;
parseMessages();
});
function parseMessages(): void {
while (true) {
const headerEnd = buffer.indexOf("\r\n\r\n");
if (headerEnd === -1) return;
const header = buffer.substring(0, headerEnd);
const match = header.match(/Content-Length:\s*(\d+)/);
if (!match) return;
const contentLength = parseInt(match[1], 10);
const messageStart = headerEnd + 4;
if (Buffer.byteLength(buffer.substring(messageStart), "utf-8") < contentLength) {
return;
}
const body = buffer.substring(messageStart, messageStart + contentLength);
buffer = buffer.substring(messageStart + contentLength);
const message: Message = JSON.parse(body);
handleMessage(message);
}
}
function handleMessage(msg: Message): void {
if (msg.method === "initialize") {
sendResponse(msg.id!, {
capabilities: {
textDocumentSync: 1, // Full sync
completionProvider: {
triggerCharacters: ["."],
},
hoverProvider: true,
},
});
} else if (msg.method === "initialized") {
// Client 已就绪
process.stderr.write("Server initialized!\n");
} else if (msg.method === "shutdown") {
sendResponse(msg.id!, null);
} else if (msg.method === "exit") {
process.exit(0);
}
}
function sendResponse(id: number | string, result: any): void {
const response: Message = {
jsonrpc: "2.0",
id,
result,
};
const body = JSON.stringify(response);
const header = `Content-Length: ${Buffer.byteLength(body, "utf-8")}\r\n\r\n`;
process.stdout.write(header + body);
}
保存为 server.ts,使用 ts-node 或编译后运行即可。
⚠️ 注意事项
| 问题 | 解决方案 |
|---|---|
| stdout 被日志污染 | 所有调试日志必须输出到 stderr |
| 消息截断 | 必须基于 Content-Length 精确切分消息 |
| 中文字符计数 | 注意 UTF-16 偏移与字节偏移的差异 |
| 大消息超时 | 实现超时机制,避免无限等待 |
🔗 扩展阅读
下一章:第 3 章:生命周期 — 初始化、能力协商、关闭与重连机制。