AST:检测与自动修复——从规则到 CodeAction

这一篇把 AST 的“读”和“写”连起来:
先写一个检测规则找出代码里的问题,再基于 AST 生成修复后的代码,最后看看这套逻辑在 LSP 的 CodeAction 里是怎么落地的。

假设你想写一个规则,把项目里所有 var 都找出来(因为现代 JS/TS 更推荐用 letconst)。

在 AST 层面,var 对应的是 VariableStatement,而它的 declarationList.flags 里会标记是不是 var

 1import ts from 'typescript';
 2
 3function findVarDeclarations(sourceFile: ts.SourceFile) {
 4  const issues: Array<{
 5    node: ts.VariableStatement;
 6    start: number;
 7    end: number;
 8    line: number;
 9    char: number;
10  }> = [];
11
12  function visit(node: ts.Node) {
13    if (
14      ts.isVariableStatement(node) &&
15      (node.declarationList.flags & ts.NodeFlags.Let) === 0 &&
16      (node.declarationList.flags & ts.NodeFlags.Const) === 0
17    ) {
18      const start = node.getStart();
19      const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
20
21      issues.push({
22        node,
23        start,
24        end: node.getEnd(),
25        line: line + 1,
26        char: character + 1,
27      });
28    }
29
30    ts.forEachChild(node, visit);
31  }
32
33  visit(sourceFile);
34  return issues;
35}

这个函数会返回所有 var 声明的位置信息。
在编辑器里,这些位置通常会被标记成“诊断(Diagnostic)”,显示为波浪线或问题列表。

检测到问题后,下一步是生成修复方案。
在 AST 层面,修复通常有两种思路:

  • 直接替换节点:用 ts.factory 创建新节点,替换掉旧的;
  • 文本替换:算出要改的范围,直接替换字符串。

对于 varlet 这种简单场景,可以直接改文本:

 1function fixVarToLet(
 2  sourceText: string,
 3  issues: Array<{ start: number; end: number }>
 4): string {
 5  // 从后往前替换,避免位置偏移
 6  let result = sourceText;
 7  for (let i = issues.length - 1; i >= 0; i--) {
 8    const { start, end } = issues[i];
 9    const before = result.substring(0, start);
10    const after = result.substring(end);
11    const varKeyword = result.substring(start, end);
12
13    // 简单替换:把 "var " 改成 "let "
14    const fixed = varKeyword.replace(/\bvar\b/, 'let');
15    result = before + fixed + after;
16  }
17  return result;
18}

更稳妥的做法是用 ts.factory 重建 AST,再打印回字符串:

 1function createFixedSourceFile(
 2  sourceFile: ts.SourceFile,
 3  issues: Array<{ node: ts.VariableStatement }>
 4): string {
 5  const transformer: ts.TransformerFactory<ts.SourceFile> = (context) => {
 6    return (sourceFile) => {
 7      function visit(node: ts.Node): ts.Node {
 8        if (
 9          ts.isVariableStatement(node) &&
10          issues.some((issue) => issue.node === node)
11        ) {
12          // 创建一个新的 VariableStatement,把 flags 改成 Let
13          return ts.factory.updateVariableStatement(
14            node,
15            node.modifiers,
16            ts.factory.updateVariableDeclarationList(node.declarationList, {
17              ...node.declarationList,
18              flags: ts.NodeFlags.Let,
19            })
20          );
21        }
22
23        return ts.visitEachChild(node, visit, context);
24      }
25
26      return ts.visitNode(sourceFile, visit) || sourceFile;
27    };
28  };
29
30  const result = ts.transform(sourceFile, [transformer]);
31  const printer = ts.createPrinter();
32  return printer.printFile(result.transformed[0]);
33}

ts.transform + ts.factory 的好处是:
可以精确控制哪些节点要改、哪些保持原样,而且打印出来的代码会保留格式(缩进、换行等)。

