AST 到编辑器 UI 的完整闭环:从语法树到 LSP 再到 Monaco

前面几篇分别讲了 AST 是什么、怎么用工具链、怎么分析、怎么修复,
这一篇把整条链路串起来:在 Language Server 里用 AST 做分析,把结果通过 LSP 协议发回前端,最终在 Monaco 编辑器里展示出来。

假设你要写一个简单的 Language Server,它做的事情是:

  • 检测代码里所有 console.log 调用,标记为 warning;
  • 提供补全:当用户输入 log. 时,补全 console.log
  • 提供 hover:当鼠标悬停在 console 上时,显示它的类型信息。

在服务器端,核心逻辑是围绕 AST 展开的:

 1import * as ts from 'typescript';
 2import {
 3  createConnection,
 4  TextDocuments,
 5  Diagnostic,
 6  DiagnosticSeverity,
 7  Position,
 8  Range,
 9} from 'vscode-languageserver/node';
10import { TextDocument } from 'vscode-languageserver-textdocument';
11
12const connection = createConnection();
13const documents = new TextDocuments(TextDocument);
14
15// 维护每个文档的 AST 和 Program
16const sourceFiles = new Map<string, ts.SourceFile>();
17const programs = new Map<string, ts.Program>();
18
19function updateAST(uri: string, text: string) {
20  const sourceFile = ts.createSourceFile(
21    uri,
22    text,
23    ts.ScriptTarget.Latest,
24    true
25  );
26  sourceFiles.set(uri, sourceFile);
27
28  // 如果需要类型信息,创建 Program
29  const host = ts.createCompilerHost({});
30  const program = ts.createProgram([uri], {}, host);
31  programs.set(uri, program);
32}
33
34// 检测 console.log 调用
35function findConsoleLogDiagnostics(
36  sourceFile: ts.SourceFile
37): Diagnostic[] {
38  const diagnostics: Diagnostic[] = [];
39
40  function visit(node: ts.Node) {
41    if (
42      ts.isCallExpression(node) &&
43      ts.isPropertyAccessExpression(node.expression) &&
44      node.expression.expression.getText() === 'console' &&
45      node.expression.name.getText() === 'log'
46    ) {
47      const start = node.getStart();
48      const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
49      const end = node.getEnd();
50      const { line: endLine, character: endChar } =
51        sourceFile.getLineAndCharacterOfPosition(end);
52
53      diagnostics.push({
54        range: {
55          start: { line, character },
56          end: { line: endLine, character: endChar },
57        },
58        severity: DiagnosticSeverity.Warning,
59        message: 'Consider removing console.log in production code',
60        source: 'custom-linter',
61      });
62    }
63
64    ts.forEachChild(node, visit);
65  }
66
67  visit(sourceFile);
68  return diagnostics;
69}

当文档变更时,更新 AST 并发布诊断:

 1documents.onDidChangeContent((change) => {
 2  const document = change.document;
 3  const text = document.getText();
 4  updateAST(document.uri, text);
 5
 6  const sourceFile = sourceFiles.get(document.uri);
 7  if (sourceFile) {
 8    const diagnostics = findConsoleLogDiagnostics(sourceFile);
 9    connection.sendDiagnostics({
10      uri: document.uri,
11      diagnostics,
12    });
13  }
14});

LSP 协议定义了一套标准的数据结构,服务器端需要把 AST 分析结果转换成这些格式。

AST 节点有 getStart()getEnd(),返回的是字符偏移量。
LSP 的 Range 需要的是 { line, character } 格式:

 1function astNodeToLSPRange(
 2  node: ts.Node,
 3  sourceFile: ts.SourceFile
 4): Range {
 5  const start = node.getStart();
 6  const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
 7  const end = node.getEnd();
 8  const { line: endLine, character: endChar } =
 9    sourceFile.getLineAndCharacterOfPosition(end);
10
11  return {
12    start: { line, character },
13    end: { line: endLine, character: endChar },
14  };
15}

