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

LSP 开发指南 / 第 4 章:文本同步

4.1 文本同步概述

文本同步是 LSP 最基础的能力——Server 需要知道 Client 侧文档的当前状态,才能提供准确的语言服务。LSP 提供了三种同步模式:

同步模式说明
None0Server 不接收文档变更通知
Full1每次变更发送完整文档内容
Incremental2每次变更只发送差异部分

Server 在 initialize 响应中声明同步模式:

{
  "capabilities": {
    "textDocumentSync": {
      "openClose": true,
      "change": 2,
      "willSave": false,
      "willSaveWaitUntil": false,
      "save": { "includeText": true }
    }
  }
}

4.1.1 同步模式对比

特性Full (1)Incremental (2)
网络传输量每次发送完整文档只发送变更范围
Server 实现复杂度简单(直接替换)中等(需要应用 TextEdit)
Client 实现复杂度简单中等(需要计算 diff)
适用场景小文件/简单 Server大文件/性能敏感场景
一致性保障需要正确应用增量

4.2 文档打开与关闭

4.2.1 textDocument/didOpen

当用户在编辑器中打开一个文件时,Client 发送 didOpen 通知:

{
  "jsonrpc": "2.0",
  "method": "textDocument/didOpen",
  "params": {
    "textDocument": {
      "uri": "file:///home/user/project/src/main.py",
      "languageId": "python",
      "version": 1,
      "text": "import os\nimport sys\n\ndef main():\n    print('Hello')\n"
    }
  }
}

参数说明

