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

LSP 开发指南 / 第 13 章:高级主题

13.1 自定义扩展方法

LSP 协议允许 Server 和 Client 通过 $ 前缀或自定义方法名扩展功能。

13.1.1 自定义通知

// Server → Client 自定义通知
interface CustomNotification {
  type: "info" | "warning" | "error";
  message: string;
  source: string;
}

// Server 发送自定义通知
connection.sendNotification("myLsp/showStatusBar", {
  text: "Analyzing project...",
  tooltip: "Indexing 127 files",
});

// Client 监听自定义通知
client.onNotification("myLsp/showStatusBar", (params) => {
  statusBar.setText(params.text);
  statusBar.setTooltip(params.tooltip);
});

13.1.2 自定义请求

// Server 提供自定义请求
connection.onRequest("myLsp/getProjectStats", async (params) => {
  const stats = {
    totalFiles: countFiles(params.rootUri),
    totalLines: countLines(params.rootUri),
    languages: detectLanguages(params.rootUri),
    lastIndexed: getLastIndexedTime(),
  };
  return stats;
});

// Client 调用自定义请求
const stats = await client.sendRequest("myLsp/getProjectStats", {
  rootUri: workspaceFolder.uri,
});
console.log(`Project has ${stats.totalFiles} files, ${stats.totalLines} lines`);

13.1.3 初始化选项传递配置

// Client 在 initialize 时传递自定义选项
const result = await client.sendRequest("initialize", {
  processId: process.pid,
  rootUri: workspaceFolder.uri,
  initializationOptions: {
    maxNumberOfProblems: 100,
    enableExperimentalFeatures: true,
    customRulesPath: "/path/to/rules.json",
    analysisDepth: 3,
  },
  capabilities: {},
});

// Server 读取初始化选项
connection.onInitialize((params) => {
  const options = params.initializationOptions || {};
  config.maxProblems = options.maxNumberOfProblems ?? 100;
  config.experimental = options.enableExperimentalFeatures ?? false;
  config.rulesPath = options.customRulesPath;
  config.depth = options.analysisDepth ?? 2;
});

13.2 语义标记(Semantic Tokens)

语义标记允许 Server 提供精确的语法着色信息,替代或增强基于正则的高亮。

13.2.1 Token 类型与修饰符

Token 类型 值 说明
namespace 0 命名空间
type 1 类型
class 2 类
enum 3 枚举
interface 4 接口
struct 5 结构体
typeParameter 6 类型参数
parameter 7 参数
variable 8 变量
property 9 属性
enumMember 10 枚举成员
function 11 函数
method 12 方法
macro 13 宏
keyword 14 关键字
modifier 15 修饰符
comment 16 注释
string 17 字符串
number 18 数字
regexp 19 正则
operator 20 操作符
Token 修饰符 值 说明
declaration 1 声明
definition 2 定义
readonly 3 只读
static 4 静态
deprecated 5 已弃用
abstract 6 抽象
async 7 异步
modification 8 修改
documentation 9 文档
defaultLibrary 10 默认库

13.2.2 Server 声明

capabilities: {
  semanticTokensProvider: {
    legend: {
      tokenTypes: [
        "namespace", "type", "class", "enum", "interface", "struct",
        "typeParameter", "parameter", "variable", "property",
        "enumMember", "function", "method", "macro", "keyword",
        "modifier", "comment", "string", "number", "regexp", "operator",
      ],
      tokenModifiers: [
        "declaration", "definition", "readonly", "static",
        "deprecated", "abstract", "async", "modification",
        "documentation", "defaultLibrary",
      ],
    },
    range: true,
    full: {
      delta: true,
    },
  },
}

13.2.3 实现示例

interface SemanticToken {
  line: number;
  startChar: number;
  length: number;
  tokenType: number;
  tokenModifiers: number;
}

connection.onRequest("textDocument/semanticTokens/full", (params) => {
  const doc = docManager.get(params.textDocument.uri);
  if (!doc) return { data: [] };

  const tokens = analyzeTokens(doc);
  return encodeTokens(tokens);
});

