freeCodeCamp.org

How to Build a Code Graph in TypeScript Using VS Code's Language APIs

8.5内容质量
How to Build a Code Graph in TypeScript Using VS Code's Language APIs

TL;DR · AI 摘要

使用VS Code语言API构建TypeScript代码图,将函数调用关系转化为图结构,解决大规模代码审查难题。

核心要点

  • 通过VS Code Call Hierarchy API可高效发现函数调用关系
  • 使用BFS遍历和循环检测确保图结构可靠性
  • Webview集成实现可视化代码图交互

结构提纲

按章节快速跳转。

  1. 现代代码库规模扩大导致传统代码审查方式失效,需要图结构解决方案

  2. ·VS Code语言API解析

    利用内置语义信息而非自研解析器,提升开发效率

  3. 定义包含文件、函数、调用关系的稳定符号ID体系

  4. 处理循环引用、缓存失效、并发控制等核心挑战

  5. ›Webview集成

    通过VS Code扩展实现图结构可视化交互

  6. 讨论当前方案在处理大型项目时的性能瓶颈

思维导图

用一张图看清主题之间的关系。

查看大纲文本(无障碍 / 无 JS 友好)
  • 代码图构建
    • 技术方案
      • VS Code API
      • BFS遍历
      • Webview
    • 核心挑战
      • 循环检测
      • 缓存失效
      • 性能优化

金句 / Highlights

值得收藏与分享的关键句。

#TypeScript#VS Code#代码图#API#前端开发
打开原文

如何使用 VS Code 的 Language APIs 在 TypeScript 中构建代码图

2026 年 10 月 1 日

/

#TypeScript

Otobong Peter

现代代码库变得越来越难以导航。这并不一定是因为开发者自己编写了更多代码。真正的原因是代码助手正在生成数百甚至数千行代码,问题已经变成了代码审查。

在大语言模型(LLM)时代之前,你可能需要花费数天时间编写几行代码。这意味着你对任何项目的理解会随着你的贡献逐步增长。你只有在加入新团队或开始新工作时,才需要审查和熟悉新代码。

但如今,一个提示可以在几分钟内生成跨越数百个文件的数千行代码。在这样的规模下,传统的代码审查方式开始失效,你花在代码审查上的时间甚至超过了实际编写代码的时间。

例如,当你打开一个大型 TypeScript 项目并想要回答一个看似简单的问题时:

"什么调用了这个函数?"

你可能会先开始在文件中搜索。你可能会使用编辑器的“查找引用”功能。你可能会在定义之间跳转。你可能会搜索导入、导出和函数名称。

但还有另一种思考问题的方式。与其将代码库视为文件的集合,不如将其建模为图。

函数成为节点,调用成为边。

一旦代码被表示为图,"什么调用了这个函数?" 或 "这个函数最终调用了什么?" 这样的问题就变成了图遍历问题。

在本教程中,我们将使用 TypeScript 和 VS Code 内置的 Language APIs 构建代码图的核心部分。我们不会编写自己的 TypeScript 解析器。相反,我们将使用 VS Code 和已安装的语言扩展已经提供的语义信息。最终结果将是一个包含文件、函数、方法和调用关系的图,可以在 VS Code Webview 中显示。

目录

  • 我们正在构建的内容 先决条件
  • 1. 理解 VS Code Language APIs
  • 2. 设置扩展
  • 3. 设计图数据模型 稳定符号 ID
  • 4. 查找函数和方法
  • 5. 查找特定位置的符号
  • 6. 解析调用层次结构
  • 7. 构建符号注册表
  • 8. 使用 BFS 遍历图
  • 9. 处理循环
  • 10. 限制图的范围
  • 11. 过滤文件
  • 12. 为什么某些边会无声消失 1. 它存储的是普通数据,而不是实时项。2. 它跟踪每个准备项的年龄。3. 它会重试可疑的空结果。
  • 13. 将图连接到 Webview
  • 14. 测试图构建器
  • 15. 代码图的局限性
  • 结论

我们正在构建的内容

我们将构建一个小型代码图引擎,使用 VS Code 的调用层次结构 API 发现函数和方法之间的关系,然后跨多个跳转遍历这些关系。在此过程中,我们将处理并发、过时的语言工具引用、缓存和重复遍历,以确保图保持可靠且高效。

先决条件

