AST:跨文件分析与项目级代码理解——从单文件到整个项目
前面几篇更多聚焦在单个文件的 AST 分析,
这一篇往前推一步:如何用 AST 理解整个项目的代码结构,包括跨文件的符号查找、引用追踪、依赖分析等。
从单文件到项目级:为什么需要跨文件分析
在 IDE 里,很多功能都需要跨文件的信息:
- 跳转到定义:当前文件里引用的
import { foo } from './bar',定义可能在另一个文件里。 - 查找所有引用:一个函数可能在多个文件里被调用,需要在整个项目里搜索。
- 重命名符号:重命名一个导出的函数,需要同时修改所有引用它的地方。
- 类型检查:TypeScript 的类型系统需要知道所有相关文件的类型信息。
单文件的 AST 只能告诉你“这个文件里有什么结构”,
而项目级的分析需要把多个文件的 AST 连起来,构建一个“符号表(Symbol Table)”或“项目图(Project Graph)”。
TypeScript Program:把多个文件的 AST 组织起来
在 TypeScript Compiler API 里,Program 是管理整个项目的核心对象:
1import * as ts from 'typescript';
2
3// 创建一个 Program,包含多个源文件
4const program = ts.createProgram({
5 rootNames: ['src/a.ts', 'src/b.ts', 'src/c.ts'],
6 options: {
7 target: ts.ScriptTarget.ES2020,
8 module: ts.ModuleKind.ESNext,
9 strict: true,
10 },
11 host: ts.createCompilerHost({}),
12});
13
14// 拿到所有源文件的 AST
15const sourceFiles = program.getSourceFiles();
16sourceFiles.forEach((file) => {
17 console.log(`File: ${file.fileName}`);
18 console.log(` Statements: ${file.statements.length}`);
19});
Program 会:
- 解析所有指定的源文件,生成各自的
SourceFile(AST 根节点); - 解析
import/export语句,建立文件间的依赖关系; - 创建
TypeChecker,提供跨文件的类型信息和符号查找。
符号表:从 AST 节点到跨文件的符号
TypeChecker 是访问“项目级符号信息”的主要入口:
1const checker = program.getTypeChecker();
2
3// 在某个源文件里找到一个 Identifier 节点
4const sourceFile = program.getSourceFile('src/a.ts');
5if (!sourceFile) return;
6
7function findSymbols(node: ts.Node) {
8 if (ts.isIdentifier(node)) {
9 const symbol = checker.getSymbolAtLocation(node);
10 if (symbol) {
11 // 获取符号的定义位置
12 const definition = symbol.valueDeclaration || symbol.getDeclarations()?.[0];
13 if (definition) {
14 const defFile = definition.getSourceFile();
15 const { line, character } = defFile.getLineAndCharacterOfPosition(
16 definition.getStart()
17 );
18
19 console.log(
20 `Symbol "${node.text}" defined at ${defFile.fileName}:${line + 1}:${character + 1}`
21 );
22 }
23
24 // 获取符号的类型
25 const type = checker.getTypeOfSymbolAtLocation(symbol, node);
26 const typeStr = checker.typeToString(type);
27 console.log(` Type: ${typeStr}`);
28 }
29 }
30
31 ts.forEachChild(node, findSymbols);
32}
33
34findSymbols(sourceFile);
checker.getSymbolAtLocation() 返回的 Symbol 对象包含了:
- 定义位置:这个符号在哪个文件的哪个位置定义;
- 类型信息:这个符号的类型是什么;
- 导出信息:如果是从其他文件导入的,可以追溯到原始定义。
查找所有引用:在整个项目里追踪符号
“查找所有引用(Find All References)”是 IDE 的常见功能,
实现思路是:
- 在当前文件里找到光标位置的符号;
- 通过
TypeChecker拿到这个符号的定义; - 遍历整个项目的所有文件,找出所有引用这个符号的地方。
1function findAllReferences(
2 program: ts.Program,
3 sourceFile: ts.SourceFile,
4 position: number
5): ts.ReferencedSymbol[] {
6 const checker = program.getTypeChecker();
7 const node = findNodeAtPosition(sourceFile, position);
8
9 if (!ts.isIdentifier(node)) {
10 return [];
11 }
12
13 const symbol = checker.getSymbolAtLocation(node);
14 if (!symbol) {
15 return [];
16 }
17
18 // 使用 TypeChecker 的 findReferences 方法
19 const references = ts.findReferences(
20 node,
21 program,
22 program.getSourceFiles()
23 );
24
25 return references;
26}
27
28function findNodeAtPosition(
29 sourceFile: ts.SourceFile,
30 offset: number
31): ts.Node | null {
32 let result: ts.Node | null = null;
33
34 function visit(node: ts.Node) {
35 if (node.getStart() <= offset && offset < node.getEnd()) {
36 result = node;
37 ts.forEachChild(node, visit);
38 }
39 }
40
41 visit(sourceFile);
42 return result;
43}
ts.findReferences() 是 TypeScript Compiler API 提供的工具函数,
它会返回一个 ReferencedSymbol[],每个元素包含:
- 符号的定义位置;
- 所有引用这个符号的位置(可能跨多个文件)。
跨文件的导入/导出分析
理解项目的模块结构,需要分析 import 和 export 语句:
1function analyzeImportsExports(program: ts.Program) {
2 const checker = program.getTypeChecker();
3 const moduleGraph = new Map<string, {
4 exports: string[];
5 imports: Array<{ from: string; names: string[] }>;
6 }>();
7
8 program.getSourceFiles().forEach((sourceFile) => {
9 if (sourceFile.isDeclarationFile) return;
10
11 const exports: string[] = [];
12 const imports: Array<{ from: string; names: string[] }> = [];
13
14 function visit(node: ts.Node) {
15 // 收集 export 声明
16 if (ts.isExportDeclaration(node) && node.moduleSpecifier) {
17 const moduleSpec = node.moduleSpecifier.getText().slice(1, -1); // 去掉引号
18 if (node.exportClause && ts.isNamedExports(node.exportClause)) {
19 node.exportClause.elements.forEach((elem) => {
20 exports.push(elem.name.text);
21 });
22 }
23 }
24
25 // 收集 import 声明
26 if (ts.isImportDeclaration(node) && node.importClause) {
27 const moduleSpec = node.moduleSpecifier.getText().slice(1, -1);
28 const names: string[] = [];
29
30 if (node.importClause.name) {
31 names.push(node.importClause.name.text);
32 }
33 if (node.importClause.namedBindings) {
34 if (ts.isNamespaceImport(node.importClause.namedBindings)) {
35 names.push(node.importClause.namedBindings.name.text);
36 } else if (ts.isNamedImports(node.importClause.namedBindings)) {
37 node.importClause.namedBindings.elements.forEach((elem) => {
38 names.push(elem.name.text);
39 });
40 }
41 }
42
43 imports.push({ from: moduleSpec, names });
44 }
45
46 ts.forEachChild(node, visit);
47 }
48
49 visit(sourceFile);
50 moduleGraph.set(sourceFile.fileName, { exports, imports });
51 });
52
53 return moduleGraph;
54}
这个函数会构建一个模块依赖图,告诉你:
- 每个文件导出了什么;
- 每个文件从哪些文件导入了什么。
在 Language Server 里,这类信息可以用来:
- 做“未使用的导出”检测;
- 做“循环依赖”检测;
- 做“重构时的影响范围分析”(比如删除一个导出,需要检查所有导入它的文件)。
在 Language Server 里的实际应用
在真实的 Language Server 实现里,跨文件分析通常是这样组织的:
维护项目状态
1class ProjectManager {
2 private programs = new Map<string, ts.Program>();
3 private fileVersions = new Map<string, number>();
4
5 updateFile(uri: string, content: string) {
6 // 更新文件内容
7 this.fileVersions.set(uri, (this.fileVersions.get(uri) || 0) + 1);
8
9 // 重新创建 Program(或增量更新)
10 const rootNames = Array.from(this.fileVersions.keys());
11 const program = ts.createProgram({
12 rootNames,
13 options: this.getCompilerOptions(),
14 host: this.createCompilerHost(),
15 });
16
17 this.programs.set(uri, program);
18 }
19
20 getProgram(uri: string): ts.Program | undefined {
21 return this.programs.get(uri);
22 }
23}
实现跨文件的 LSP 功能
1// 实现 textDocument/definition
2connection.onDefinition((params) => {
3 const { textDocument, position } = params;
4 const program = projectManager.getProgram(textDocument.uri);
5 if (!program) return null;
6
7 const sourceFile = program.getSourceFile(textDocument.uri);
8 if (!sourceFile) return null;
9
10 const offset = sourceFile.getPositionOfLineAndCharacter(
11 position.line,
12 position.character
13 );
14 const node = findNodeAtPosition(sourceFile, offset);
15 if (!node || !ts.isIdentifier(node)) return null;
16
17 const checker = program.getTypeChecker();
18 const symbol = checker.getSymbolAtLocation(node);
19 if (!symbol) return null;
20
21 const definition = symbol.valueDeclaration || symbol.getDeclarations()?.[0];
22 if (!definition) return null;
23
24 const defFile = definition.getSourceFile();
25 const { line, character } = defFile.getLineAndCharacterOfPosition(
26 definition.getStart()
27 );
28
29 return {
30 uri: defFile.fileName,
31 range: {
32 start: { line, character },
33 end: {
34 line,
35 character: character + definition.getText().length,
36 },
37 },
38 };
39});
40
41// 实现 textDocument/references
42connection.onReferences((params) => {
43 const { textDocument, position } = params;
44 const program = projectManager.getProgram(textDocument.uri);
45 if (!program) return [];
46
47 const sourceFile = program.getSourceFile(textDocument.uri);
48 if (!sourceFile) return [];
49
50 const offset = sourceFile.getPositionOfLineAndCharacter(
51 position.line,
52 position.character
53 );
54 const node = findNodeAtPosition(sourceFile, offset);
55 if (!node || !ts.isIdentifier(node)) return [];
56
57 const references = ts.findReferences(
58 node,
59 program,
60 program.getSourceFiles()
61 );
62
63 const locations: Location[] = [];
64 references.forEach((ref) => {
65 ref.references.forEach((refNode) => {
66 const refFile = refNode.getSourceFile();
67 const { line, character } = refFile.getLineAndCharacterOfPosition(
68 refNode.getStart()
69 );
70
71 locations.push({
72 uri: refFile.fileName,
73 range: {
74 start: { line, character },
75 end: {
76 line,
77 character: character + refNode.getText().length,
78 },
79 },
80 });
81 });
82 });
83
84 return locations;
85});
性能考虑:增量更新与缓存
项目级的 AST 分析比单文件复杂得多,性能是关键:
- 增量更新:文件变更时,只重新解析变更的文件,而不是整个项目。
- 缓存符号表:
TypeChecker内部会缓存符号和类型信息,避免重复计算。 - 懒加载:只解析当前打开或相关的文件,其他文件按需解析。
在 TypeScript Language Server 里,这些优化都已经内置了。
如果你自己实现 Language Server,可以参考类似的策略:
1class IncrementalProjectManager {
2 private parsedFiles = new Map<string, ts.SourceFile>();
3 private program: ts.Program | null = null;
4
5 updateFile(uri: string, content: string) {
6 // 只更新变更的文件
7 const sourceFile = ts.createSourceFile(
8 uri,
9 content,
10 ts.ScriptTarget.Latest,
11 true
12 );
13 this.parsedFiles.set(uri, sourceFile);
14
15 // 重新创建 Program(TypeScript 内部会做增量优化)
16 this.program = ts.createProgram(
17 Array.from(this.parsedFiles.keys()),
18 this.getCompilerOptions(),
19 this.createCompilerHost()
20 );
21 }
22
23 getProgram(): ts.Program | null {
24 return this.program;
25 }
26}
小结
跨文件的 AST 分析是 Language Server 的核心能力:
Program管理整个项目的 AST:把多个文件的语法树组织在一起。TypeChecker提供符号和类型信息:可以跨文件查找定义、类型、引用。- 符号表连接不同文件的 AST:让“跳转定义”“查找引用”这些功能成为可能。
在 Theia + LSP 的架构里,Language Server 负责做这些跨文件分析,
通过 LSP 协议把结果(定义位置、引用列表等)发回前端,
最终在 Monaco 编辑器里呈现为跳转、高亮、问题面板等 UI 效果。
理解跨文件分析,有助于在扩展中实现更复杂的代码理解功能,
比如项目级的重构、依赖分析、代码导航等。