← 文章 / 编程开发
freeCodeCamp 1小时前 · 2026-10-02 00:28:07 · 0 阅读

使用 VS Code 语言 API 在 TypeScript 中构建代码图

现代代码库变得越来越难以导航。这并非主要因为开发者亲自编写的代码更多了,而是因为 AI 编码助手能在几分钟内生成数百甚至数千行代码,使得代码审查成了主要痛点。 在 LLM(大语言模型)出现之前,你可能需要花费数天时间才能写好几行代码。这意味着你对项目的认知随着你的贡献而逐步积累。只有当你加入新团队或入职新公司时,才需要去审阅并熟悉陌生的代码。 但如今,一条提示词(prompt)就能在几分钟内跨上百个文件生成数千行代码。在这种规模下,传统的代码审查方式开始失效,你花在审查代码上的时间甚至超过了实际编写代码的时间。 举个例子,如果你打开一个大型 TypeScript 项目,想要回答一个看似简单的问题,比如:

“谁调用了这个函数?”

你通常得从搜索文件开始。你可能会使用编辑器的“查找引用”功能,在定义之间跳转,或者搜索导入、导出和函数名。 但我们可以换个角度思考这个问题。不要将代码库视为一组文件的集合,而是将其建模为一个图(graph)。 函数变成节点(node),调用关系变成边(edge)。 方法调用若作为图可视化的效果 一旦代码被表示为图,“谁调用了这个函数?”或“这个函数最终调用了什么?”这类问题就转化为了图的遍历问题。 在本教程中,我们将使用 TypeScript 和 VS Code 内置的语言 API 来构建代码图的核心。我们不会自己编写 TypeScript 解析器,而是利用 VS Code 和已安装的语言扩展已经提供的语义信息。最终结果是一个包含文件、函数、方法及调用关系的图,并可以在 VS Code 的 Webview 中展示。

目录

我们要构建什么

我们将构建一个小型代码图引擎,利用 VS Code 的 Call Hierarchy API 来发现函数和方法之间的关系,然后跨多跳遍历这些关系。过程中我们会处理并发、失效的语言工具引用、缓存以及重复遍历等问题,确保图结构可靠且高效。

前置要求

在跟随本文操作之前,你需要熟悉以下内容:

  • TypeScript 以及基于 async/await 的基本异步编程

  • 兼容 VS Code 的任意代码、VS Code 扩展 API,以及 vscode.commands.executeCommand

  • 图的基本概念,例如节点、边和广度优先搜索(BFS)

  • TypeScript 中对 Map、数组和泛型函数的使用

开始吧!

假设我们有以下代码:

function checkout() {
  processPayment();
}

function processPayment() {
  chargeCard();
}

function chargeCard() {
  saveTransaction();
}

function saveTransaction() {
  // persist transaction
}

我们需要将这段源代码转换为一张图。在一个真实的代码库中,这张图可能跨越多个文件:

Image showing what a multi-file codebase would look like when visualized as a graph

该实现主要分两部分:扩展宿主使用 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-analyzer、Python 的 Pyright 或 Pylance。

这一点尤其重要,因为这意味着我们不需要为每种语言单独构建解析器和调用图引擎。只要某个语言扩展通过 VS Code 提供了文档符号和调用层次结构支持,同一套图构建架构就可以直接利用这些信息。

AST 能告诉你某个函数内部包含调用表达式,但它无法自动解析该调用跨越 import、文件、模块、类、别名等语言结构后,具体指向哪个函数。

语言服务器已经完成了大部分语义解析工作。因此,我们无需从头构建解析器和符号解析器,可以直接向 VS Code 查询它已掌握的信息。

2. 扩展初始化

在 package.json 中,我们声明了一个命令:

{
  "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"
  }
}

由于扩展宿主(Extension Host)和 Webview 运行在不同的环境中,需要分别打包。简化的 esbuild 配置如下:

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 之前,需要先确定图的结构。一个实用的模型如下:

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;
}