字段类型说明
uriDocumentUri文档唯一标识符
languageIdstring语言标识(如 pythontypescript
versioninteger文档版本号(每次变更递增)
textstring文档完整内容

4.2.2 textDocument/didClose

当用户关闭文档时,Client 发送 didClose 通知:

{
  "jsonrpc": "2.0",
  "method": "textDocument/didClose",
  "params": {
    "textDocument": {
      "uri": "file:///home/user/project/src/main.py"
    }
  }
}

4.2.3 Server 端文档管理

interface TextDocumentItem {
  uri: string;
  languageId: string;
  version: number;
  text: string;
}

class DocumentManager {
  private documents = new Map<string, TextDocumentItem>();

  open(doc: TextDocumentItem): void {
    this.documents.set(doc.uri, { ...doc });
    console.error(`Opened: ${doc.uri} (${doc.languageId})`);
  }

  close(uri: string): void {
    this.documents.delete(uri);
    console.error(`Closed: ${uri}`);
  }

  get(uri: string): TextDocumentItem | undefined {
    return this.documents.get(uri);
  }

  getAll(): TextDocumentItem[] {
    return Array.from(this.documents.values());
  }
}

const docManager = new DocumentManager();

connection.onNotification("textDocument/didOpen", (params) => {
  docManager.open(params.textDocument);
});

connection.onNotification("textDocument/didClose", (params) => {
  docManager.close(params.textDocument.uri);
});

4.3 Full 同步模式

4.3.1 消息格式

Full 模式下,Client 在每次文档变更时发送完整的新内容:

{
  "jsonrpc": "2.0",
  "method": "textDocument/didChange",
  "params": {
    "textDocument": {
      "uri": "file:///home/user/project/src/main.py",
      "version": 5
    },
    "contentChanges": [
      {
        "text": "import os\nimport sys\nimport json\n\ndef main():\n    print('Hello World')\n"
      }
    ]
  }
}

4.3.2 Server 实现

connection.onNotification("textDocument/didChange", (params) => {
  const { uri, version } = params.textDocument;
  const fullText = params.contentChanges[0].text;

  // 直接替换整个文档内容
  const doc = docManager.get(uri);
  if (doc) {
    doc.text = fullText;
    doc.version = version;
  }

  // 重新分析文档
  reanalyzeDocument(uri);
});

4.4 Incremental 同步模式

4.4.1 消息格式

Incremental 模式下,Client 只发送变更的部分:

{
  "jsonrpc": "2.0",
  "method": "textDocument/didChange",
  "params": {
    "textDocument": {
      "uri": "file:///home/user/project/src/main.py",
      "version": 5
    },
    "contentChanges": [
      {
        "range": {
          "start": { "line": 1, "character": 7 },
          "end": { "line": 1, "character": 10 }
        },
        "rangeLength": 3,
        "text": "json"
      }
    ]
  }
}

这个消息表示:将第 2 行(0-indexed)第 7-10 列的 sys 替换为 json

4.4.2 增量应用实现

interface TextEdit {
  range: {
    start: { line: number; character: number };
    end: { line: number; character: number };
  };
  rangeLength?: number;
  text: string;
}

function applyIncrementalChange(
  originalText: string,
  edit: TextEdit
): string {
  const lines = originalText.split("\n");
  const { start, end } = edit.range;

  // 取出 range 前的部分、新文本、range 后的部分
  const prefix =
    lines.slice(0, start.line).join("\n") +
    (start.line > 0 ? "\n" : "") +
    lines[start.line].substring(0, start.character);

  const suffix =
    lines[end.line].substring(end.character) +
    (end.line < lines.length - 1 ? "\n" : "") +
    lines.slice(end.line + 1).join("\n");

  return prefix + edit.text + suffix;
}

// 在 didChange 处理器中使用
connection.onNotification("textDocument/didChange", (params) => {
  const { uri, version } = params.textDocument;
  const doc = docManager.get(uri);
  if (!doc) return;

  let text = doc.text;
  for (const change of params.contentChanges) {
    if ("range" in change) {
      // 增量变更
      text = applyIncrementalChange(text, change);
    } else {
      // 全量替换(fallback)
      text = change.text;
    }
  }

  doc.text = text;
  doc.version = version;
});

4.4.3 版本号管理

版本号是保证 Client 和 Server 文档状态一致性的关键:

class VersionedDocumentManager {
  private documents = new Map<string, TextDocumentItem>();

  applyChange(uri: string, version: number, changes: TextEdit[]): boolean {
    const doc = this.documents.get(uri);
    if (!doc) return false;

    // 版本号必须严格递增
    if (version <= doc.version) {
      console.error(
        `Version mismatch for ${uri}: expected > ${doc.version}, got ${version}`
      );
      return false;
    }

    // 应用变更...
    doc.version = version;
    return true;
  }
}
规则说明
版本号从 1 开始didOpen 时设置初始版本
版本号严格递增每次 didChange 必须大于当前版本
版本号可能跳跃Client 可以合并多次快速编辑为一次通知
版本号不可回退收到更小的版本号应忽略

4.5 文档保存

4.5.1 textDocument/didSave

当用户保存文档时,Client 发送 didSave 通知:

{
  "jsonrpc": "2.0",
  "method": "textDocument/didSave",
  "params": {
    "textDocument": {
      "uri": "file:///home/user/project/src/main.py"
    },
    "text": "import os\nimport json\n\ndef main():\n    print('Hello World')\n"
  }
}

Server 声明是否需要接收保存时的文本:

{
  "textDocumentSync": {
    "save": {
      "includeText": true
    }
  }
}

4.5.2 willSave 与 willSaveWaitUntil

willSave:保存前的通知(单向)

{
  "jsonrpc": "2.0",
  "method": "textDocument/willSave",
  "params": {
    "textDocument": { "uri": "file:///..." },
    "reason": 1
  }
}

保存原因枚举:

常量说明
1Manual用户手动保存
2AfterDelay自动保存延迟后
3FocusOut编辑器失去焦点

willSaveWaitUntil:保存前的格式化(请求/响应)

// Server 端处理保存前格式化
connection.onRequest("textDocument/willSaveWaitUntil", async (params) => {
  const doc = docManager.get(params.textDocument.uri);
  if (!doc) return [];

  // 返回 TextEdit 数组,在保存前应用
  return formatDocument(doc);
});

4.5.3 保存时自动格式化工作流

用户按下 Ctrl+S
    │
    ▼
┌──────────────┐
│ willSave     │───▶ Server(通知,可选)
└──────┬───────┘
       │
┌──────▼───────────────┐
│ willSaveWaitUntil    │───▶ Server 返回 TextEdits
└──────┬───────────────┘       Client 应用编辑
       │
┌──────▼───────┐
│  实际保存文件  │
└──────┬───────┘
       │
┌──────▼───────┐
│ didSave      │───▶ Server(通知,可选)
└──────────────┘

4.6 完整的同步实现

import {
  createMessageConnection,
  StreamMessageReader,
  StreamMessageWriter,
} from "vscode-jsonrpc/node";

interface TextDocumentItem {
  uri: string;
  languageId: string;
  version: number;
  text: string;
}

interface Position {
  line: number;
  character: number;
}

interface Range {
  start: Position;
  end: Position;
}

interface TextEdit {
  range: Range;
  newText: string;
}

class SyncedDocumentManager {
  private docs = new Map<string, TextDocumentItem>();

  // ===== 打开/关闭 =====
  didOpen(params: { textDocument: TextDocumentItem }): void {
    this.docs.set(params.textDocument.uri, { ...params.textDocument });
  }

  didClose(params: { textDocument: { uri: string } }): void {
    this.docs.delete(params.textDocument.uri);
  }

  // ===== Full 同步 =====
  didChangeFull(uri: string, version: number, fullText: string): void {
    const doc = this.docs.get(uri);
    if (doc) {
      doc.text = fullText;
      doc.version = version;
    }
  }

  // ===== Incremental 同步 =====
  didChangeIncremental(
    uri: string,
    version: number,
    changes: Array<{ range: Range; text: string }>
  ): void {
    const doc = this.docs.get(uri);
    if (!doc) return;

    for (const change of changes) {
      doc.text = this.applyTextChange(doc.text, change.range, change.text);
    }
    doc.version = version;
  }

  // ===== 保存 =====
  didSave(uri: string, text?: string): void {
    const doc = this.docs.get(uri);
    if (doc && text !== undefined) {
      doc.text = text;
    }
  }

  // ===== 工具方法 =====
  getDocument(uri: string): TextDocumentItem | undefined {
    return this.docs.get(uri);
  }

  private applyTextChange(text: string, range: Range, newText: string): string {
    const lines = text.split("\n");
    const before =
      lines
        .slice(0, range.start.line)
        .concat([
          lines[range.start.line]?.substring(0, range.start.character) ?? "",
        ])
        .join("\n");

    const after =
      [
        lines[range.end.line]?.substring(range.end.character) ?? "",
      ]
        .concat(lines.slice(range.end.line + 1))
        .join("\n");

    return before + newText + after;
  }
}

// ===== 连接到 Server =====
function setupConnection(reader: any, writer: any) {
  const connection = createMessageConnection(reader, writer);
  const manager = new SyncedDocumentManager();

  // 初始化
  connection.onRequest("initialize", (params) => ({
    capabilities: {
      textDocumentSync: {
        openClose: true,
        change: 2, // Incremental
        save: { includeText: false },
      },
    },
  }));

  // 文档同步
  connection.onNotification("textDocument/didOpen", (params) => {
    manager.didOpen(params);
  });

  connection.onNotification("textDocument/didClose", (params) => {
    manager.didClose(params);
  });

  connection.onNotification("textDocument/didChange", (params) => {
    const { uri, version } = params.textDocument;
    const changes = params.contentChanges;

    if (changes.length > 0 && "range" in changes[0]) {
      manager.didChangeIncremental(uri, version, changes);
    } else {
      manager.didChangeFull(uri, version, changes[0].text);
    }
  });

  connection.onNotification("textDocument/didSave", (params) => {
    manager.didSave(params.textDocument.uri, params.text);
  });

  connection.listen();
  return connection;
}

4.7 多文件与 Workspace 编辑

4.7.1 WorkspaceEdit

某些操作(如重命名)需要同时修改多个文件:

interface WorkspaceEdit {
  changes?: { [uri: string]: TextEdit[] };
  documentChanges?: (
    | TextDocumentEdit
    | CreateFile
    | RenameFile
    | DeleteFile
  )[];
}

// 示例:重命名变量涉及三个文件
const workspaceEdit: WorkspaceEdit = {
  changes: {
    "file:///src/main.py": [
      {
        range: {
          start: { line: 5, character: 4 },
          end: { line: 5, character: 8 },
        },
        newName: "new_name",
      },
    ],
    "file:///src/utils.py": [
      {
        range: {
          start: { line: 10, character: 0 },
          end: { line: 10, character: 4 },
        },
        newName: "new_name",
      },
    ],
  },
};

⚠️ 常见陷阱

陷阱说明
版本号不一致Client 和 Server 对文档版本的理解不同会导致各种诡异 bug
增量应用顺序错误多个 TextEdit 必须按逆序应用(从文档末尾到开头)
忘记处理 Full 回退Incremental 模式下 Client 可能发送完整内容替换
未处理文档关闭忘记清理已关闭文档的缓存导致内存泄漏
换行符差异Windows \r\n vs Unix \n 可能导致 Range 计算偏移

🔗 扩展阅读


下一章第 5 章:语言特性 — 代码补全、悬停、跳转定义、引用查找、签名帮助。