第 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。