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

LSP 开发指南 / 第 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 字段详解

字段类型必填说明
rangeRange错误位置范围
severityDiagnosticSeverity严重级别
codeinteger | string错误码(如 "E501"
sourcestring来源(如 "eslint""pylsp"
messagestring错误描述信息
tagsDiagnosticTag[]附加标签
relatedInformationDiagnosticRelatedInformation[]关联信息
dataany自定义数据(用于 Code Action)

6.3 严重级别(Diagnostic Severity)

名称编辑器展示说明
1Error🔴 红色波浪线语法错误、类型错误等必须修复的问题
2Warning🟡 黄色波浪线潜在问题、未使用变量等
3Information🔵 蓝色波浪线信息性提示
4Hint🟢 绿色下划线优化建议、代码风格提示
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)

标签用于给诊断添加特殊语义:

标签值名称说明编辑器行为
1Unnecessary未使用的代码通常显示为灰色/删除线
2Deprecated已弃用的代码通常显示为删除线
// 标记未使用的导入
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-E999ErrorE001: 语法错误, E002: 类型错误
W001-W999WarningW001: 未使用变量, W002: 弃用 API
I001-I999InfoI001: TODO 注释
H001-H999HintH001: 可简化的表达式
// 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 章:工作区管理 — 配置管理、文件事件监听、工作区符号。