补全需要知道“当前光标位置在 AST 的哪个节点上”,然后根据上下文返回候选项:

 1connection.onCompletion((params) => {
 2  const { textDocument, position } = params;
 3  const sourceFile = sourceFiles.get(textDocument.uri);
 4  if (!sourceFile) return [];
 5
 6  // 找到光标位置对应的 AST 节点
 7  const offset = sourceFile.getPositionOfLineAndCharacter(
 8    position.line,
 9    position.character
10  );
11  const node = findNodeAtPosition(sourceFile, offset);
12
13  // 如果是在 PropertyAccessExpression 里,且对象是 console
14  if (
15    ts.isPropertyAccessExpression(node) &&
16    node.expression.getText() === 'console'
17  ) {
18    return [
19      {
20        label: 'log',
21        kind: CompletionItemKind.Method,
22        detail: 'console.log(...args: any[]): void',
23        documentation: 'Outputs a message to the console',
24      },
25      {
26        label: 'error',
27        kind: CompletionItemKind.Method,
28        detail: 'console.error(...args: any[]): void',
29      },
30    ];
31  }
32
33  return [];
34});
35
36function findNodeAtPosition(
37  sourceFile: ts.SourceFile,
38  offset: number
39): ts.Node {
40  let result: ts.Node = sourceFile;
41
42  function visit(node: ts.Node) {
43    if (node.getStart() <= offset && offset < node.getEnd()) {
44      result = node;
45      ts.forEachChild(node, visit);
46    }
47  }
48
49  visit(sourceFile);
50  return result;
51}

Hover 需要返回当前符号的类型、文档等信息:

 1connection.onHover((params) => {
 2  const { textDocument, position } = params;
 3  const sourceFile = sourceFiles.get(textDocument.uri);
 4  const program = programs.get(textDocument.uri);
 5  if (!sourceFile || !program) return null;
 6
 7  const offset = sourceFile.getPositionOfLineAndCharacter(
 8    position.line,
 9    position.character
10  );
11  const node = findNodeAtPosition(sourceFile, offset);
12
13  if (ts.isIdentifier(node) && node.text === 'console') {
14    const checker = program.getTypeChecker();
15    const symbol = checker.getSymbolAtLocation(node);
16    if (symbol) {
17      const type = checker.getTypeOfSymbolAtLocation(symbol, node);
18      const typeStr = checker.typeToString(type);
19
20      return {
21        contents: {
22          kind: 'markdown',
23          value: `\`\`\`typescript\n${typeStr}\n\`\`\`\n\nConsole API for logging`,
24        },
25        range: astNodeToLSPRange(node, sourceFile),
26      };
27    }
28  }
29
30  return null;
31});

在 Theia 前端,LSP 客户端会接收这些消息,然后转换成 Monaco 能理解的格式。

Theia 的 LSP 客户端收到 publishDiagnostics 后,会:

  1. 转换成内部的 Marker 结构;
  2. 通知 Monaco 编辑器更新装饰器。
 1// 在 Theia 的 LSP 客户端适配层(简化示意)
 2class MonacoDiagnosticsAdapter {
 3  constructor(
 4    private editor: monaco.editor.IStandaloneCodeEditor,
 5    private markers: Marker[]
 6  ) {}
 7
 8  updateDecorations() {
 9    const decorations = this.markers.map((marker) => ({
10      range: new monaco.Range(
11        marker.range.start.line + 1,
12        marker.range.start.character + 1,
13        marker.range.end.line + 1,
14        marker.range.end.character + 1
15      ),
16      options: {
17        className: marker.severity === DiagnosticSeverity.Error
18          ? 'monaco-error-line'
19          : 'monaco-warning-line',
20        glyphMarginClassName: marker.severity === DiagnosticSeverity.Error
21          ? 'monaco-error-glyph'
22          : 'monaco-warning-glyph',
23        overviewRuler: {
24          color: marker.severity === DiagnosticSeverity.Error
25            ? '#ff0000'
26            : '#ffaa00',
27          position: monaco.editor.OverviewRulerLane.Right,
28        },
29      },
30    }));
31
32    this.editor.deltaDecorations([], decorations);
33  }
34}

