AST:调试与可视化——从查看结构到开发工具链

写 AST 相关的代码时,最头疼的往往是“不知道当前节点长什么样”“转换后结果对不对”。
这一篇聊聊如何调试和可视化 AST:从最简单的打印,到专业的可视化工具,再到开发 Language Server 时的调试技巧。

拿到一个 SourceFile 后,最直接的方式是打印它的结构:

 1import * as ts from 'typescript';
 2
 3const sourceCode = `
 4function greet(name: string) {
 5  console.log('Hello ' + name);
 6}
 7`;
 8
 9const sourceFile = ts.createSourceFile(
10  'demo.ts',
11  sourceCode,
12  ts.ScriptTarget.Latest,
13  true
14);
15
16// 直接打印整个 AST(会很长)
17console.log(JSON.stringify(sourceFile, null, 2));

但这样打印出来的结果通常非常庞大,因为 AST 节点包含很多元数据(位置信息、标志位等)。
更实用的方式是只打印你关心的部分:

 1function printAST(node: ts.Node, indent = 0) {
 2  const prefix = '  '.repeat(indent);
 3  const kindName = ts.SyntaxKind[node.kind];
 4
 5  // 打印节点类型和关键信息
 6  if (ts.isIdentifier(node)) {
 7    console.log(`${prefix}${kindName}("${node.text}")`);
 8  } else if (ts.isStringLiteral(node)) {
 9    console.log(`${prefix}${kindName}("${node.text}")`);
10  } else if (ts.isFunctionDeclaration(node)) {
11    console.log(`${prefix}${kindName}(${node.name?.text || 'anonymous'})`);
12  } else {
13    console.log(`${prefix}${kindName}`);
14  }
15
16  // 递归打印子节点
17  ts.forEachChild(node, (child) => {
18    printAST(child, indent + 1);
19  });
20}
21
22printAST(sourceFile);

输出大概长这样:

 1SourceFile
 2  FunctionDeclaration(greet)
 3    Identifier("greet")
 4    Parameter(name)
 5      Identifier("name")
 6      StringKeyword
 7    Block
 8      ExpressionStatement
 9        CallExpression
10          PropertyAccessExpression
11            Identifier("console")
12            Identifier("log")
13          BinaryExpression
14            StringLiteral("Hello ")
15            Identifier("name")

TypeScript 提供了一些辅助函数来查看节点信息:

 1// 获取节点的文本内容
 2const nodeText = node.getText();
 3
 4// 获取节点的起始和结束位置
 5const start = node.getStart();
 6const end = node.getEnd();
 7
 8// 获取行号和列号
 9const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
10
11// 获取节点的完整文本(包括注释)
12const fullText = node.getFullText();
13
14// 检查节点类型
15if (ts.isFunctionDeclaration(node)) {
16  console.log('This is a function declaration');
17}
18
19// 获取节点的标志位(modifiers、flags 等)
20const flags = ts.getCombinedModifierFlags(node);

在调试时,可以写一个辅助函数来打印节点的关键信息:

 1function debugNode(node: ts.Node, sourceFile: ts.SourceFile) {
 2  const kindName = ts.SyntaxKind[node.kind];
 3  const start = node.getStart();
 4  const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
 5  const text = node.getText();
 6
 7  console.log({
 8    kind: kindName,
 9    position: `${line + 1}:${character + 1}`,
10    text: text.substring(0, 50), // 只显示前50个字符
11    start,
12    end: node.getEnd(),
13  });
14}

AST Explorer 是最常用的 AST 可视化工具,支持多种语言和解析器。

使用方式:

  1. 打开网站,选择语言(比如 JavaScript、TypeScript);
  2. 在左侧输入代码;
  3. 右侧会实时显示 AST 结构,可以展开/折叠节点;
  4. 支持切换不同的解析器(Babel、TypeScript、ESLint 等)。

在 AST Explorer 里,你可以:

  • 快速查看代码对应的 AST 结构;
  • 测试不同的解析器对同一段代码的解析结果;
  • 写 transform 代码,实时看到转换结果。

比如你想知道 console.log('hello') 的 AST 结构,
直接在 AST Explorer 里输入,就能看到完整的树形结构,比手写打印函数方便得多。