在继续之前,你应该熟悉以下内容:

  • TypeScript 和基本的异步编程(使用 async/await)
  • 任何与 VS Code 兼容的代码、VS Code 扩展 API 以及 vscode.commands.executeCommand
  • 基本的图概念,如节点、边和广度优先搜索(BFS)
  • 在 TypeScript 中使用映射、数组和泛型函数

让我们开始吧!

假设有以下代码:

code
function checkout() {
  processPayment();
}

function processPayment() {
  chargeCard();
}

function chargeCard() {
  saveTransaction();
}

function saveTransaction() {
  // 保存事务
}

我们希望将源代码转换为图结构。对于实际的代码库,该图可能跨越多个文件:

该实现包含两个主要部分。扩展宿主使用 VS Code 的语言 API 来发现图结构。Webview 显示生成的图。这个架构中有趣的部分是图构建器。

1. 理解 VS Code 语言 API

VS Code 已经暴露了多个命令,扩展可以使用这些命令来查询语言智能。对于本项目,有四个特别有用:

| 命令 | 目的 | |------|------| | vscode.executeDocumentSymbolProvider | 在文档中查找符号 | | vscode.prepareCallHierarchy | 将位置解析为调用层次项 | | vscode.provideIncomingCalls | 查找调用者 | | vscode.provideOutgoingCalls | 查找被调用者 |

这些 API 位于语言特定实现之上。对于 TypeScript 和 JavaScript,TypeScript 语言服务提供了底层信息。其他语言通过各自的语言扩展和语言服务器(如 Go 的 gopls、Rust 的 rust-analyser、Python 的 Pyright 或 Pylance)暴露类似能力。

这一点尤为重要,因为我们不需要为每种语言单独构建解析器和调用图引擎。如果语言扩展通过 VS Code 提供了文档符号和调用层次支持,相同的图构建架构可以直接使用这些信息。

AST 可以告诉你一个函数包含调用表达式。但它不会自动告诉你该调用在跨导入、文件、模块、类、别名和其他语言结构时引用的是哪个函数。

语言服务器已经完成了大部分语义工作。因此,我们不需要再构建另一个解析器和符号解析器,而是可以直接向 VS Code 请求它已知的信息。

2. 配置扩展

我们的 package.json 声明了一个命令:

code
{
  "main": "./out/extension.js",
  "engines": {
    "vscode": "^1.85.0"
  },
  "activationEvents": [],
  "contributes": {
    "commands": [
      {
        "command": "codeGraphView.open",
        "title": "Code Graph: Open Graph for Active File",
        "icon": "$(type-hierarchy)"
      }
    ],
    "menus": {
      "editor/title": [
        {
          "command": "codeGraphView.open",
          "group": "navigation",
          "when": "resourceLangId == typescript"
        }
      ]
    }
  },
  "dependencies": {
    "elkjs": "^0.9.3"
  }
}

扩展宿主和 Webview 运行在不同环境中,因此它们被单独打包。一个简化的 esbuild 配置如下所示:

code
const extensionConfig = {
  entryPoints: ['src/extension.ts'],
  bundle: true,
  outfile: 'out/extension.js',
  external: ['vscode'],
  format: 'cjs',
  platform: 'node',
};

const webviewConfig = {
  entryPoints: ['webview/main.ts'],
  bundle: true,
  outfile: 'out/webview/main.js',
  format: 'iife',
  platform: 'browser',
};

扩展代码在 Node 环境中运行,而 Webview 代码在浏览器环境中运行。

3. 设计图数据模型

在调用语言 API 之前,我们需要决定图的结构。一个有用的模型是:

code
export interface SymbolRow {
  id: string;
  name: string;
  kind: 'function' | 'method';
  line: number;
  character: number;
}

export interface FileNode {
  id: string;
  label: string;
  file: string;
  symbols: SymbolRow[];
}

export interface CallEdge {
  id: string;
  source: string;
  target: string;
}

export interface GraphData {
  rootFileId: string;
  rootSymbolId?: string;
  roots: string[];
  files: FileNode[];
  edges: CallEdge[];
  truncated: boolean;
}

有两个重要概念。

  • FileNode 包含属于某个文件的函数或方法。
  • CallEdge 表示两个符号之间的关系。

我们始终保持边的方向一致:

code
调用者 → 被调用者

