Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 25 additions & 9 deletions src/semantic_analysis/callGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* emit edges to every *instantiated*, concrete subtype's override of that method. RTA edges carry
* a `ts.dispatch=rta` tag so consumers can tell them apart from the exact declared-type edge.
*/
import { Node, type Project } from "ts-morph";
import { Node, type Project, ts as tsMorphTs } from "ts-morph";
import {
CALL_DEP,
type TSCallEdge,
Expand Down Expand Up @@ -319,15 +319,31 @@ export function indexCallExpressions(project: Project): Map<string, Node> {
for (const sf of project.getSourceFiles()) {
const fp = sf.getFilePath();
if (sf.isDeclarationFile() || fp.includes("/node_modules/")) continue;
sf.forEachDescendant((n) => {
if (Node.isCallExpression(n) || Node.isNewExpression(n) || Node.isTaggedTemplateExpression(n)) {
const s = sf.getLineAndColumnAtPos(n.getStart());
const e = sf.getLineAndColumnAtPos(n.getEnd());
// Full span (start AND end) keys the node uniquely; chained calls like `f(x).g(y)`
// share a start position, so a start-only key would collide and mis-resolve.
idx.set(`${fp}#${s.line}:${s.column}-${e.line}:${e.column}`, n);
// Walk the RAW compiler AST, not `forEachDescendant`. ts-morph wraps every node it visits in a
// JS object and caches it on the SourceFile for the program's lifetime, so traversing with the
// wrapper API materialises the entire AST of every file -- measured at +5.09GB over 6,758 files
// of vscode/src, for the ~471k call-like nodes we actually keep. Recursing the compiler nodes
// costs nothing and we wrap only the matches.
const compilerSf = sf.compilerNode;
const visit = (raw: tsMorphTs.Node): void => {
const k = raw.kind;
if (
k === tsMorphTs.SyntaxKind.CallExpression ||
k === tsMorphTs.SyntaxKind.NewExpression ||
k === tsMorphTs.SyntaxKind.TaggedTemplateExpression
) {
const start = raw.getStart(compilerSf);
const node = sf.getDescendantAtStartWithWidth(start, raw.getEnd() - start);
if (node) {
const s = sf.getLineAndColumnAtPos(start);
const e = sf.getLineAndColumnAtPos(raw.getEnd());
idx.set(`${fp}#${s.line}:${s.column}-${e.line}:${e.column}`, node);
}
}
});
raw.forEachChild(visit);
};
compilerSf.forEachChild(visit);
}
return idx;
}

63 changes: 63 additions & 0 deletions test/call-index-raw-walk.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* `indexCallExpressions` walks the raw compiler AST rather than ts-morph's `forEachDescendant`,
* because the wrapper API caches a JS object per visited node on the SourceFile for the program's
* lifetime (+5.09GB over the 6,758 files of vscode/src). The raw walk must index exactly the same
* call sites under exactly the same keys as the wrapper walk it replaced — this pins that.
*/
import { describe, expect, test } from "bun:test";
import { Node, Project } from "ts-morph";
import { indexCallExpressions } from "../src/semantic_analysis/callGraph";

/** The wrapper-materialising walk this replaced, kept here as the differential oracle. */
function viaWrappers(project: Project): Map<string, Node> {
const idx = new Map<string, Node>();
for (const sf of project.getSourceFiles()) {
const fp = sf.getFilePath();
if (sf.isDeclarationFile() || fp.includes("/node_modules/")) continue;
sf.forEachDescendant((node) => {
if (!Node.isCallExpression(node) && !Node.isNewExpression(node) && !Node.isTaggedTemplateExpression(node))
return;
const s = sf.getLineAndColumnAtPos(node.getStart());
const e = sf.getLineAndColumnAtPos(node.getEnd());
idx.set(`${fp}#${s.line}:${s.column}-${e.line}:${e.column}`, node);
});
}
return idx;
}

describe("indexCallExpressions raw AST walk", () => {
test("indexes the same call sites as the wrapper walk", () => {
const project = new Project({ useInMemoryFileSystem: true });
project.createSourceFile(
"/a.ts",
[
"function f(x: number): number { return x; }",
"class C { m(): void {} static s(): void {} }",
"function tag(s: TemplateStringsArray): string { return ''; }",
"export function driver(): void {",
" f(1);", // call
" new C().m();", // new + method call
" C.s();", // static call
" tag`t`;", // tagged template
" [1, 2].map((v) => f(v));", // nested call in arrow
" (function () { return f(2); })();", // IIFE
" f(f(f(3)));", // nested same-line calls
" const o = { k: () => new C() };", // new inside object literal
" o.k();",
"}",
"export const modScope = f(9);", // module-scope call
].join("\n"),
);
project.createSourceFile("/b.d.ts", "export declare function g(): void;"); // must be skipped

const raw = indexCallExpressions(project);
const oracle = viaWrappers(project);

expect([...raw.keys()].sort()).toEqual([...oracle.keys()].sort());
expect(raw.size).toBe(oracle.size);
for (const [k, node] of raw) expect(node.getStart()).toBe((oracle.get(k) as Node).getStart());
// sanity: the fixture really does contain the constructs, and .d.ts contributed nothing
expect(raw.size).toBeGreaterThan(10);
expect([...raw.keys()].every((k) => k.startsWith("/a.ts#"))).toBe(true);
});
});
Loading