AST:跨文件分析与项目级代码理解——从单文件到整个项目

前面几篇更多聚焦在单个文件的 AST 分析,
这一篇往前推一步:如何用 AST 理解整个项目的代码结构,包括跨文件的符号查找、引用追踪、依赖分析等。

在 IDE 里,很多功能都需要跨文件的信息:

  • 跳转到定义:当前文件里引用的 import { foo } from './bar',定义可能在另一个文件里。
  • 查找所有引用:一个函数可能在多个文件里被调用,需要在整个项目里搜索。
  • 重命名符号:重命名一个导出的函数,需要同时修改所有引用它的地方。
  • 类型检查:TypeScript 的类型系统需要知道所有相关文件的类型信息。

单文件的 AST 只能告诉你“这个文件里有什么结构”,
而项目级的分析需要把多个文件的 AST 连起来,构建一个“符号表(Symbol Table)”或“项目图(Project Graph)”。

在 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,提供跨文件的类型信息和符号查找。

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 的常见功能,
实现思路是:

  1. 在当前文件里找到光标位置的符号;
  2. 通过 TypeChecker 拿到这个符号的定义;
  3. 遍历整个项目的所有文件,找出所有引用这个符号的地方。
 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[],每个元素包含:

  • 符号的定义位置;
  • 所有引用这个符号的位置(可能跨多个文件)。

理解项目的模块结构,需要分析 importexport 语句:

 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 实现里,跨文件分析通常是这样组织的:

 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}
 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 效果。

理解跨文件分析,有助于在扩展中实现更复杂的代码理解功能,
比如项目级的重构、依赖分析、代码导航等。