因此如果 checkout() 调用 processPayment(),图中始终包含:

code
checkout → processPayment

即使我们是在查询入站调用时发现这种关系。

稳定的符号 ID

函数名称并不唯一。一个项目可能包含:

code
// users.ts
function save() {}

和:

code
// payments.ts
function save() {}

因此我们需要基于符号位置的标识符。

code
function idOf(
  uri: vscode.Uri,
  pos: vscode.Position
): string {
  return `${uri.toString()}#${pos.line}:${pos.character}`;
}

对于调用层次结构项,我们使用其 selectionRange:

code
function itemId(
  item: vscode.CallHierarchyItem
): string {
  return idOf(
    item.uri,
    item.selectionRange.start
  );
}

使用 selectionRange 的优势在于它能标识符号的名称而非整个主体或声明范围。这种稳定的 ID 成为去重的基础。如果通过图中的不同路径发现了相同的函数,我们可以识别所有发现都指向同一个节点。

4. 查找函数和方法

构建图的第一步是发现活动文件中的符号。VS Code 通过以下方式暴露文档符号:

我们可以这样调用:

code
/*
 * 使用 VS Code 内置的语言服务而非自行解析代码来获取文档中的所有符号。这样我们可以获取文档中的方法、函数和变量
 */
async function getDocumentSymbols(
  uri: vscode.Uri
): Promise<vscode.DocumentSymbol[]> {

  /*
   * `vscode.executeDocumentSymbolProvider` 将分析委托给
   * 为文档语言注册的语言提供者。
   */

  const result =
    await vscode.commands.executeCommand<
      vscode.DocumentSymbol[] | undefined
    >(
      'vscode.executeDocumentSymbolProvider',
      uri
    );

  // 如果未找到符号则返回空列表。
  return result ?? [];
}

返回的符号构成一个层次结构。例如:

我们需要遍历该层次结构并收集可能表示可调用代码的符号。

code
// 检查符号是否可以作为可调用节点处理。
const isCallableKind = (
  kind: vscode.SymbolKind,
  includeConstructors: boolean
) =>
  kind === vscode.SymbolKind.Function ||
  kind === vscode.SymbolKind.Method ||
  (
    includeConstructors &&
    kind === vscode.SymbolKind.Constructor
  );

我们可以递归检查符号树:

code
// 递归地从符号树中收集函数、方法和变量
function collectCandidates(
  symbols: vscode.DocumentSymbol[],
  isCallable: (
    kind: vscode.SymbolKind
  ) => boolean,
  out: vscode.DocumentSymbol[] = []
) {
  for (const symbol of symbols) {
    if (
      isCallable(symbol.kind) ||
      symbol.kind === vscode.SymbolKind.Variable
    ) {
      out.push(symbol);
    } else if (
      symbol.children.length
    ) {
      collectCandidates(
        symbol.children,
        isCallable,
        out
      );
    }
  }
  return out;
}

变量值得考虑,因为赋值给变量的函数可能被语言工具以不同方式报告。例如:

code
const handler = () => {
  // ...
};

该符号可能被报告为变量,即使它参与了调用层次结构。

5. 查找特定位置的符号

当用户打开某个方法的图时,需要确定哪个符号包含光标位置。由于文档符号是分层的,可以通过递归找到包含该位置的最深层符号。

code
// 查找包含给定位置的最具体符号。
function symbolAt(
  symbols: vscode.DocumentSymbol[],
  position: vscode.Position
) {
  for (const symbol of symbols) {
    if (symbol.range.contains(position)) {
      return (
        symbolAt(
          symbol.children,
          position
        ) ?? symbol
      );
    }
  }
  return undefined;
}

这为我们建立了编辑器与图之间的桥梁。用户在源文件中选择一个位置,我们将该位置解析为符号,然后将该符号解析为调用层次结构项。

6. 解析调用层次结构

调用层次结构 API 分为两个阶段。首先:

code
position → CallHierarchyItem

然后:

code
CallHierarchyItem → incoming/outgoing calls

我们可以这样准备项:

code
async function prepare(
  uri: vscode.Uri,
  position: vscode.Position
) {
  // VS Code 的语言工具已经知道如何解析源文件中的符号。
  // 因此我们可以使用它来为该位置的符号准备调用层次结构。
  const items =
    await vscode.commands.executeCommand<
      vscode.CallHierarchyItem[] | undefined
    >(
      'vscode.prepareCallHierarchy',
      uri,
      position
    );

  // 该命令返回一个层次结构项数组。在我们的情况下,
  // 我们关注的是光标直接下方的符号,
  // 因此我们使用第一个结果。
  // 可选链操作符也处理了在给定位置无法解析符号的情况。
  return items?.[0];
}

一旦获得该项,就可以查询调用者:

code
async function callers(
  item: vscode.CallHierarchyItem
) {
  // 向 VS Code 查询所有调用该项的符号。
  const calls =
    await vscode.commands.executeCommand<
      vscode.CallHierarchyIncomingCall[] | undefined
    >(
      'vscode.provideIncomingCalls',
      item
    );

  // 返回调用符号,默认为空列表(当未找到时)。
  return (
    calls ?? []
  ).map(call => call.from);
}

或被调用者:

code
async function callees(
  item: vscode.CallHierarchyItem
) {
  // 向 VS Code 查询所有被该项调用的符号。
  const calls =
    await vscode.commands.executeCommand<
      vscode.CallHierarchyOutgoingCall[] | undefined
    >(
      'vscode.provideOutgoingCalls',
      item
    );

// 返回调用的符号,当未找到时默认返回空列表。 return ( calls ?? [] ).map(call => call.to); }

code

如果我们有:

并询问processPayment的入站调用,语言API会给我们:

checkout retryPayment

code

然后我们将这些结果规范化为:

checkout → processPayment retryPayment → processPayment

code

因此,相同的图结构可以表示入站和出站遍历。

## 7. 构建符号注册表

随着我们爬取图结构,相同的符号可能会重复出现。例如:

我们应该为C创建一个节点,而不是三个。注册表提供了这种去重功能。

class Registry { // 将文件和符号分开存储,以便在图中跨文件重用。 private readonly files = new Map<string, FileNode>();

// 按ID存储符号,以实现快速查找和重复检测。 private readonly rows = new Map<string, SymbolRow>();

get size() { return this.rows.size; }

has(id: string) { return this.rows.has(id); }

register( uri: vscode.Uri, name: string, kind: vscode.SymbolKind, position: vscode.Position ): string { // 从文件和符号位置生成稳定的ID。 const id = idOf(uri, position);

// 避免重复注册相同符号。 if (this.rows.has(id)) { return id; }

const fileId = uri.toString();

let file = this.files.get(fileId);

// 首次遇到该文件时创建条目。 if (!file) { file = { id: fileId, label: vscode.workspace .asRelativePath(uri), file: uri.fsPath, symbols: [], };

this.files.set( fileId, file ); }

// 将VS Code的符号类型规范化为图的简化表示。 const row: SymbolRow = { id, name, kind: kind === vscode.SymbolKind.Method ? 'method' : 'function', line: position.line, character: position.character, };

// 全局存储符号及其所属文件。 this.rows.set(id, row); file.symbols.push(row);

return id; } }现在图构建器可以重复注册符号而无需担心重复问题。

code

## 8. 使用BFS遍历图

单次调用层次查找只能提供一步关系。有用的代码图需要多步关系。从A()的查找可能告诉我们它调用B(),但不会告诉我们B()接下来调用什么。

为了构建有用的图,我们需要反复追踪这些关系:A → B → C → D。每次查找都会扩展图的层级,这就是为什么我们需要使用BFS等遍历策略来高效探索多步关系。

例如:

// 假设一个包含4步的图 A --> B --> C --> D --> E

code

如果我们从A开始并请求深度为三(3步),我们希望得到:

深度0: A 深度1: B 深度2: C 深度3: D

code

广度优先搜索(BFS)是自然的选择,因为图明确围绕跳转深度组织。遍历维护一个前沿:

当前前沿 ↓ 发现邻居 ↓ 下一个前沿 ↓ 发现邻居

code

一个基本实现如下:

const walk = async ( start: Handle, direction: 'incoming' | 'outgoing', limit: number ) => { // 从给定符号开始,逐层遍历调用图。 let frontier: Handle[] = [start];

for ( let depth = 0; depth < limit && frontier.length > 0; depth++ ) { // 并行解析下一层,限制并发数为6次查询。 const results = await mapLimit( frontier, 6, handle => oneHop( handle, direction ) );

const next: Handle[] = [];

frontier.forEach( (handle, index) => { for ( const other of results[index] ) { // 在添加关系之前注册新发现的符号。 if ( !registry.has( other.node.id ) ) { registry.register( other.node.uri, other.node.name, other.node.kind, other.node.pos ); }

// 在图中保留调用关系的方向。 if ( direction === 'outgoing' ) { addEdge( handle.node.id, other.node.id ); } else { addEdge( other.node.id, handle.node.id ); }

next.push(other); } } );

// 从当前深度发现的符号继续遍历。 frontier = next; } };

code

mapLimit 辅助函数用于控制并发的语言服务器请求数量:

async function mapLimit<T, R>( items: T[], limit: number, fn: (item: T) => Promise<R> ): Promise<R[]> { // 同一时间最多运行 limit 个异步操作。 const results = new Array<R>(items.length);

let next = 0;

// 创建共享下一个可用项目的工作者。 const workers = Array.from( { length: Math.min( limit, items.length ), }, async () => { while ( next < items.length ) { const index = next++;

results[index] = await fn( items[index] ); } } ); await Promise.all(workers); return results; }

code

关键区别在于 BFS 仅涉及本地计算,而解析符号通常需要请求 VS Code 的语言工具执行实际工作。

每次访问符号时,Code Graph View 可能需要查询语言服务以获取其入向或出向调用。这些查询可能涉及解析源文件、解析符号以及与语言服务器通信。随着图的扩展,这些请求的数量也会随之增长。

因此,尽管遍历本身很简单,但顺序执行这些查询会使整个过程显著变慢。mapLimit 通过允许多个独立的语言工具请求并发执行,同时限制并发数量以避免压垮语言服务,从而解决了这个问题。

## 9. 处理循环

实际代码不是树结构,而是图结构。这意味着循环是正常的。

简单的递归遍历可能会无限循环。因此我们需要跟踪已经探索过的内容。但有一个微妙的细节需要注意。一个简单的:

Set<string>

code

const explored = new Map<string, number>();

code

// 记录该节点已执行的最深剩余遍历深度。 const key = ${direction}:${node.id}; if ( (explored.get(key) ?? -1) < remainingDepth ) { // 仅当此次遍历能比之前更深入时才重新访问。 explored.set( key, remainingDepth ); next.push(node); }

code

这意味着,如果我们之前以剩余1跳的状态到达某个节点,但之后又以剩余3跳的状态发现它,我们可以再次探索该节点。这比将节点标记为“已访问”更加精确。

## 10. 图的边界控制

图的规模可能增长得非常迅速。一个高度连接的函数可能有数十个调用者,而这些调用者本身也可能各自拥有数十个调用者。因此,图构建器应设置明确的限制。

const MAX_SYMBOLS = 400; const MAX_CALLS_PER_SYMBOL = 50; const HOP_CONCURRENCY = 6;

code

当图达到限制时,我们不应假装图是完整的。相反:

let truncated = false;

code

if ( registry.size >= MAX_SYMBOLS ) { truncated = true; continue; }

code

生成的GraphData可以向UI说明:

此图已被截断。

code

这比让意外庞大的代码库导致扩展程序看似冻结要更好。

## 11. 文件过滤

语言服务器可能会返回与我们正在探索的应用程序无关的文件中的关系。这些可能是构建文件或由依赖项安装或语言特定的构建/运行时操作生成的输出。例如,TypeScript项目可能会引入:

node_modules

code

Python项目可能会引入:

site-packages

code

在将这些路径添加到图之前,我们可以进行过滤。

const DEPENDENCY_DIRS = /\/(node_modules|vendor|target|\.venv|venv|site-packages|__pycache__|build|obj|\.dart_tool)\//;

function isWorkspaceFile( uri: vscode.Uri ): boolean { if ( uri.scheme !== 'file' || DEPENDENCY_DIRS.test(uri.path) ) { return false; } return !!vscode.workspace .getWorkspaceFolder(uri); }

code

这使图保持聚焦于用户的 workspace。这也展示了语言智能与应用程序行为之间的一个重要区别。语言服务器告诉我们它能解析什么,而我们的图构建器决定哪些内容应该成为图的一部分。

## 12. 为什么某些边会无声消失

