LSP 开发指南 / 第 4 章:文本同步
4.1 文本同步概述
文本同步是 LSP 最基础的能力——Server 需要知道 Client 侧文档的当前状态,才能提供准确的语言服务。LSP 提供了三种同步模式:
| 同步模式 | 值 | 说明 |
|---|---|---|
| None | 0 | Server 不接收文档变更通知 |
| Full | 1 | 每次变更发送完整文档内容 |
| Incremental | 2 | 每次变更只发送差异部分 |
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"
}
}
}
参数说明:
| 字段 | 类型 | 说明 |
|---|---|---|
uri | DocumentUri | 文档唯一标识符 |
languageId | string | 语言标识(如 python、typescript) |
version | integer | 文档版本号(每次变更递增) |
text | string | 文档完整内容 |
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
}
}
保存原因枚举:
| 值 | 常量 | 说明 |
|---|---|---|
| 1 | Manual | 用户手动保存 |
| 2 | AfterDelay | 自动保存延迟后 |
| 3 | FocusOut | 编辑器失去焦点 |
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 章:语言特性 — 代码补全、悬停、跳转定义、引用查找、签名帮助。