第 6 章:诊断信息
6.1 诊断概述
诊断信息(Diagnostics)是 LSP 中最直观的用户交互功能——编辑器中看到的红色/黄色波浪线就是诊断信息的视觉呈现。LSP 通过 Server → Client 单向推送 的方式传递诊断。
6.1.1 诊断工作流
┌──────────────┐ ┌──────────────┐
│ Server │ │ Client │
└──────┬───────┘ └──────┬───────┘
│ │
│ textDocument/didOpen │
│◀──────────────────────────────────│
│ │
│ (分析文档,发现错误) │
│ │
│ textDocument/publishDiagnostics │
│──────────────────────────────────▶│
│ │ (在编辑器中显示波浪线)
│ │
│ textDocument/didChange │
│◀──────────────────────────────────│
│ │
│ (重新分析,更新诊断) │
│ │
│ textDocument/publishDiagnostics │
│──────────────────────────────────▶│
│ │ (更新波浪线)
6.2 publishDiagnostics 通知
6.2.1 消息格式
{
"jsonrpc": "2.0",
"method": "textDocument/publishDiagnostics",
"params": {
"uri": "file:///home/user/project/src/main.py",
"version": 5,
"diagnostics": [
{
"range": {
"start": { "line": 10, "character": 4 },
"end": { "line": 10, "character": 15 }
},
"severity": 1,
"code": "undefined-variable",
"source": "pylsp",
"message": "Undefined variable 'undefined_var'",
"tags": [],
"relatedInformation": [
{
"location": {
"uri": "file:///home/user/project/src/utils.py",
"range": {
"start": { "line": 5, "character": 0 },
"end": { "line": 5, "character": 20 }
}
},
"message": "Variable was defined here but removed in latest commit"
}
]
}
]
}
}
6.2.2 Diagnostic 字段详解
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
range | Range | ✅ | 错误位置范围 |
severity | DiagnosticSeverity | ❌ | 严重级别 |
code | integer | string | ❌ | 错误码(如 "E501") |
source | string | ❌ | 来源(如 "eslint"、"pylsp") |
message | string | ✅ | 错误描述信息 |
tags | DiagnosticTag[] | ❌ | 附加标签 |
relatedInformation | DiagnosticRelatedInformation[] | ❌ | 关联信息 |
data | any | ❌ | 自定义数据(用于 Code Action) |
6.3 严重级别(Diagnostic Severity)
| 值 | 名称 | 编辑器展示 | 说明 |
|---|---|---|---|
| 1 | Error | 🔴 红色波浪线 | 语法错误、类型错误等必须修复的问题 |
| 2 | Warning | 🟡 黄色波浪线 | 潜在问题、未使用变量等 |
| 3 | Information | 🔵 蓝色波浪线 | 信息性提示 |
| 4 | Hint | 🟢 绿色下划线 | 优化建议、代码风格提示 |
import { DiagnosticSeverity } from "vscode-languageserver";
function createDiagnostic(
line: number,
col: number,
endLine: number,
endCol: number,
message: string,
severity: DiagnosticSeverity,
code?: string
): Diagnostic {
return {
range: {
start: { line, character: col },
end: { line: endLine, character: endCol },
},
severity,
source: "my-language-server",
message,
code,
};
}
// 创建不同级别的诊断
const error = createDiagnostic(10, 4, 10, 15, "Undefined variable 'x'", DiagnosticSeverity.Error, "E001");
const warning = createDiagnostic(20, 0, 20, 10, "Unused variable 'temp'", DiagnosticSeverity.Warning, "W001");
const info = createDiagnostic(30, 0, 30, 20, "Consider using list comprehension", DiagnosticSeverity.Information);
const hint = createDiagnostic(40, 0, 40, 15, "Can be simplified to 'return x > 0'", DiagnosticSeverity.Hint);
6.4 诊断标签(Diagnostic Tags)
标签用于给诊断添加特殊语义:
| 标签值 | 名称 | 说明 | 编辑器行为 |
|---|---|---|---|
| 1 | Unnecessary | 未使用的代码 | 通常显示为灰色/删除线 |
| 2 | Deprecated | 已弃用的代码 | 通常显示为删除线 |
// 标记未使用的导入
const unnecessaryImport: Diagnostic = {
range: { start: { line: 0, character: 7 }, end: { line: 0, character: 13 } },
severity: DiagnosticSeverity.Warning,
message: "Unused import 'os'",
source: "my-lsp",
tags: [DiagnosticTag.Unnecessary],
code: "unused-import",
};
// 标记已弃用的 API
const deprecatedCall: Diagnostic = {
range: { start: { line: 15, character: 4 }, end: { line: 15, character: 20 } },
severity: DiagnosticSeverity.Warning,
message: "'old_function' is deprecated, use 'new_function' instead",
source: "my-lsp",
tags: [DiagnosticTag.Deprecated],
code: "deprecated-function",
};
6.5 关联信息(Related Information)
诊断可以包含关联位置的信息,帮助用户理解错误上下文:
const typeMismatch: Diagnostic = {
range: {
start: { line: 10, character: 8 },
end: { line: 10, character: 15 },
},
severity: DiagnosticSeverity.Error,
message: "Type 'string' is not assignable to type 'number'",
source: "my-lsp",
relatedInformation: [
{
location: {
uri: "file:///src/types.ts",
range: {
start: { line: 42, character: 0 },
end: { line: 42, character: 30 },
},
},
message: "'count' is declared as number here",
},
{
location: {
uri: "file:///src/main.ts",
range: {
start: { line: 10, character: 8 },
end: { line: 10, character: 15 },
},
},
message: "The assignment is here",
},
],
};
6.6 Server 端诊断实现
6.6.1 实时诊断
import { Diagnostic } from "vscode-languageserver";
class DiagnosticProvider {
private debounceTimer: NodeJS.Timeout | null = null;
private debounceMs = 300;
constructor(private connection: any, private docManager: any) {}
// 文档变更时触发(带防抖)
scheduleDiagnostics(uri: string): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
}
this.debounceTimer = setTimeout(() => {
this.publishDiagnostics(uri);
}, this.debounceMs);
}
// 立即发布诊断(保存时)
publishDiagnostics(uri: string): void {
const doc = this.docManager.get(uri);
if (!doc) return;
const diagnostics = this.analyze(doc);
this.connection.sendNotification("textDocument/publishDiagnostics", {
uri,
version: doc.version,
diagnostics,
});
}
// 清除诊断
clearDiagnostics(uri: string): void {
this.connection.sendNotification("textDocument/publishDiagnostics", {
uri,
diagnostics: [],
});
}
private analyze(doc: { text: string; uri: string }): Diagnostic[] {
const diagnostics: Diagnostic[] = [];
const lines = doc.text.split("\n");
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// 示例 1:检测未定义变量
const undefinedMatch = line.match(/\b(undefined_var)\b/);
if (undefinedMatch) {
const col = line.indexOf(undefinedMatch[1]);
diagnostics.push({
range: {
start: { line: i, character: col },
end: { line: i, character: col + undefinedMatch[1].length },
},
severity: DiagnosticSeverity.Error,
source: "my-lsp",
message: `Undefined variable '${undefinedMatch[1]}'`,
code: "undefined-variable",
});
}
// 示例 2:检测过长行
if (line.length > 120) {
diagnostics.push({
range: {
start: { line: i, character: 120 },
end: { line: i, character: line.length },
},
severity: DiagnosticSeverity.Warning,
source: "my-lsp",
message: `Line exceeds 120 characters (${line.length})`,
code: "line-too-long",
});
}
// 示例 3:检测 TODO
const todoIdx = line.indexOf("TODO");
if (todoIdx !== -1) {
diagnostics.push({
range: {
start: { line: i, character: todoIdx },
end: { line: i, character: todoIdx + 4 },
},
severity: DiagnosticSeverity.Information,
source: "my-lsp",
message: "TODO comment found",
code: "todo-comment",
tags: [],
});
}
}
return diagnostics;
}
}
6.6.2 在连接中集成诊断
const connection = createMessageConnection(/* ... */);
const docManager = new DocumentManager();
const diagnostics = new DiagnosticProvider(connection, docManager);
// 文档打开时分析
connection.onNotification("textDocument/didOpen", (params) => {
docManager.open(params.textDocument);
diagnostics.publishDiagnostics(params.textDocument.uri);
});
// 文档变更时防抖分析
connection.onNotification("textDocument/didChange", (params) => {
// ... 更新文档内容
diagnostics.scheduleDiagnostics(params.textDocument.uri);
});
// 保存时立即分析
connection.onNotification("textDocument/didSave", (params) => {
diagnostics.publishDiagnostics(params.textDocument.uri);
});
// 关闭时清除诊断
connection.onNotification("textDocument/didClose", (params) => {
docManager.close(params.textDocument.uri);
diagnostics.clearDiagnostics(params.textDocument.uri);
});
6.7 增量诊断
对于大型项目,逐文件分析可能很慢。增量诊断只分析变更的文件:
class IncrementalDiagnosticProvider {
private dirtyFiles = new Set<string>();
private analysisQueue: string[] = [];
private isAnalyzing = false;
markDirty(uri: string): void {
this.dirtyFiles.add(uri);
this.analysisQueue.push(uri);
this.processQueue();
}
private async processQueue(): Promise<void> {
if (this.isAnalyzing || this.analysisQueue.length === 0) return;
this.isAnalyzing = true;
while (this.analysisQueue.length > 0) {
const uri = this.analysisQueue.shift()!;
if (!this.dirtyFiles.has(uri)) continue;
this.dirtyFiles.delete(uri);
const diagnostics = await this.analyzeFile(uri);
this.publishDiagnostics(uri, diagnostics);
}
this.isAnalyzing = false;
}
private async analyzeFile(uri: string): Promise<Diagnostic[]> {
// 使用异步分析,避免阻塞
return new Promise((resolve) => {
setImmediate(() => {
const doc = this.docManager.get(uri);
if (!doc) return resolve([]);
resolve(this.doAnalyze(doc));
});
});
}
}
6.8 Client 端诊断处理
6.8.1 Client Capabilities
Client 在初始化时声明诊断相关能力:
{
"capabilities": {
"textDocument": {
"publishDiagnostics": {
"relatedInformation": true,
"tagSupport": {
"valueSet": [1, 2]
},
"versionSupport": true,
"codeDescriptionSupport": true,
"dataSupport": true
}
}
}
}
6.8.2 诊断过滤与配置
一些编辑器允许用户配置诊断显示:
// Client 端:过滤特定严重级别的诊断
connection.onNotification("textDocument/publishDiagnostics", (params) => {
const filteredDiagnostics = params.diagnostics.filter((d) => {
// 过滤掉 Hint 级别
if (d.severity === DiagnosticSeverity.Hint) return false;
// 过滤特定代码
if (d.code === "todo-comment") return false;
return true;
});
// 更新编辑器 UI
updateEditorDiagnostics(params.uri, filteredDiagnostics);
});
6.9 Pull 模式诊断(LSP 3.17+)
LSP 3.17 引入了 Pull 模式 作为 publishDiagnostics 的替代方案:
// Client 主动拉取诊断
connection.onRequest("textDocument/diagnostic", async (params) => {
const doc = docManager.get(params.textDocument.uri);
if (!doc) return { items: [] };
const diagnostics = await analyzeDocument(doc);
return {
kind: "full",
items: diagnostics,
};
});
// 或者工作区级别拉取
connection.onRequest("workspace/diagnostic", async (params) => {
const results = [];
for (const [uri, doc] of docManager.getAllDocuments()) {
const diagnostics = await analyzeDocument(doc);
results.push({
uri,
version: doc.version,
diagnostics,
});
}
return { items: results };
});
Push vs Pull 模式对比
| 特性 | Push (publishDiagnostics) | Pull (textDocument/diagnostic) |
|---|---|---|
| 触发方 | Server 主动推送 | Client 主动拉取 |
| 实现复杂度 | 低 | 中等 |
| 一致性 | Server 维护状态 | Client 按需获取 |
| 适用场景 | 实时编辑 | 大型项目/增量分析 |
| LSP 版本 | 所有版本 | 3.17+ |
6.10 自定义诊断代码
建立一套一致的错误码体系对用户体验至关重要:
| 代码前缀 | 类别 | 示例 |
|---|---|---|
| E001-E999 | Error | E001: 语法错误, E002: 类型错误 |
| W001-W999 | Warning | W001: 未使用变量, W002: 弃用 API |
| I001-I999 | Info | I001: TODO 注释 |
| H001-H999 | Hint | H001: 可简化的表达式 |
// diagnostic-codes.ts
export enum DiagnosticCode {
// Errors
SyntaxError = "E001",
TypeError = "E002",
UndefinedVariable = "E003",
MissingImport = "E004",
DuplicateDefinition = "E005",
// Warnings
UnusedVariable = "W001",
UnusedImport = "W002",
DeprecatedApi = "W003",
LineTooLong = "W004",
UnreachableCode = "W005",
// Info
TodoComment = "I001",
Suggestion = "I002",
// Hints
SimplifyExpression = "H001",
AddTypeAnnotation = "H002",
}
⚠️ 常见陷阱
| 陷阱 | 说明 |
|---|---|
| 诊断不消失 | 改正错误后必须重新发送 diagnostics(包括空数组) |
| 闪烁/抖动 | 未正确使用防抖导致频繁更新 |
| 版本不匹配 | 带 version 的诊断应检查是否与文档版本一致 |
| 大项目性能 | 不要在每次按键都全量分析整个项目 |
| stderr 与诊断混淆 | Server 的调试日志应输出到 stderr,不要和诊断混淆 |
🔗 扩展阅读
下一章:第 7 章:工作区管理 — 配置管理、文件事件监听、工作区符号。