在大型图中,一些明显相互调用的函数可能会最终没有连接,且没有任何错误报告。问题是CallHierarchyItem与语言服务的状态相关。如果该状态过时,查询其调用者或被调用者可能会返回空数组。从图构建器的角度来看,这看起来就像一个没有调用者的函数。

在VS Code实现中,调用层次结构会话仅保留最近请求的有限数量。我们的爬虫同时可能有多个正在进行的查询,因此在图遍历过程中,旧的条目可能会变得不可用。图构建器通过以下三种方式处理这个问题。

### 1. 它存储的是原始数据,而不是实时项。

每个函数都由一个包含其 ID、URI、名称、类型和位置的 NodeRef 表示。一跳结果也会以 NodeRef 的形式进行缓存。这为我们提供了足够的信息,在需要时可以重新构建调用层次结构项。

interface NodeRef { id: string; uri: vscode.Uri; name: string; kind: vscode.SymbolKind; pos: vscode.Position; }

code

### 2. 它会跟踪每个已准备项的时效性

每当准备新的调用层次结构项时,全局纪元计数器会递增。每个句柄会记录其对应项创建时的纪元值。如果某个项变得足够老旧,爬虫会从已存储的 NodeRef 中重新生成一个新的项。

### 3. 它会重试可疑的空结果

简化版的 oneHop 看起来像这样:

const SESSION_WINDOW = 7;

// 刷新过期的语言工具引用,必要时重试一次 for (let attempt = 0; attempt < 2; attempt++) { const stale = !current.item || epoch - current.epoch > SESSION_WINDOW;

if (stale) { // 在使用过期的 CallHierarchyItem 之前重新解析符号 const fresh = await prepareFresh( current.node.uri, current.node.pos );

if (!fresh) { return []; }

current = fresh; }

const items = await lookup( current.item!, direction );

// 过期引用可能返回空结果,此时应使其失效并重试一次 if ( items.length === 0 && attempt === 0 && epoch - current.epoch > SESSION_WINDOW ) { current = { node: current.node, epoch: -1, };

continue; }

// 缓存解析后的关联关系以避免重复查询语言工具 hopCache.set(key, { nodes: items.map(refOf), at: Date.now(), });

return items.map(child => ({ node: refOf(child), item: child, epoch: current.epoch, })); }

code

此处 lookup 是对之前讨论的 vscode.provideIncomingCalls 或 vscode.provideOutgoingCalls 命令的简写。会话时限的具体数值是 VS Code 的实现细节,而非扩展应依赖的内容。因此爬虫不会假设特定的限制始终存在。SESSION_WINDOW 仅为我们提供了一个保守的阈值,用于刷新老旧句柄。

这减少了丢失的边,但无法保证构建完整的图。语言工具仍可能返回不完整的信息或无法解析某些关系。

这个更广泛的经验教训适用于超出调用层次结构的语言服务 API:存储能够从语言服务重新构建对象所需的信息,而不是对象本身。将来自过期状态的空结果视为潜在未知,而非自动视为无。

## 13. 将图连接到 Webview

一旦构建了图,扩展就需要一个显示它的位置。VS Code 的 Webview 非常适合这个用途。

扩展宿主创建面板:

const panel = vscode.window.createWebviewPanel( 'codeGraphView', 'Code Graph', vscode.ViewColumn.Beside, { enableScripts: true, retainContextWhenHidden: true, } );

code

图以可序列化的数据形式发送到 Webview:

panel.webview.postMessage({ command: 'graphData', data: graphData, });

code

Webview 随后可以接收它:

window.addEventListener( 'message', event => { const message = event.data;

if ( message.command !== 'graphData' ) { return; }

renderGraph( message.data ); } );

code

到此为止,问题中语言服务器部分的实现已经完成。

我们已经将:

源代码

code

转换为:

符号 + 关系

code

然后进一步转换为:

GraphData

code

可视化层现在可以使用这些数据来渲染图。

可以使用 ELK 这样的库来计算图的布局,而不会影响图构建的逻辑。

## 14. 测试图构建器

如果每个测试都需要运行 VS Code 实例和真实语言服务器,这种扩展的测试会变得很困难。更好的方法是将图构建逻辑与 VS Code 本身隔离。爬虫只需要以下几个操作:

prepareCallHierarchy provideIncomingCalls provideOutgoingCalls

code

