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

LSP 开发指南 / 第 7 章:工作区管理

7.1 工作区概述

工作区(Workspace)是 LSP Server 运行的上下文环境,通常对应一个项目目录。工作区管理涉及三大核心功能:

功能说明
配置管理读取/监听编辑器和项目配置
文件事件监听文件创建、修改、删除
工作区符号全局符号搜索

7.2 工作区文件夹

7.2.1 初始化时的工作区信息

Client 在 initialize 请求中提供工作区信息:

{
  "params": {
    "rootUri": "file:///home/user/my-project",
    "workspaceFolders": [
      {
        "uri": "file:///home/user/my-project",
        "name": "my-project"
      },
      {
        "uri": "file:///home/user/my-project/packages/core",
        "name": "core"
      }
    ]
  }
}

7.2.2 动态变更工作区文件夹

Client 可以在运行时添加/移除工作区文件夹:

// Server 监听工作区文件夹变更
connection.onNotification("workspace/didChangeWorkspaceFolders", (params) => {
  for (const folder of params.event.added) {
    console.error(`Workspace added: ${folder.name} (${folder.uri})`);
    indexWorkspace(folder.uri);
  }
  for (const folder of params.event.removed) {
    console.error(`Workspace removed: ${folder.name} (${folder.uri})`);
    removeWorkspaceIndex(folder.uri);
  }
});

Server 声明支持工作区文件夹变更:

{
  "capabilities": {
    "workspace": {
      "workspaceFolders": {
        "supported": true,
        "changeNotifications": true
      }
    }
  }
}

7.3 配置管理

7.3.1 配置读取

Server 通过 workspace/configuration 请求读取配置:

// Server 请求配置
const configs = await connection.sendRequest("workspace/configuration", {
  items: [
    {
      section: "myLsp",           // 配置节
    },
    {
      section: "myLsp.maxLineLength",
    },
    {
      scopeUri: "file:///src/main.py",
      section: "myLsp.pythonPath",
    },
  ],
});

// configs = [全局配置, maxLineLength值, 特定文件的pythonPath]

7.3.2 配置变更监听

// Server 声明支持配置变更
capabilities: {
  workspace: {
    didChangeConfiguration: {
      dynamicRegistration: true,
    },
  },
}

// Server 监听配置变更
connection.onNotification("workspace/didChangeConfiguration", (params) => {
  // params.settings 可能包含新配置(取决于 Client 实现)
  reloadConfiguration();
});

// 更可靠的方式:主动重新请求配置
connection.onNotification("workspace/didChangeConfiguration", async () => {
  const newConfig = await connection.sendRequest("workspace/configuration", {
    items: [{ section: "myLsp" }],
  });
  applyConfiguration(newConfig[0]);
});

7.3.3 配置结构设计

interface ServerConfiguration {
  /** 最大行长度 */
  maxLineLength: number;
  /** 分析器设置 */
  analysis: {
    /** 是否启用类型检查 */
    enableTypeChecking: boolean;
    /** 最大分析深度 */
    maxDepth: number;
  };
  /** 诊断设置 */
  diagnostics: {
    /** 启用的规则列表 */
    enabledRules: string[];
    /** 禁用的规则列表 */
    disabledRules: string[];
  };
}

const defaultConfig: ServerConfiguration = {
  maxLineLength: 120,
  analysis: {
    enableTypeChecking: true,
    maxDepth: 10,
  },
  diagnostics: {
    enabledRules: [],
    disabledRules: [],
  },
};

async function getConfiguration(): Promise<ServerConfiguration> {
  try {
    const config = await connection.sendRequest("workspace/configuration", {
      items: [{ section: "myLsp" }],
    });
    return { ...defaultConfig, ...config[0] };
  } catch {
    return defaultConfig;
  }
}

7.3.4 在编辑器中注册配置

VS Code 的 package.json 配置声明

{
  "contributes": {
    "configuration": {
      "title": "My LSP Server",
      "properties": {
        "myLsp.maxLineLength": {
          "type": "number",
          "default": 120,
          "description": "Maximum line length"
        },
        "myLsp.analysis.enableTypeChecking": {
          "type": "boolean",
          "default": true,
          "description": "Enable type checking"
        }
      }
    }
  }
}

7.4 文件事件