如果你主要用 TypeScript Compiler API,可以用 TypeScript AST Viewer

  • 专门针对 TypeScript AST;
  • 显示节点的所有属性(包括位置、标志位等);
  • 支持高亮显示节点对应的源码位置;
  • 可以查看节点的类型信息(如果有 TypeChecker)。

在开发 TypeScript Language Server 或写 TypeScript 相关的工具时,这个工具特别有用。

写 transformer 时,最需要的是看到“转换前”和“转换后”的对比:

 1function transformAndCompare(sourceCode: string) {
 2  const sourceFile = ts.createSourceFile(
 3    'demo.ts',
 4    sourceCode,
 5    ts.ScriptTarget.Latest,
 6    true
 7  );
 8
 9  console.log('=== BEFORE ===');
10  console.log(sourceCode);
11  console.log('\n=== AST STRUCTURE ===');
12  printAST(sourceFile);
13
14  // 执行转换
15  const result = ts.transform(sourceFile, [myTransformer]);
16  const transformed = result.transformed[0];
17  const printer = ts.createPrinter();
18  const newCode = printer.printFile(transformed);
19
20  console.log('\n=== AFTER ===');
21  console.log(newCode);
22  console.log('\n=== NEW AST STRUCTURE ===');
23  printAST(transformed);
24}

更精细的做法是只打印被修改的节点:

 1function transformWithTracking(sourceFile: ts.SourceFile) {
 2  const modifiedNodes: ts.Node[] = [];
 3
 4  const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
 5    return (sourceFile) => {
 6      function visit(node: ts.Node): ts.Node {
 7        const original = node;
 8
 9        // 执行转换逻辑
10        const transformed = ts.visitEachChild(node, visit, context);
11
12        // 如果节点被修改了,记录下来
13        if (transformed !== original) {
14          modifiedNodes.push(transformed);
15          console.log(`Modified: ${ts.SyntaxKind[transformed.kind]} at ${transformed.getStart()}`);
16        }
17
18        return transformed;
19      };
20
21      return ts.visitNode(sourceFile, visit) || sourceFile;
22    };
23  };
24
25  const result = ts.transform(sourceFile, [transformer]);
26
27  console.log(`Modified ${modifiedNodes.length} nodes`);
28  return result;
29}

开发 Language Server 时,调试 AST 分析逻辑有几种方式:

在 Language Server 的关键位置添加日志:

 1connection.onDefinition((params) => {
 2  const { textDocument, position } = params;
 3  const sourceFile = getSourceFile(textDocument.uri);
 4
 5  console.log(`[DEBUG] Definition request at ${position.line}:${position.character}`);
 6  console.log(`[DEBUG] Source file: ${sourceFile.fileName}`);
 7
 8  const offset = sourceFile.getPositionOfLineAndCharacter(
 9    position.line,
10    position.character
11  );
12  const node = findNodeAtPosition(sourceFile, offset);
13
14  console.log(`[DEBUG] Found node: ${ts.SyntaxKind[node.kind]}`);
15  console.log(`[DEBUG] Node text: ${node.getText().substring(0, 50)}`);
16
17  // ... 后续逻辑
18});

如果 Language Server 是用 Node.js 写的,可以在 VS Code 或 Theia 里直接调试:

  1. 在 Language Server 的代码里打断点;
  2. launch.json 里配置调试启动参数;
  3. 启动调试,触发 LSP 请求时会在断点处停下。
1{
2  "type": "node",
3  "request": "launch",
4  "name": "Debug Language Server",
5  "program": "${workspaceFolder}/server/dist/index.js",
6  "args": ["--stdio"],
7  "console": "integratedTerminal"
8}

对于复杂的分析逻辑,可以把中间结果写到文件里:

 1import * as fs from 'fs';
 2
 3function debugToFile(data: any, filename: string) {
 4  fs.writeFileSync(
 5    filename,
 6    JSON.stringify(data, null, 2),
 7    'utf-8'
 8  );
 9}
10
11// 在分析过程中
12const symbols = analyzeSymbols(sourceFile);
13debugToFile(symbols, 'debug-symbols.json');
14
15const references = findReferences(symbols);
16debugToFile(references, 'debug-references.json');