Theia 会注册一个 Monaco 的 CompletionItemProvider,在其中调用 LSP 客户端:

 1// 在 Theia 的 Monaco 语言适配层(简化示意)
 2monaco.languages.registerCompletionItemProvider('typescript', {
 3  provideCompletionItems: async (model, position) => {
 4    // 调用 LSP 客户端的 completion 请求
 5    const items = await lspClient.request('textDocument/completion', {
 6      textDocument: { uri: model.uri.toString() },
 7      position: {
 8        line: position.lineNumber - 1,
 9        character: position.column - 1,
10      },
11    });
12
13    // 转换成 Monaco 格式
14    return {
15      suggestions: items.map((item) => ({
16        label: item.label,
17        kind: mapLSPKindToMonaco(item.kind),
18        detail: item.detail,
19        documentation: item.documentation,
20        insertText: item.insertText || item.label,
21        range: mapLSPRangeToMonaco(item.textEdit?.range, model, position),
22      })),
23    };
24  },
25});

类似地,注册一个 HoverProvider

 1monaco.languages.registerHoverProvider('typescript', {
 2  provideHover: async (model, position) => {
 3    const hover = await lspClient.request('textDocument/hover', {
 4      textDocument: { uri: model.uri.toString() },
 5      position: {
 6        line: position.lineNumber - 1,
 7        character: position.column - 1,
 8      },
 9    });
10
11    if (hover) {
12      return {
13        range: mapLSPRangeToMonaco(hover.range, model, position),
14        contents: hover.contents,
15      };
16    }
17
18    return null;
19  },
20});

假设用户在 Theia 编辑器里写了一段代码:

1console.log('Hello');

整个流程是:

  1. 用户输入

    • Monaco 编辑器捕获键盘事件,更新文本模型。
  2. 文档变更通知

    • Theia 通过 LSP 客户端发送 textDocument/didChange 到 Language Server。
  3. 服务器端 AST 分析

    • Language Server 收到变更,重新解析文件生成 AST;
    • 遍历 AST,找到 console.log 调用;
    • 生成 Diagnostic:{ range: {...}, severity: Warning, message: '...' }
  4. 发布诊断

    • 服务器通过 textDocument/publishDiagnostics 通知发送诊断结果;
    • Theia 的 LSP 客户端接收,转换成 Marker 并写入 Markers 服务。
  5. Monaco 更新装饰器

    • Theia 的 Monaco 适配层监听 Markers 变化;
    • 调用 editor.deltaDecorations(),在编辑器里显示黄色波浪线。
  6. 用户悬停

    • 用户把鼠标移到 console 上;
    • Monaco 触发 HoverProvider
    • Theia 通过 LSP 客户端发送 textDocument/hover 请求;
    • 服务器在 AST 上定位节点,用 TypeChecker 查类型,返回 hover 结果;
    • Theia 转换成 Monaco 格式,显示悬浮提示框。

从 AST 到编辑器 UI 的完整闭环,核心是三层转换:

  • AST 分析层:在 Language Server 里用 TypeScript Compiler API(或其它工具链)解析代码、分析结构、生成结果。
  • LSP 协议层:把 AST 分析结果转换成 LSP 标准格式(Diagnostics、Completion、Hover 等),通过 JSON-RPC 发送。
  • 编辑器适配层:Theia 接收 LSP 消息,转换成 Monaco 的装饰器、补全项、悬浮提示等,最终在编辑器 UI 里呈现。

在 Theia + Monaco + LSP 这套架构里,AST 是服务器端做语言智能的底层数据结构,
LSP 是把这些智能“标准化传输”的协议,Monaco 是最终承载这些智能的编辑器 UI。
理解这条链路,有助于在扩展中合理地利用 AST 分析能力,并通过 LSP 和 Monaco 将其呈现给用户。