检测“导入但未使用”的模块,比 var 检测稍微复杂一点,因为需要:

  • 遍历所有 import 语句,记录导入的符号;
  • 遍历整个文件,看这些符号有没有被引用;
  • 如果某个导入完全没用,标记为问题。
 1function findUnusedImports(sourceFile: ts.SourceFile) {
 2  const importedNames = new Set<string>();
 3  const usedNames = new Set<string>();
 4
 5  // 第一步:收集所有导入的符号
 6  function collectImports(node: ts.Node) {
 7    if (ts.isImportDeclaration(node) && node.importClause) {
 8      if (node.importClause.name) {
 9        importedNames.add(node.importClause.name.text);
10      }
11      if (node.importClause.namedBindings) {
12        if (ts.isNamespaceImport(node.importClause.namedBindings)) {
13          importedNames.add(node.importClause.namedBindings.name.text);
14        } else if (ts.isNamedImports(node.importClause.namedBindings)) {
15          node.importClause.namedBindings.elements.forEach((elem) => {
16            importedNames.add(elem.name.text);
17          });
18        }
19      }
20    }
21    ts.forEachChild(node, collectImports);
22  }
23
24  // 第二步:收集所有使用的标识符
25  function collectUsages(node: ts.Node) {
26    if (ts.isIdentifier(node) && !isInImportOrType(node)) {
27      usedNames.add(node.text);
28    }
29    ts.forEachChild(node, collectUsages);
30  }
31
32  // 简单判断:不在 import/type 位置的 Identifier 才算“使用”
33  function isInImportOrType(node: ts.Node): boolean {
34    let parent = node.parent;
35    while (parent) {
36      if (ts.isImportDeclaration(parent) || ts.isTypeNode(parent)) {
37        return true;
38      }
39      parent = parent.parent;
40    }
41    return false;
42  }
43
44  collectImports(sourceFile);
45  collectUsages(sourceFile);
46
47  // 第三步:找出未使用的导入
48  const unused: ts.ImportDeclaration[] = [];
49  function findUnused(node: ts.Node) {
50    if (ts.isImportDeclaration(node) && node.importClause) {
51      let isUsed = false;
52      if (node.importClause.name && usedNames.has(node.importClause.name.text)) {
53        isUsed = true;
54      }
55      if (node.importClause.namedBindings) {
56        if (
57          ts.isNamespaceImport(node.importClause.namedBindings) &&
58          usedNames.has(node.importClause.namedBindings.name.text)
59        ) {
60          isUsed = true;
61        } else if (ts.isNamedImports(node.importClause.namedBindings)) {
62          const hasAnyUsed = node.importClause.namedBindings.elements.some(
63            (elem) => usedNames.has(elem.name.text)
64          );
65          if (hasAnyUsed) isUsed = true;
66        }
67      }
68      if (!isUsed) {
69        unused.push(node);
70      }
71    }
72    ts.forEachChild(node, findUnused);
73  }
74
75  findUnused(sourceFile);
76  return unused;
77}

修复未使用导入的方式是直接删除整行 import 语句:

 1function removeUnusedImports(
 2  sourceText: string,
 3  unusedImports: ts.ImportDeclaration[]
 4): string {
 5  let result = sourceText;
 6  // 同样从后往前删,避免位置偏移
 7  for (let i = unusedImports.length - 1; i >= 0; i--) {
 8    const imp = unusedImports[i];
 9    const start = imp.getStart();
10    const end = imp.getEnd();
11    // 找到这一行的结束位置(包括换行符)
12    const lineEnd = sourceText.indexOf('\n', end);
13    const actualEnd = lineEnd >= 0 ? lineEnd + 1 : end;
14
15    result = result.substring(0, start) + result.substring(actualEnd);
16  }
17  return result;
18}

在 Language Server 里,检测和修复通常通过两个 LSP 协议接口暴露:

  • textDocument/publishDiagnostics:把检测到的问题发回客户端(编辑器),显示成波浪线或问题列表;
  • textDocument/codeAction:当用户点击“快速修复”时,返回可用的修复方案。

一个典型的 CodeAction 响应大概长这样:

 1// 在 Language Server 内部
 2connection.onCodeAction((params) => {
 3  const { textDocument, range, context } = params;
 4  const sourceFile = getSourceFile(textDocument.uri);
 5  const diagnostics = context.diagnostics;
 6
 7  const codeActions: lsp.CodeAction[] = [];
 8
 9  for (const diagnostic of diagnostics) {
10    // 如果诊断是“未使用的导入”
11    if (diagnostic.code === 'unused-import') {
12      codeActions.push({
13        title: 'Remove unused import',
14        kind: lsp.CodeActionKind.QuickFix,
15        edit: {
16          changes: {
17            [textDocument.uri]: [
18              {
19                range: diagnostic.range,
20                newText: '', // 删除这一行
21              },
22            ],
23          },
24        },
25      });
26    }
27
28    // 如果是 "var" 相关诊断
29    if (diagnostic.code === 'prefer-let') {
30      codeActions.push({
31        title: 'Replace var with let',
32        kind: lsp.CodeActionKind.QuickFix,
33        edit: {
34          changes: {
35            [textDocument.uri]: [
36              {
37                range: diagnostic.range,
38                newText: 'let', // 替换 var
39              },
40            ],
41          },
42        },
43      });
44    }
45  }
46
47  return codeActions;
48});

在 Theia 里,当你在 Monaco 编辑器里看到红色波浪线,点击“快速修复”时,
Theia 会调用 Language Server 的 codeAction,拿到修复方案,再通过 workspace/applyEdit 把修改应用到文件。

ESLint 的规则、Prettier 的格式化,本质上也是“检测 + 修复”的流程:

  • ESLint:用 ESTree 格式的 AST,写 rule.create(context),在遍历时检测问题,用 context.report + fixer 提供修复;
  • Prettier:解析成 AST,按风格规则重新打印,相当于“全文件修复”。

你在 Theia 里配置 ESLint/Prettier 时,它们通常也会通过 LSP 或文件保存钩子接入,
最终在编辑器里看到的“自动修复建议”,背后就是这套 AST 检测 + 生成修复的逻辑。

从检测到修复,再到 LSP CodeAction,整个链路的核心是:

  • AST 提供结构化的代码表示,让检测规则可以精确匹配语法模式;
  • ts.factory / ts.transform 提供安全的代码生成,避免字符串替换的脆弱性;
  • LSP CodeAction 把修复方案标准化,让编辑器可以统一展示和执行。

在 Theia + Monaco + LSP 这套架构里,Language Server 负责跑检测和生成修复,
Monaco 负责展示诊断和触发 CodeAction,而 AST 是服务器端做这些事情的底层数据结构。