强曰为道
与天地相似,故不违。知周乎万物,而道济天下,故不过。旁行而不流,乐天知命,故不忧.
文档目录

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远程开发、调试支持远程连接需要端口管理
WebSocketWeb IDE(Monaco、CodeMirror)浏览器兼容需要 ws 库
Pipe(命名管道)Windows 环境跨进程通信平台相关
Node IPCVS 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 的错误码,并扩展了自定义错误码:

错误码名称说明
-32700ParseErrorJSON 解析失败
-32600InvalidRequest请求格式无效
-32601MethodNotFound方法不存在
-32602InvalidParams参数无效
-32603InternalError内部错误
-32800RequestCancelled请求已被取消
-32801ContentModified内容已被修改

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 章:生命周期 — 初始化、能力协商、关闭与重连机制。