我们可以为这些命令创建模拟实现。例如:

const sessions = new Map();

let sessionCounter = 0;

async function executeCommand( command, ...args ) { // 模拟 VS Code 在解析符号时创建会话 if ( command === 'vscode.prepareCallHierarchy' ) { const id = 'session-' + ++sessionCounter;

sessions.set(id, true);

return [ createFakeItem( args, id ), ]; }

// 模拟依赖有效会话的调用查询 if ( command === 'vscode.provideIncomingCalls' || command === 'vscode.provideOutgoingCalls' ) { const item = args[0];

// 当 CallHierarchyItem 属于过期会话时返回空 if ( !sessions.has( item.sessionId ) ) { return []; }

return getFakeCalls( item ); } }

code

模拟可以建模过期调用层次状态等边界情况。重要的是,如果模拟使用特定的会话限制,这应被理解为测试模型,而不是自动视为官方 VS Code API 的保证。

然后我们可以生成一个确定性的图,并将爬虫的输出与简单的参考 BFS 进行比较。

flowchart LR A[A] --> B[B] A --> C[C] B --> D[D] C --> D D --> E[E]

code

参考实现知道预期的边。生产环境的爬虫会针对模拟语言服务运行。如果两个结果不同,测试就会失败。这种方法让我们可以在不完全依赖编辑器运行时的情况下测试复杂的图逻辑。

## 15. 代码图的局限性

基于语言服务器的调用图很有用,但它并不是程序执行的完整表示。有些关系可能难以或无法通过静态调用层次分析来解析。

例如包括:

- 动态调度

- 反射

- 依赖注入

- 事件发射器

- 回调

- 运行时生成的代码

- 框架特定行为

考虑以下代码:

eventEmitter.on( 'payment.completed', handlePayment );

code

开发者可能明白这会在事件和 handlePayment 之间创建运行时关系。静态调用图可能不会将这种关系表示为普通函数调用。因此,图的质量在很大程度上取决于语言服务器以及它能解析的关系类型。

这就是为什么应该将图理解为语义近似,而不是完美的运行时模型。还存在语言特定的维度。

图构建器本身可以保持大部分语言无关性,但不同的语言扩展可能为文档符号和调用层次提供不同级别的支持。

## 结论

构建代码图并不需要从头编写编译器或实现解析器。VS Code 通过其语言 API 已经暴露了大量语义信息。

核心流程如下:

文档 -> 文档符号 -> 调用层次 -> 图节点 + 边 -> 广度优先遍历 -> 图数据 -> 可视化

code

最重要的工程决策并不在于绘制图表本身。它们涉及选择有用的图模型、创建稳定的符号标识、正确解释入栈和出栈调用、控制遍历深度和并发性、处理循环,以及将语言服务器状态视为可能变化的实体。

一旦这些基础模块就位,可视化就变成了一个独立的问题。这种分离正是使该架构超越单一 VS Code 扩展的实用性所在。

相同的图模型最终可以用于依赖关系探索、变更影响分析、架构视图、AI 上下文选择,以及导航日益复杂的代码库的其他方式。

我基于此构建了一个可用版本,可通过以下链接访问:https://github.com/otobongfp/code-graph-view 。

我期待看到你们基于图结构为软件工程流程创造出的所有精彩应用。

合作伙伴推荐课程

(opens in a new tab)

ADVERTISEMENT

- Udemy 上的 TypeScript 入门 Daniel Stern 4.2(8,809 人评分) 1小时6分钟 中级 查看课程 (opens in a new tab)
- Udemy 上的 React & TypeScript 扩展开发 Jason Xian 4.6(1,420 人评分) 8小时52分钟 初级 证书 查看课程 (opens in a new tab)
- Coursera 上的 TypeScript 入门 Per Harald Borgen 和 Ania Kubow 4.6(60 人评分) 4周,每周1小时 中级 证书 查看课程 (opens in a new tab)
- Coursera 上的 TypeScript 变量与数据类型 Chaitra Deshpande 4.4(104 人评分) 1小时55分钟 初级 证书 查看课程 (opens in a new tab)

更多 TypeScript 课程请访问 Class Central

→

构建项目

如果你读到了这里,请感谢作者以表达你的支持。说声谢谢

免费学习编程。freeCodeCamp 的开源课程已帮助超过 40,000 人成为开发者。立即开始