function analyzeTokens(doc: TextDocumentItem): SemanticToken[] {
  const tokens: SemanticToken[] = [];
  const lines = doc.text.split("\n");

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

    // 匹配关键字
    const kwPattern = /\b(def|class|if|else|for|while|return|import|from|as)\b/g;
    let match;
    while ((match = kwPattern.exec(line)) !== null) {
      tokens.push({
        line: i,
        startChar: match.index,
        length: match[0].length,
        tokenType: 14, // keyword
        tokenModifiers: 0,
      });
    }

    // 匹配函数名
    const fnPattern = /(?:def|function)\s+(\w+)/g;
    while ((match = fnPattern.exec(line)) !== null) {
      tokens.push({
        line: i,
        startChar: match.index + match[0].indexOf(match[1]),
        length: match[1].length,
        tokenType: 11, // function
        tokenModifiers: 1, // declaration
      });
    }

    // 匹配字符串
    const strPattern = /(["'])(?:(?!\1).)*\1/g;
    while ((match = strPattern.exec(line)) !== null) {
      tokens.push({
        line: i,
        startChar: match.index,
        length: match[0].length,
        tokenType: 17, // string
        tokenModifiers: 0,
      });
    }
  }

  return tokens.sort((a, b) => a.line - b.line || a.startChar - b.startChar);
}

function encodeTokens(tokens: SemanticToken[]): { data: number[] } {
  const data: number[] = [];
  let prevLine = 0;
  let prevChar = 0;

  for (const token of tokens) {
    const deltaLine = token.line - prevLine;
    const deltaChar = deltaLine === 0 ? token.startChar - prevChar : token.startChar;

    data.push(deltaLine, deltaChar, token.length, token.tokenType, token.tokenModifiers);

    prevLine = token.line;
    prevChar = token.startChar;
  }

  return { data };
}

13.3 进度报告(Progress)

13.3.1 工作区进度

// Server 请求创建进度
connection.onRequest("window/workDoneProgress/create", async (params) => {
  return params.token;
});

// 发送进度
async function analyzeProjectWithProgress(): Promise<void> {
  const token = "analysis-progress";

  // 开始
  connection.sendProgress("window/workDoneProgress", token, {
    kind: "begin",
    title: "Analyzing Project",
    message: "Scanning files...",
    cancellable: true,
    percentage: 0,
  });

  const files = await getAllProjectFiles();
  for (let i = 0; i < files.length; i++) {
    // 检查取消
    if (isCancelled(token)) break;

    // 报告进度
    connection.sendProgress("window/workDoneProgress", token, {
      kind: "report",
      message: `Analyzing ${files[i].name}`,
      percentage: Math.round((i / files.length) * 100),
    });

    await analyzeFile(files[i]);
  }

  // 结束
  connection.sendProgress("window/workDoneProgress", token, {
    kind: "end",
    message: "Analysis complete",
  });
}

13.3.2 带取消支持的长时间操作

connection.onRequest("textDocument/references", async (params, token) => {
  const progressToken = "ref-search";

  connection.sendProgress("window/workDoneProgress", progressToken, {
    kind: "begin",
    title: "Finding References",
    cancellable: true,
    percentage: 0,
  });

  const results: Location[] = [];
  const files = getAllProjectFiles();

  for (let i = 0; i < files.length; i++) {
    // 检查客户端取消请求
    if (token.isCancellationRequested) {
      connection.sendProgress("window/workDoneProgress", progressToken, {
        kind: "end",
        message: "Cancelled",
      });
      throw new ResponseError(ErrorCodes.RequestCancelled, "Request cancelled");
    }

    connection.sendProgress("window/workDoneProgress", progressToken, {
      kind: "report",
      message: `Searching ${files[i].name}`,
      percentage: Math.round((i / files.length) * 100),
    });

    const refs = await findReferencesInFile(files[i], params);
    results.push(...refs);
  }

  connection.sendProgress("window/workDoneProgress", progressToken, {
    kind: "end",
    message: `Found ${results.length} references`,
  });

  return results;
});

13.4 调试与追踪

13.4.1 Trace 级别

// Client 设置 trace 级别
connection.sendNotification("$/setTrace", {
  value: "verbose", // "off" | "messages" | "verbose"
});

// Server 接收 trace 设置
connection.onRequest("$/setTrace", (params) => {
  currentTraceLevel = params.value;
});

// Server 发送追踪日志
function trace(message: string, verbose?: string): void {
  if (currentTraceLevel === "off") return;
  if (currentTraceLevel === "verbose" && verbose) {
    connection.sendNotification("$/logTrace", {
      message: verbose,
      verbose: "true",
    });
  }
  connection.sendNotification("$/logTrace", { message });
}

13.4.2 日志输出

// 使用 window/logMessage 发送日志
function log(type: MessageType, message: string): void {
  connection.sendNotification("window/logMessage", {
    type,     // 1=Error, 2=Warning, 3=Info, 4=Log
    message,
  });
}

// 使用 window/showMessage 显示弹窗
function showMessage(type: MessageType, message: string): void {
  connection.sendNotification("window/showMessage", {
    type,
    message,
  });
}

13.5 Folding Ranges

capabilities: {
  foldingRangeProvider: true,
}

connection.onRequest("textDocument/foldingRange", (params) => {
  const doc = docManager.get(params.textDocument.uri);
  if (!doc) return [];

  const lines = doc.text.split("\n");
  const ranges: FoldingRange[] = [];
  const stack: { line: number; char: string }[] = [];

  for (let i = 0; i < lines.length; i++) {
    const trimmed = lines[i].trimStart();
    if (trimmed.endsWith("{") || trimmed.endsWith(":") || trimmed.endsWith("(")) {
      stack.push({ line: i, char: trimmed.slice(-1) });
    } else if (trimmed.startsWith("}") || trimmed.startsWith(")") || (trimmed === "" && stack.length > 0)) {
      const start = stack.pop();
      if (start && i - start.line > 1) {
        ranges.push({
          startLine: start.line,
          endLine: i - 1,
          kind: "region",
        });
      }
    }
  }

  return ranges;
});

13.6 Linked Editing

LSP 3.16+ 支持链接编辑(同时重命名开闭标签):

capabilities: {
  linkedEditingRangeProvider: true,
}

connection.onRequest("textDocument/linkedEditingRange", (params) => {
  const doc = docManager.get(params.textDocument.uri);
  if (!doc) return null;

  const text = doc.text;
  const offset = doc.offsetAt(params.position);

  // 匹配 HTML 标签
  const tagMatch = findHTMLTagAtPosition(text, offset);
  if (!tagMatch) return null;

  return {
    ranges: [tagMatch.openRange, tagMatch.closeRange],
    wordPattern: "\\w+",
  };
});

⚠️ 注意事项

问题 建议
自定义方法命名冲突 使用项目前缀(如 myLsp/xxx)
语义标记性能 使用增量更新(semanticTokens/full/delta)
进度报告频率 控制在每秒 2-5 次,避免 UI 卡顿
Trace 日志大小 生产环境关闭 verbose trace

🔗 扩展阅读


下一章:第 14 章:Docker 开发 — 容器化开发环境与容器内 LSP。