AST:检测与自动修复——从规则到 CodeAction
这一篇把 AST 的“读”和“写”连起来:
先写一个检测规则找出代码里的问题,再基于 AST 生成修复后的代码,最后看看这套逻辑在 LSP 的 CodeAction 里是怎么落地的。
一个最简单的检测规则:找出所有 var
假设你想写一个规则,把项目里所有 var 都找出来(因为现代 JS/TS 更推荐用 let 或 const)。
在 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创建新节点,替换掉旧的; - 文本替换:算出要改的范围,直接替换字符串。
对于 var → let 这种简单场景,可以直接改文本:
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}
在 LSP CodeAction 里的落地
在 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 的规则、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 是服务器端做这些事情的底层数据结构。