强曰为道

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

第 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 类型说明
namespace0命名空间
type1类型
class2
enum3枚举
interface4接口
struct5结构体
typeParameter6类型参数
parameter7参数
variable8变量
property9属性
enumMember10枚举成员
function11函数
method12方法
macro13
keyword14关键字
modifier15修饰符
comment16注释
string17字符串
number18数字
regexp19正则
operator20操作符
Token 修饰符说明
declaration1声明
definition2定义
readonly3只读
static4静态
deprecated5已弃用
abstract6抽象
async7异步
modification8修改
documentation9文档
defaultLibrary10默认库

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。