可以写一些工具函数来辅助调试:

 1// 高亮显示节点在源码中的位置
 2function highlightNode(node: ts.Node, sourceFile: ts.SourceFile) {
 3  const start = node.getStart();
 4  const end = node.getEnd();
 5  const fullText = sourceFile.getFullText();
 6
 7  const before = fullText.substring(0, start);
 8  const target = fullText.substring(start, end);
 9  const after = fullText.substring(end);
10
11  console.log(before + `>>>${target}<<<` + after);
12}
13
14// 打印节点的所有属性
15function inspectNode(node: ts.Node) {
16  const info: any = {
17    kind: ts.SyntaxKind[node.kind],
18    text: node.getText(),
19  };
20
21  // 根据节点类型添加特定属性
22  if (ts.isIdentifier(node)) {
23    info.name = node.text;
24  } else if (ts.isFunctionDeclaration(node)) {
25    info.name = node.name?.text;
26    info.parameters = node.parameters.map((p) => p.name.getText());
27  } else if (ts.isCallExpression(node)) {
28    info.expression = node.expression.getText();
29    info.arguments = node.arguments.map((arg) => arg.getText());
30  }
31
32  console.log(JSON.stringify(info, null, 2));
33}
34
35// 查找特定类型的节点
36function findNodesOfKind(
37  sourceFile: ts.SourceFile,
38  kind: ts.SyntaxKind
39): ts.Node[] {
40  const nodes: ts.Node[] = [];
41
42  function visit(node: ts.Node) {
43    if (node.kind === kind) {
44      nodes.push(node);
45    }
46    ts.forEachChild(node, visit);
47  }
48
49  visit(sourceFile);
50  return nodes;
51}

如果你在 Theia 里开发 Language Server,调试流程是:

  1. 启动 Language Server

    • 在终端里运行 Language Server,或者通过 Theia 的扩展机制启动。
  2. 查看 LSP 消息

    • 在 Language Server 的代码里打印所有收到的 LSP 请求和响应;
    • 或者在 Theia 的 LSP 客户端层添加日志,查看发送和接收的消息。
  3. 在 Monaco 编辑器里触发功能

    • 打开一个文件,触发补全、hover、跳转等功能;
    • 观察 Language Server 的日志输出,看 AST 分析的结果。
  4. 对比预期和实际结果

    • 如果结果不对,回到 AST 分析逻辑,用前面提到的工具函数打印节点信息,找出问题。
1// 错误:直接用 node.kind 比较
2if (node.kind === ts.SyntaxKind.CallExpression) {
3  // ...
4}
5
6// 正确:用 Type Guard 函数
7if (ts.isCallExpression(node)) {
8  // ...
9}

调试时,如果发现节点类型判断不对,可以打印 ts.SyntaxKind[node.kind] 看看实际类型。

1// 获取位置时要注意:getStart() 返回的是字符偏移,不是行列号
2const offset = node.getStart();
3const { line, character } = sourceFile.getLineAndCharacterOfPosition(offset);
4
5// 如果位置不对,检查:
6// 1. 节点是否正确(可能选错了节点)
7// 2. sourceFile 是否正确(可能是旧版本的文件)
8console.log(`Node at offset ${offset} = line ${line + 1}, col ${character + 1}`);
1// 使用 createPrinter 时可以配置选项
2const printer = ts.createPrinter({
3  removeComments: false,
4  newLine: ts.NewLineKind.LineFeed,
5});
6
7// 如果格式不对,检查:
8// 1. 是否保留了原始格式信息
9// 2. 是否需要用 Prettier 等工具二次格式化

调试和可视化 AST 是开发 Language Server、写 ESLint 规则、做代码转换时的必备技能:

  • 打印 AST 结构:最简单直接的方式,适合快速查看;
  • AST Explorer / TypeScript AST Viewer:专业的可视化工具,适合探索和学习;
  • 对比转换前后:写 transformer 时最需要的能力;
  • Language Server 调试:结合日志、断点、文件输出等方式,定位问题。

在 Theia + LSP 的开发流程里,理解如何调试 AST 分析逻辑,
有助于快速定位 Language Server 的问题,验证分析结果的正确性,
最终让 IDE 的语言智能功能更可靠。