7.4.1 文件观察器

Server 可以注册文件观察器,监听工作区内文件变更:

// 动态注册文件观察器
connection.sendRequest("client/registerCapability", {
  registrations: [
    {
      id: "file-watcher-config",
      method: "workspace/didChangeWatchedFiles",
      registerOptions: {
        watchers: [
          {
            globPattern: "**/*.py",          // Python 文件
          },
          {
            globPattern: "**/pyproject.toml", // 项目配置文件
          },
          {
            globPattern: "**/.mylsp*",        // 自定义配置
          },
        ],
      },
    },
  ],
});

7.4.2 文件变更通知

当工作区内的文件发生变更时,Client 发送通知:

{
  "jsonrpc": "2.0",
  "method": "workspace/didChangeWatchedFiles",
  "params": {
    "changes": [
      {
        "uri": "file:///home/user/project/src/utils.py",
        "type": 2
      },
      {
        "uri": "file:///home/user/project/src/new_file.py",
        "type": 1
      },
      {
        "uri": "file:///home/user/project/src/old_file.py",
        "type": 3
      }
    ]
  }
}

文件变更类型

类型说明
Created1文件创建
Changed2文件修改
Deleted3文件删除

7.4.3 Server 端处理

connection.onNotification("workspace/didChangeWatchedFiles", (params) => {
  for (const change of params.changes) {
    switch (change.type) {
      case FileChangeType.Created:
        handleFileCreated(change.uri);
        break;
      case FileChangeType.Changed:
        handleFileChanged(change.uri);
        break;
      case FileChangeType.Deleted:
        handleFileDeleted(change.uri);
        break;
    }
  }
});

function handleFileChanged(uri: string): void {
  // 重新索引该文件的符号
  reindexFile(uri);
  // 重新发布该文件的诊断
  diagnostics.publishDiagnostics(uri);
}

function handleFileDeleted(uri: string): void {
  // 清理缓存
  symbolIndex.removeFile(uri);
  // 清除诊断
  diagnostics.clearDiagnostics(uri);
}

function handleFileCreated(uri: string): void {
  // 新建索引
  indexFile(uri);
}

7.5 工作区编辑

7.5.1 applyEdit 请求

Server 可以请求 Client 执行工作区编辑:

// Server 请求 Client 执行工作区编辑
connection.sendRequest("workspace/applyEdit", {
  edit: {
    documentChanges: [
      {
        textDocument: { uri: "file:///src/main.py", version: null },
        edits: [
          {
            range: {
              start: { line: 0, character: 0 },
              end: { line: 0, character: 0 },
            },
            newText: "import json\n",
          },
        ],
      },
    ],
  },
});

7.5.2 WorkspaceEdit 类型

interface WorkspaceEdit {
  // 方式 1:按 URI 分组的编辑
  changes?: { [uri: string]: TextEdit[] };

  // 方式 2:文档变更列表(推荐,支持版本控制)
  documentChanges?: (
    | TextDocumentEdit
    | CreateFile
    | RenameFile
    | DeleteFile
  )[];
}

// 创建文件
interface CreateFile {
  kind: "create";
  uri: DocumentUri;
  options?: { overwrite?: boolean; ignoreIfExists?: boolean };
}

// 重命名文件
interface RenameFile {
  kind: "rename";
  oldUri: DocumentUri;
  newUri: DocumentUri;
  options?: { overwrite?: boolean; ignoreIfExists?: boolean };
}

// 删除文件
interface DeleteFile {
  kind: "delete";
  uri: DocumentUri;
  options?: { recursive?: boolean; ignoreIfNotExists?: boolean };
}

7.6 工作区符号

7.6.1 工作区符号搜索

Client 发送 workspace/symbol 请求搜索全局符号:

{
  "jsonrpc": "2.0",
  "id": 20,
  "method": "workspace/symbol",
  "params": {
    "query": "UserService"
  }
}

Server 响应

{
  "jsonrpc": "2.0",
  "id": 20,
  "result": [
    {
      "name": "UserService",
      "kind": 5,
      "location": {
        "uri": "file:///src/services/user.ts",
        "range": {
          "start": { "line": 12, "character": 0 },
          "end": { "line": 12, "character": 40 }
        }
      },
      "containerName": "services"
    },
    {
      "name": "UserServiceError",
      "kind": 12,
      "location": {
        "uri": "file:///src/errors.ts",
        "range": {
          "start": { "line": 45, "character": 0 },
          "end": { "line": 45, "character": 30 }
        }
      },
      "containerName": "errors"
    }
  ]
}