这里有两个关键概念。

  1. FileNode 包含属于该文件的函数或方法。

  2. CallEdge 表示两个符号之间的关系。

我们保持边的方向一致:

caller → callee

也就是说,如果 checkout() 调用了 processPayment(),图中始终是:

checkout → processPayment

即使这个关系是在查询“谁调用了我”时发现的,方向也不会变。

稳定的符号 ID

函数名并不唯一,一个项目里很容易出现:

// users.ts
function save() {}

和:

// payments.ts
function save() {}

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

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

对于调用层级项,我们使用它的 selectionRange:

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

用 selectionRange 的好处是,它定位的是符号的名称,而不是整个函数体或声明范围。这个稳定的 ID 是去重的基础:当同一个函数从图中多条路径被发现时,我们能识别出这些发现指向的是同一个节点。

4. 查找函数和方法

构建图的第一步,是发现当前文件中的符号。VS Code 通过以下 API 暴露文档符号:

vscode.executeDocumentSymbolProvider

调用方式如下:

/*
 * 借助 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 ?? [];
}

返回的符号是层级结构,比如:

为了让图更易于交互,我们需要构建一个层级结构

我们需要遍历这个层级结构,并收集那些能够代表可调用代码的符号。

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

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

  // 从符号树中递归收集函数、方法和变量
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;
}

变量也值得纳入考量,因为赋值给变量的函数在语言工具中可能会有不同的报告方式。例如:

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

即使某个符号参与调用层级,它也可能被报告为变量。

5. 定位指定位置的符号

当用户打开某个特定方法的图时,我们需要确定包含光标的符号是哪个。由于文档符号具有层级结构,我们可以递归查找包含该位置的最深层符号。

// 查找包含给定位置的最具体符号。
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. 解析调用层级

调用层级(Call Hierarchy)API 的工作分为两个阶段。首先:

position → CallHierarchyItem

然后:

CallHierarchyItem → 传入/传出调用

我们可以这样准备 CallHierarchyItem:

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

  // 该命令返回一个层级项数组。在我们的场景中,
  // 我们只关心光标直接下方的符号,所以取第一个结果即可。
  // 使用可选链(Optional chaining)还能处理在指定位置
  // 无法解析出任何符号的情况。
  return items?.[0];
}

拿到 item 之后,我们就可以查询调用者:

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

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

或者查询被调用者:

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

  // 返回被调用的符号;如果没找到,则默认为空列表。
  return (
    calls ?? []
  ).map(call => call.to);
}

假设存在这样的调用关系:

代码图谱中一个函数通常可以有多个调用者

当请求 processPayment 的传入调用时,语言 API 返回的结果是:

checkout
retryPayment

接着我们将这些结果规范化为:

checkout → processPayment
retryPayment → processPayment

因此,同一套图结构既能表示入边遍历,也能表示出边遍历。

7. 构建符号注册表

遍历图时,同一个符号会反复出现。比如:

通过注册表去重可以改善图的可视化效果,让线条更干净、噪声更少

我们应该为 C 只创建一个节点,而不是三个。注册表就是用来做这层去重的。

class Registry {
  // Keep files and their symbols separately so they can be reused across the graph.
  private readonly files =
    new Map<string, FileNode>();

  // Store symbols by ID for fast lookup and duplicate detection.
  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 {
    // Generate a stable ID from the file and symbol position.
    const id =
      idOf(uri, position);

    // Avoid registering the same symbol more than once.
    if (this.rows.has(id)) {
      return id;
    }

    const fileId =
      uri.toString();

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

    // Create the file entry the first time we encounter it.
    if (!file) {
      file = {
        id: fileId,
        label:
          vscode.workspace
            .asRelativePath(uri),
        file: uri.fsPath,
        symbols: [],
      };

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

    // Normalize VS Code's symbol kind into the graph's simpler representation.
    const row: SymbolRow = {
      id,
      name,
      kind:
        kind ===
        vscode.SymbolKind.Method
          ? 'method'
          : 'function',
      line: position.line,
      character:
        position.character,
    };

    // Store the symbol globally and under its containing file.
    this.rows.set(id, row);
    file.symbols.push(row);

    return id;
  }
}Now the graph builder can repeatedly register symbols without worrying about duplicates.

8. 用 BFS 遍历图

一次调用层级查询只能获取一跳的信息。要构建有用的代码图,需要多跳查询。从 A() 出发的查询可能告诉我们它调用了 B(),但无法揭示 B() 下一步会调用什么。

为了构建实用的图,我们需要反复追踪这些关系:A → B → C → D。每次查询都会将图的层级向外扩展一层,因此我们需要一种遍历策略(如 BFS)来高效地探索多跳路径。

例如:

// 假设一个具有 4 跳的图
A --> B --> C --> D --> E

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

Depth 0: A
Depth 1: B
Depth 2: C
Depth 3: D

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

current frontier
      ↓
discover neighbors
      ↓
next frontier
      ↓
discover neighbors

基础实现如下:

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

  for (
    let depth = 0;
    depth < limit &&
    frontier.length > 0;
    depth++
  ) {
    // 并行解析下一层,将并发请求数限制为六次。
    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;
  }
};

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;

  // 创建多个 worker,共享获取下一个待处理项。
  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;
}

关键区别在于:BFS 只是本地计算,而解析符号往往需要让 VS Code 的语言工具真正干活。

Code Graph View 在遍历每个符号时,可能需要向语言服务查询它的入边或出边调用。这些查询可能涉及解析源文件、解析符号,以及与语言服务器通信。图越大,请求数量也会随之增长。

所以虽然遍历本身很简单,但如果串行执行这些查询,整个过程会慢很多。mapLimit 通过让多个独立的语言工具请求并发执行来解决这个问题,同时限制并发数,避免压垮语言服务。

9. 处理循环引用

真实的代码不是树,而是图。这意味着循环引用是常态。

举个例子:

代码库是图而不是树,因此经常存在循环调用

朴素的递归遍历可能会无限进行下去。因此我们需要记录已经探索过的节点。但这里有个微妙的细节:如果同一个节点会在不同深度被访问到,简单用一个

Set<string>

并不总是够用。更好的做法是,在探索节点时记录剩余的遍历深度。

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

然后:

// 追踪该节点已执行过的最深剩余遍历深度。
const key =
  `${direction}:${node.id}`;
if (
  (explored.get(key) ?? -1)
  < remainingDepth
) {
  // 只有当本次遍历能探索得更深时,才重新访问。
  explored.set(
    key,
    remainingDepth
  );
  next.push(node);
}

这意味着,如果我们之前到达某节点时剩余一跳,后来发现它还有三跳剩余,就可以再次探索该节点。这比简单地将其标记为“已访问”更精准。

10. 限定图的规模

图的规模可能迅速膨胀。一个高连通度的函数可能有数十个调用方,而这些调用方本身又各有数十个调用方。因此,图构建器必须设有明确的上限。

例如:

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

当图触及上限时,我们不应假装图是完整的。而是:

let truncated = false;

并且:

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

生成的 GraphData 可以告知 UI:

此图已被截断。

这比让意外庞大的代码库导致扩展程序看似卡死要好得多。

11. 过滤文件

语言服务器可能返回指向不属于当前应用的文件关系。这些可能是构建文件,或由依赖安装或特定语言的构建/运行时操作生成的输出。例如,TypeScript 项目可能涉及:

node_modules

Python 项目可能涉及:

site-packages

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

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);
}

这样能让图谱始终聚焦于用户的工作区。它也清晰地体现了语言智能与应用行为之间的关键区别:语言服务器告诉我们哪些内容可以被解析,而我们的图谱构建器则决定哪些内容应当纳入图谱。

12. 为什么有些边会悄无声息地消失

在大型图谱中,有些函数明明互相调用,最终却可能没有任何连接,而且系统也不会报告任何错误。问题在于 CallHierarchyItem 依赖于语言服务的状态。一旦该状态过期,查询其调用者或被调用者时就可能返回空数组。从图谱构建器的视角看,这与一个没有任何调用者的函数完全一样。

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

1. 存储纯数据,而非实时项目

每个函数由 NodeRef 表示,其中包含其 ID、URI、名称、类型和位置。单跳查询结果也以 NodeRef 形式缓存。这些信息足以在需要时重建一个调用层次项目。

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

2. 追踪每个已准备项目的“年龄”

一个全局 epoch 计数器会在每次准备新的调用层次项目时递增。每个句柄都记录其项目创建时的 epoch 值。如果某个项目变得足够陈旧,爬虫就会根据存储的 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,
  }));
}

这里的 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,
    }
  );

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

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

Webview 随后可以接收该数据:

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

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

    renderGraph(
      message.data
    );
  }
);

至此,语言服务器侧的问题已经解决完毕。

我们完成了如下转换:

source code

变为:

symbols + relationships

再进一步转化为:

GraphData

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

可以使用 ELK 等库来计算图形节点的布局位置,且不影响图构建的核心逻辑。

14. 测试图构建器

如果每个测试都依赖运行中的 VS Code 实例和真实语言服务器,那么测试这类扩展会很困难。更好的做法是将图构建逻辑与 VS Code 本身隔离开来。爬虫实际只需要依赖少数几个操作:

prepareCallHierarchy
provideIncomingCalls
provideOutgoingCalls

我们可以为这些命令创建一个伪实现。例如:

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
    );
  }
}

这个模拟实现可以建模诸如调用层级状态过期等边界情况。需要注意的是,如果伪实现中使用了特定的会话限制,应将其视为测试模型,而不是自动视为 VS Code 官方 API 的保证。

这样,我们就可以生成一个确定性的图,并将爬虫的输出与一个简单的参考 BFS(广度优先搜索)结果进行对比。

例如:

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

参考实现已知预期边的情况。生产环境中的爬虫则针对模拟的语言服务运行。如果两者结果不一致,测试即失败。这种方法让我们能够在不完全依赖编辑器运行时的情况下,测试复杂的图逻辑。

15. 代码图的局限性

基于语言服务器的调用图很有用,但它并非程序执行的完整表征。静态调用层级分析在解析某些关系时可能非常困难,甚至无法实现。

例如:

  • 动态分派

  • 反射

  • 依赖注入

  • 事件发射器

  • 回调

  • 运行时生成的代码

  • 框架特有行为

考虑如下代码:

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

开发者可能明白这会在事件与 handlePayment 之间建立一种运行时关系。但静态调用图未必将其表示为普通的函数调用。因此,图的质量部分取决于语言服务器以及它能解析的关系类型。

因此,应将此图理解为一种语义近似,而非完美的运行时模型。此外,还有一个语言特定的维度。

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

结语

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

核心流程如下:

Document -> Document Symbols -> Call Hierarchy -> Graph Nodes + Edges -> BFS Traversal -> GraphData -> Visualization

工程上最重要的决策其实不在于如何画图,而在于:选一个实用的图模型、构建稳定的符号标识、正确解析传入和传出的调用、控制遍历的深度与并发、处理循环引用,并把 language server 的状态视为随时可能变化的东西。

这些基础打好之后,可视化就只是一个独立的问题。正是这种解耦,让这套架构的价值超越了单一的 VS Code 扩展。

同一套图模型未来还可以支撑依赖探索、变更影响分析、架构视图、AI 上下文选择,以及其他 navigate 日益复杂代码库的方式。

我基于这套思路做了一个可运行的版本,代码在这里:https://github.com/otobongfp/code-graph-view。

期待看到大家用代码图做出各种有趣的东西,为软件工程流程添砖加瓦。

原始来源: freeCodeCamp

评论 (0)