AST 到编辑器 UI 的完整闭环:从语法树到 LSP 再到 Monaco
前面几篇分别讲了 AST 是什么、怎么用工具链、怎么分析、怎么修复,
这一篇把整条链路串起来:在 Language Server 里用 AST 做分析,把结果通过 LSP 协议发回前端,最终在 Monaco 编辑器里展示出来。
在 Language Server 里做 AST 分析
假设你要写一个简单的 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});
把 AST 分析结果转换成 LSP 协议格式
LSP 协议定义了一套标准的数据结构,服务器端需要把 AST 分析结果转换成这些格式。
Diagnostics:从 AST 节点到 LSP Range
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}
Completion:从 AST 上下文到补全项
补全需要知道“当前光标位置在 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:从 AST 节点到类型信息
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 客户端会接收这些消息,然后转换成 Monaco 能理解的格式。
Diagnostics → Monaco 装饰器
Theia 的 LSP 客户端收到 publishDiagnostics 后,会:
- 转换成内部的
Marker结构; - 通知 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}
Completion → Monaco CompletionItemProvider
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});
Hover → Monaco HoverProvider
类似地,注册一个 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');
整个流程是:
用户输入
- Monaco 编辑器捕获键盘事件,更新文本模型。
文档变更通知
- Theia 通过 LSP 客户端发送
textDocument/didChange到 Language Server。
- Theia 通过 LSP 客户端发送
服务器端 AST 分析
- Language Server 收到变更,重新解析文件生成 AST;
- 遍历 AST,找到
console.log调用; - 生成 Diagnostic:
{ range: {...}, severity: Warning, message: '...' }。
发布诊断
- 服务器通过
textDocument/publishDiagnostics通知发送诊断结果; - Theia 的 LSP 客户端接收,转换成
Marker并写入 Markers 服务。
- 服务器通过
Monaco 更新装饰器
- Theia 的 Monaco 适配层监听 Markers 变化;
- 调用
editor.deltaDecorations(),在编辑器里显示黄色波浪线。
用户悬停
- 用户把鼠标移到
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 将其呈现给用户。