7.6.2 符号索引实现

interface SymbolEntry {
  name: string;
  kind: SymbolKind;
  uri: string;
  range: Range;
  containerName?: string;
}

class SymbolIndex {
  private symbols: SymbolEntry[] = [];
  private fileSymbols = new Map<string, SymbolEntry[]>();

  // 索引一个文件的符号
  indexFile(uri: string, text: string): void {
    // 移除旧索引
    this.removeFile(uri);

    const lines = text.split("\n");
    const newSymbols: SymbolEntry[] = [];
    let currentContainer = "";

    for (let i = 0; i < lines.length; i++) {
      const line = lines[i];

      // 类定义
      const classMatch = line.match(/^class\s+(\w+)/);
      if (classMatch) {
        const sym: SymbolEntry = {
          name: classMatch[1],
          kind: SymbolKind.Class,
          uri,
          range: { start: { line: i, character: 0 }, end: { line: i, character: line.length } },
        };
        newSymbols.push(sym);
        currentContainer = classMatch[1];
        continue;
      }

      // 函数/方法定义
      const funcMatch = line.match(/^(?:async\s+)?(?:def|function)\s+(\w+)/);
      if (funcMatch) {
        newSymbols.push({
          name: funcMatch[1],
          kind: SymbolKind.Function,
          uri,
          range: { start: { line: i, character: 0 }, end: { line: i, character: line.length } },
          containerName: currentContainer || undefined,
        });
        continue;
      }
    }

    this.fileSymbols.set(uri, newSymbols);
    this.symbols = [...this.symbols, ...newSymbols];
  }

  removeFile(uri: string): void {
    const oldSymbols = this.fileSymbols.get(uri);
    if (oldSymbols) {
      this.symbols = this.symbols.filter((s) => !oldSymbols.includes(s));
      this.fileSymbols.delete(uri);
    }
  }

  search(query: string): SymbolEntry[] {
    const lower = query.toLowerCase();
    return this.symbols.filter((s) =>
      s.name.toLowerCase().includes(lower)
    );
  }
}

// 在 Server 中使用
const symbolIndex = new SymbolIndex();

connection.onRequest("workspace/symbol", (params) => {
  return symbolIndex.search(params.query).map((entry) => ({
    name: entry.name,
    kind: entry.kind,
    location: { uri: entry.uri, range: entry.range },
    containerName: entry.containerName,
  }));
});

7.7 工作区文件操作

7.7.1 文件操作注册

Server 可以在文件重命名/创建/删除时自动调整 import 路径:

capabilities: {
  workspace: {
    fileOperations: {
      willRename: {
        filters: [
          { pattern: { glob: "**/*.py" } },
          { pattern: { glob: "**/*.ts" } },
        ],
      },
      didRename: {
        filters: [
          { pattern: { glob: "**/*.py" } },
          { pattern: { glob: "**/*.ts" } },
        ],
      },
    },
  },
}

7.7.2 willRename 处理

connection.onRequest("workspace/willRenameFiles", async (params) => {
  const edits: WorkspaceEdit = { documentChanges: [] };

  for (const fileOp of params.files) {
    // 更新所有引用该文件的 import 语句
    const affectedFiles = findFilesImporting(fileOp.oldUri);
    for (const affectedUri of affectedFiles) {
      const doc = docManager.get(affectedUri);
      if (!doc) continue;

      const importEdits = updateImportPaths(doc, fileOp.oldUri, fileOp.newUri);
      if (importEdits.length > 0) {
        edits.documentChanges!.push({
          textDocument: { uri: affectedUri, version: doc.version },
          edits: importEdits,
        });
      }
    }
  }

  return edits;
});

⚠️ 注意事项

问题建议
配置读取失败始终提供默认配置作为 fallback
文件观察器过多只注册必要的 glob pattern
符号索引内存大项目应使用增量索引
工作区多根目录检查 workspaceFolders 是否为空

🔗 扩展阅读


下一章第 8 章:代码动作 — Quick Fix、重构、Code Lens。