diff --git a/apps/web/src/map-model.test.ts b/apps/web/src/map-model.test.ts
index 0391c6e..cf5e017 100644
--- a/apps/web/src/map-model.test.ts
+++ b/apps/web/src/map-model.test.ts
@@ -132,6 +132,23 @@ describe("mapModel", () => {
expect(mapModel(world, new Set(), null).portals).toEqual([]);
});
+ it("marks the rooms of the unit the player is in", () => {
+ const everywhere = new Set(demo.rooms.map(({ room }) => room.id));
+ const main = demo.rooms
+ .filter(({ room }) => room.cluster === "demo.ts::main")
+ .map(({ room }) => room.id);
+ expect(main.length).toBeGreaterThan(1);
+ const model = mapModel(demo, everywhere, main[0] ?? null, "demo.ts::main");
+ expect(
+ model.rooms.filter((room) => room.active).map((room) => room.id)
+ ).toEqual(main);
+ expect(model.rooms.find((room) => room.id === "demo.ts")?.unit).toBe(
+ "demo.ts"
+ );
+ const none = mapModel(demo, everywhere, main[0] ?? null);
+ expect(none.rooms.some((room) => room.active)).toBe(false);
+ });
+
it("draws one area of a world of several: only its rooms", () => {
const { codeGraph } = codeGraphOf(examples.repo);
if (codeGraph === null) {
diff --git a/apps/web/src/map-model.ts b/apps/web/src/map-model.ts
index 6c3a012..5214f76 100644
--- a/apps/web/src/map-model.ts
+++ b/apps/web/src/map-model.ts
@@ -30,6 +30,10 @@ type MapRoom = {
readonly visited: boolean;
/** The room the player stands in. */
readonly current: boolean;
+ /** The function (cluster) it belongs to, or the room itself. */
+ readonly unit: string;
+ /** It belongs to the unit the player is in: the function they walk through. */
+ readonly active: boolean;
};
type MapDoor = {
@@ -95,7 +99,8 @@ const pointKey = ({ x, z }: Point): string => `${x.toFixed(3)},${z.toFixed(3)}`;
const mapModel = (
world: BuiltWorld,
visited: ReadonlySet
+ called by {view.callers.join(" · ")} +
+ )} + {view.callees.length > 0 && ( +calls {view.callees.join(" · ")}
+ )}
{view.code.map((line) => (
diff --git a/apps/web/src/source-view.test.ts b/apps/web/src/source-view.test.ts
index e13847d..4fa9564 100644
--- a/apps/web/src/source-view.test.ts
+++ b/apps/web/src/source-view.test.ts
@@ -1,4 +1,5 @@
import type { CodeGraph, FlowStep } from "@repo/code-graph";
+import type { SourceFile } from "@repo/parser";
import { describe, expect, it } from "vitest";
import { examples } from "./examples";
@@ -7,15 +8,19 @@ import { sourceView } from "./source-view";
import type { SourceView } from "./source-view";
import { codeGraphOf } from "./world-from-code";
-/** Parses an example or fails the test with the parser's message. */
-const parsed = (name: "demo" | "loops") => {
- const result = codeGraphOf(examples[name]);
+/** Parses files or fails the test with the parser's message. */
+const parsedFiles = (files: readonly SourceFile[]) => {
+ const result = codeGraphOf(files);
if (result.error !== null) {
- throw new Error(`${name}: ${result.error}`);
+ throw new Error(`${files[0]?.path}: ${result.error}`);
}
return result;
};
+/** Parses an example or fails the test with the parser's message. */
+const parsed = (name: "demo" | "loops" | "portals" | "resolution") =>
+ parsedFiles(examples[name]);
+
/** The first top-level flow node of `fnId` of the given kind. */
const topLevel = (
graph: CodeGraph,
@@ -123,3 +128,101 @@ describe("sourceView", () => {
expectWholeLines(view, examples.loops[0].source);
});
});
+
+describe("sourceView calls", () => {
+ const demo = parsed("demo");
+
+ it("lists a function's callers and callees", () => {
+ expect(
+ sourceView(demo.codeGraph, demo.sources, "demo.ts::getUser")
+ ).toMatchObject({ callers: ["main()"], callees: ["loadSession()"] });
+ });
+
+ it("lists the function's calls in a room of its flow too", () => {
+ const fork = topLevel(demo.codeGraph, "demo.ts::main", "branch");
+ expect(sourceView(demo.codeGraph, demo.sources, fork.id)).toMatchObject({
+ callers: [],
+ callees: ["getUser()", "showDashboard()", "showLogin()"],
+ });
+ });
+
+ it("lists nothing for a hub", () => {
+ expect(sourceView(demo.codeGraph, demo.sources, "demo.ts")).toMatchObject({
+ callers: [],
+ callees: [],
+ });
+ });
+
+ it("marks a function calling itself", () => {
+ const portals = parsed("portals");
+ expect(
+ sourceView(portals.codeGraph, portals.sources, "portals.ts::countdown")
+ ).toMatchObject({
+ callers: ["countdown() ↻", "main()"],
+ callees: ["countdown() ↻"],
+ });
+ });
+
+ it("names an ambiguous call's candidates as maybe", () => {
+ const resolution = parsed("resolution");
+ expect(
+ sourceView(
+ resolution.codeGraph,
+ resolution.sources,
+ "resolution.ts::main"
+ )?.callees
+ ).toEqual([
+ "pick()",
+ "settle()",
+ "maybe Circle.render() | Square.render()",
+ ]);
+ });
+
+ it("names the file of callers that share a name", () => {
+ const caller = {
+ source: 'import { run } from "./lib";\nexport function main() { run(); }',
+ };
+ const shared = parsedFiles([
+ { path: "a.ts", ...caller },
+ { path: "b.ts", ...caller },
+ { path: "lib.ts", source: "export function run() {}" },
+ ]);
+ expect(
+ sourceView(shared.codeGraph, shared.sources, "lib.ts::run")?.callers
+ ).toEqual(["a.ts · main()", "b.ts · main()"]);
+ });
+
+ it("keeps five candidates of an ambiguous call and counts the rest", () => {
+ const shapes = ["A", "B", "C", "D", "E", "F", "G"];
+ const many = parsedFiles([
+ {
+ path: "many.ts",
+ source: [
+ ...shapes.map((shape) => `class ${shape} {\n render() {}\n}`),
+ `function main(shape: ${shapes.join(" | ")}) {\n shape.render();\n}`,
+ ].join("\n"),
+ },
+ ]);
+ expect(
+ sourceView(many.codeGraph, many.sources, "many.ts::main")?.callees
+ ).toEqual([
+ "maybe A.render() | B.render() | C.render() | D.render() | E.render() | +2",
+ ]);
+ });
+
+ it("keeps five names and counts the rest", () => {
+ const names = ["a", "b", "c", "d", "e", "f", "g"];
+ const fan = parsedFiles([
+ {
+ path: "fan.ts",
+ source: [
+ `function main() {\n${names.map((name) => ` ${name}();`).join("\n")}\n}`,
+ ...names.map((name) => `function ${name}() {}`),
+ ].join("\n"),
+ },
+ ]);
+ expect(
+ sourceView(fan.codeGraph, fan.sources, "fan.ts::main")?.callees
+ ).toEqual(["a()", "b()", "c()", "d()", "e()", "+2"]);
+ });
+});
diff --git a/apps/web/src/source-view.ts b/apps/web/src/source-view.ts
index 2b92061..9deaac5 100644
--- a/apps/web/src/source-view.ts
+++ b/apps/web/src/source-view.ts
@@ -1,9 +1,14 @@
-import type { CodeGraph, SourceSpan } from "@repo/code-graph";
+import type { CodeGraph, FunctionNode, SourceSpan } from "@repo/code-graph";
+import { callIndexOf } from "@repo/code-graph/call-index";
+import type { CallLink } from "@repo/code-graph/call-index";
import { roomSubject } from "@repo/code-graph/subjects";
+import { labelOf } from "~/world-from-code";
+
// The source panel's model, kept free of React so it runs under vitest: the
// code of the room the player stands in, as whole lines of its function (or
-// of its file, for a hub), with the room's own code marked.
+// of its file, for a hub), with the room's own code marked, and who calls
+// that function and whom it calls.
/** One line of source, split around the marked part. */
type SourceLine = {
@@ -24,6 +29,68 @@ type SourceView = {
/** `4–8`: the marked lines, else the lines shown. */
readonly lines: string;
readonly code: readonly SourceLine[];
+ /** The function's callers, `count() ↻` for itself; none for a hub or a lobby. */
+ readonly callers: readonly string[];
+ /** Whom it calls, then `maybe a() | b()` per ambiguous call; cut at `+n`. */
+ readonly callees: readonly string[];
+};
+
+/** Names a call list shows before it ends in `+n`. */
+const CALL_LIST_MAX = 5;
+
+const NO_CALLS: readonly string[] = [];
+
+/** A call or, for an ambiguous one, every function it may reach. */
+type CallEntry = CallLink | readonly FunctionNode[];
+
+/** An ambiguous call's candidates are cut at `CALL_LIST_MAX` too. */
+const functionsIn = (entry: CallEntry): readonly FunctionNode[] =>
+ "fn" in entry ? [entry.fn] : entry.slice(0, CALL_LIST_MAX);
+
+/**
+ * The first `CALL_LIST_MAX` entries, then how many more there are, and as
+ * many candidates of an ambiguous call. Only those shown are labelled, so
+ * a function called from hundreds of places costs no more than one called
+ * from five. `Class.method()`, with its file (`src/a.ts · main()`) when
+ * another function shown shares the name; `↻` for a function calling
+ * itself, `maybe a() | b() | +n` for an ambiguous call.
+ */
+const callList = (
+ codeGraph: CodeGraph,
+ entries: readonly CallEntry[]
+): readonly string[] => {
+ const shown = entries.slice(0, CALL_LIST_MAX);
+ const counts = new Map();
+ for (const fn of shown.flatMap(functionsIn)) {
+ counts.set(fn.qualifiedName, (counts.get(fn.qualifiedName) ?? 0) + 1);
+ }
+ const name = (fn: FunctionNode): string => {
+ const label = labelOf(codeGraph, fn.id);
+ return (counts.get(fn.qualifiedName) ?? 0) > 1
+ ? `${fn.moduleId} · ${label}`
+ : label;
+ };
+ const labels = shown.map((entry) => {
+ if (!("fn" in entry)) {
+ const candidates = functionsIn(entry).map(name);
+ const more = entry.length - candidates.length;
+ return `maybe ${[...candidates, ...(more > 0 ? [`+${more}`] : [])].join(" | ")}`;
+ }
+ return entry.recursive ? `${name(entry.fn)} ↻` : name(entry.fn);
+ });
+ const hidden = entries.length - shown.length;
+ return hidden > 0 ? [...labels, `+${hidden}`] : labels;
+};
+
+const callsOf = (codeGraph: CodeGraph, fn: FunctionNode) => {
+ const index = callIndexOf(codeGraph);
+ return {
+ callers: callList(codeGraph, index.callersOf(fn.id)),
+ callees: callList(codeGraph, [
+ ...index.calleesOf(fn.id),
+ ...index.maybeCalleesOf(fn.id),
+ ]),
+ };
};
type Range = { readonly start: number; readonly end: number };
@@ -92,7 +159,7 @@ const sourceView = (
const subject =
found?.kind === "entrance" || found?.kind === "zone" ? null : found;
const source = subject === null ? undefined : sources.get(subject.module.id);
- if (subject === null || source === undefined) {
+ if (codeGraph === null || subject === null || source === undefined) {
return null;
}
if (subject.kind === "module") {
@@ -103,6 +170,8 @@ const sourceView = (
region: null,
lines: lineRange(1, code.length),
code,
+ callers: NO_CALLS,
+ callees: NO_CALLS,
};
}
if (subject.kind === "class") {
@@ -113,12 +182,15 @@ const sourceView = (
region: null,
lines: lineRange(span.startLine, span.endLine),
code: linesOf(source, span.startLine, span.endLine, null),
+ callers: NO_CALLS,
+ callees: NO_CALLS,
};
}
const { fn } = subject;
const head = {
path: subject.module.path,
fn: `${fn.qualifiedName}()`,
+ ...callsOf(codeGraph, fn),
};
if (subject.kind === "function") {
return {
diff --git a/apps/web/src/world-app.tsx b/apps/web/src/world-app.tsx
index a897ed5..bbaa9f9 100644
--- a/apps/web/src/world-app.tsx
+++ b/apps/web/src/world-app.tsx
@@ -198,6 +198,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
world={generated.world}
visited={visitedIn(visits, seed)}
current={roomId}
+ activeUnit={navigation.roomId}
feed={feed}
/>
)}
@@ -236,6 +237,7 @@ const WorldApp = ({ setup }: { readonly setup: WorldSetup }) => {
labelOf={labelOfArea}
links={areaLinks}
feed={feed}
+ activeUnit={navigation.roomId}
locked={locked}
/>
)}
diff --git a/apps/web/src/world-from-code.ts b/apps/web/src/world-from-code.ts
index 03542e7..13c76a4 100644
--- a/apps/web/src/world-from-code.ts
+++ b/apps/web/src/world-from-code.ts
@@ -313,6 +313,7 @@ export {
breadcrumbOf,
codeGraphOf,
describeRoom,
+ labelOf,
promptOf,
sourceSeedOf,
worldFromCode,
diff --git a/apps/web/src/world-map-model.ts b/apps/web/src/world-map-model.ts
index 88e557d..3c4c53c 100644
--- a/apps/web/src/world-map-model.ts
+++ b/apps/web/src/world-map-model.ts
@@ -24,6 +24,8 @@ type MapAreaRoom = {
readonly id: string;
readonly kind: RoomKind;
readonly rect: Rect;
+ /** The function (cluster) it belongs to, or the room itself. */
+ readonly unit: string;
};
type MapArea = {
@@ -142,6 +144,7 @@ const generatedArea = (
id: room.id,
kind: room.kind,
rect: roomBounds(room),
+ unit: room.cluster ?? room.id,
})),
error: null,
entry: area.id === world.entry,
diff --git a/apps/web/src/world-map-overlay.tsx b/apps/web/src/world-map-overlay.tsx
index 99bf947..c222d07 100644
--- a/apps/web/src/world-map-overlay.tsx
+++ b/apps/web/src/world-map-overlay.tsx
@@ -18,6 +18,8 @@ type WorldMapOverlayProps = {
readonly links: readonly AreaLink[];
/** Where the player is; only the marker follows it. */
readonly feed: PoseFeed;
+ /** The function (or hub) the player is in, drawn brighter. */
+ readonly activeUnit: string | null;
/** Walking: the locked pointer sends the map no wheel or drag. */
readonly locked: boolean;
};
@@ -39,6 +41,7 @@ const WorldMapOverlay = ({
labelOf,
links,
feed,
+ activeUnit,
locked,
}: WorldMapOverlayProps) => {
const model = useMemo(
@@ -74,6 +77,7 @@ const WorldMapOverlay = ({
svgRef={view.ref}
model={model}
player={player}
+ activeUnit={activeUnit}
done={world.pending.length === 0}
/>
diff --git a/apps/web/src/world-map.test.tsx b/apps/web/src/world-map.test.tsx
index 0a279f0..814acb8 100644
--- a/apps/web/src/world-map.test.tsx
+++ b/apps/web/src/world-map.test.tsx
@@ -157,6 +157,29 @@ describe("WorldMap", () => {
expect(near).not.toMatch(/ {
+ const store = codeAreaStore(workspace(), 1);
+ store.ensureAll();
+ const model = worldMapModel(store.snapshot().world);
+ const unit = "apps/web/src/main.ts::main";
+ const rooms = model.areas.flatMap((area) =>
+ area.rooms.filter((room) => room.unit === unit)
+ );
+ expect(rooms.length).toBeGreaterThan(0);
+ const markup = renderToStaticMarkup(
+
+ );
+ const group = new RegExp(`(.*?) `).exec(
+ markup
+ )?.[1];
+ expect(countOf(group ?? "", " )
+ ).not.toContain("data-active-unit");
+ });
+
it("draws zone names larger as the view widens", () => {
const store = codeAreaStore(workspace(), 1);
store.ensureAll();
diff --git a/apps/web/src/world-map.tsx b/apps/web/src/world-map.tsx
index bd0f6f8..81888aa 100644
--- a/apps/web/src/world-map.tsx
+++ b/apps/web/src/world-map.tsx
@@ -1,5 +1,5 @@
import type { Rect } from "@repo/types";
-import { memo, useId } from "react";
+import { memo, useId, useMemo } from "react";
import type { Ref } from "react";
import { PORTAL_COLOURS } from "~/map-model";
@@ -13,6 +13,8 @@ type WorldMapProps = {
readonly model: WorldMapModel;
/** Drawn on its own, so following the player redraws only the marker. */
readonly player: MapPlayer | null;
+ /** The function (or hub) the player is in: its rooms are drawn brighter. */
+ readonly activeUnit?: string | null;
/** Every area generated or given up on: `data-state="done"`. */
readonly done: boolean;
/** What part of the world shows; the whole of it by default. */
@@ -146,6 +148,41 @@ const Layers = ({ model, clip }: LayersProps) => {
*/
const MapLayers = memo(Layers);
+type ActiveRoomsProps = {
+ readonly model: WorldMapModel;
+ readonly unit: string;
+};
+
+/**
+ * The rooms of the unit the player is in, over the layers. Not map detail,
+ * so it still shows zoomed out, and apart from the memoised layers, so
+ * walking into another function redraws only these few rects.
+ */
+const ActiveRooms = ({ model, unit }: ActiveRoomsProps) => {
+ const rooms = useMemo(
+ () =>
+ model.areas.flatMap((area) =>
+ area.rooms.filter((room) => room.unit === unit)
+ ),
+ [model, unit]
+ );
+ return (
+
+ {rooms.map((room) => (
+
+ ))}
+
+ );
+};
+
type ZoneNamesProps = {
readonly model: WorldMapModel;
/** Width of the view in metres: names grow as the map zooms out. */
@@ -196,6 +233,7 @@ const ZoneNames = ({ model, viewWidth }: ZoneNamesProps) => {
const WorldMap = ({
model,
player,
+ activeUnit = null,
done,
viewBox,
svgRef,
@@ -230,6 +268,7 @@ const WorldMap = ({
fill={BACKGROUND}
/>
+ {activeUnit !== null && }
{player !== null && (
+ links.map((link) => link.fn.qualifiedName);
+
+const groupNames = (groups: readonly (readonly FunctionNode[])[]) =>
+ groups.map((group) => group.map((fn) => fn.qualifiedName));
+
+/** `fnId` calls one of `candidates`, which the analysis cannot tell apart. */
+const ambiguous = (
+ graph: CodeGraph,
+ fnId: string,
+ offset: number,
+ candidates: readonly string[]
+): CallSite => {
+ const caller = graph.functions.find((fn) => fn.id === fnId);
+ if (caller === undefined) {
+ throw new Error(`No function "${fnId}"`);
+ }
+ return {
+ id: `${fnId}@${caller.span.start + offset}`,
+ callerId: fnId,
+ calleeName: "shape.render",
+ calleeId: null,
+ resolution: "ambiguous",
+ candidateIds: candidates.map((name) => `shapes.ts::${name}`),
+ kind: "call",
+ awaited: false,
+ span: caller.span,
+ };
+};
+
+/** `draw` makes two ambiguous calls over the same shapes, `paint` one over others. */
+const shapes = (): CodeGraph => {
+ const graph = fixtureGraph({
+ path: "shapes.ts",
+ functions: [
+ { name: "draw" },
+ { name: "paint" },
+ { name: "Square.render" },
+ { name: "Circle.render" },
+ { name: "Line.render" },
+ ],
+ });
+ return {
+ ...graph,
+ callSites: [
+ ...graph.callSites,
+ ambiguous(graph, "shapes.ts::draw", 1, [
+ "Square.render",
+ "Circle.render",
+ ]),
+ ambiguous(graph, "shapes.ts::draw", 2, [
+ "Circle.render",
+ "Square.render",
+ ]),
+ ambiguous(graph, "shapes.ts::draw", 3, ["Line.render", "Circle.render"]),
+ ambiguous(graph, "shapes.ts::paint", 1, ["Line.render", "missing"]),
+ ],
+ };
+};
+
+describe("callIndexOf", () => {
+ it("lists a function's callers and callees from the call edges", () => {
+ const index = callIndexOf(demoGraph());
+ expect(names(index.calleesOf("demo.ts::main"))).toEqual([
+ "getUser",
+ "showDashboard",
+ "showLogin",
+ ]);
+ expect(names(index.callersOf("demo.ts::getUser"))).toEqual(["main"]);
+ expect(names(index.calleesOf("demo.ts::getUser"))).toEqual(["loadSession"]);
+ expect(index.callersOf("demo.ts::main")).toEqual([]);
+ });
+
+ it("sorts by qualified name, not call order", () => {
+ const index = callIndexOf(
+ fixtureGraph({
+ path: "a.ts",
+ functions: [
+ { name: "zeta", calls: ["omega"] },
+ { name: "alpha", calls: ["omega"] },
+ { name: "omega" },
+ ],
+ })
+ );
+ expect(names(index.callersOf("a.ts::omega"))).toEqual(["alpha", "zeta"]);
+ });
+
+ it("flags a function calling itself on both sides", () => {
+ const index = callIndexOf(
+ fixtureGraph({
+ path: "a.ts",
+ functions: [
+ { name: "main", calls: ["count"] },
+ { name: "count", calls: ["count"] },
+ ],
+ })
+ );
+ expect(index.callersOf("a.ts::count")).toEqual([
+ expect.objectContaining({ recursive: true }),
+ expect.objectContaining({ recursive: false }),
+ ]);
+ expect(names(index.callersOf("a.ts::count"))).toEqual(["count", "main"]);
+ expect(index.calleesOf("a.ts::count")).toEqual([
+ expect.objectContaining({ recursive: true }),
+ ]);
+ });
+
+ it("groups an ambiguous call's candidates, once per set", () => {
+ const index = callIndexOf(shapes());
+ expect(groupNames(index.maybeCalleesOf("shapes.ts::draw"))).toEqual([
+ ["Circle.render", "Line.render"],
+ ["Circle.render", "Square.render"],
+ ]);
+ // An unknown candidate leaves one: no choice left to show.
+ expect(index.maybeCalleesOf("shapes.ts::paint")).toEqual([]);
+ expect(index.calleesOf("shapes.ts::draw")).toEqual([]);
+ });
+
+ it("knows nothing of an unknown function", () => {
+ const index = callIndexOf(demoGraph());
+ expect(index.callersOf("nope")).toEqual([]);
+ expect(index.calleesOf("nope")).toEqual([]);
+ expect(index.maybeCalleesOf("nope")).toEqual([]);
+ });
+
+ it("builds one index per graph", () => {
+ const graph = demoGraph();
+ expect(callIndexOf(graph)).toBe(callIndexOf(graph));
+ expect(callIndexOf(demoGraph())).not.toBe(callIndexOf(graph));
+ });
+});
diff --git a/packages/code-graph/src/call-index.ts b/packages/code-graph/src/call-index.ts
new file mode 100644
index 0000000..e3d5abb
--- /dev/null
+++ b/packages/code-graph/src/call-index.ts
@@ -0,0 +1,125 @@
+// Who calls a function and whom it calls, from the graph's call edges, plus
+// the functions an ambiguous call of it may reach. For the source panel's
+// lists; the world itself still follows resolved calls only.
+
+import type { CodeGraph, FunctionNode } from "./code-graph";
+
+/** One function on the other end of a call; `recursive` when it is the same one. */
+type CallLink = {
+ readonly fn: FunctionNode;
+ readonly recursive: boolean;
+};
+
+type CallIndex = {
+ /** The functions calling this one, by qualified name. */
+ readonly callersOf: (fnId: string) => readonly CallLink[];
+ /** The functions this one calls, by qualified name. */
+ readonly calleesOf: (fnId: string) => readonly CallLink[];
+ /**
+ * One group per distinct set of candidates of this function's ambiguous
+ * calls, each by qualified name, the groups by their first member.
+ */
+ readonly maybeCalleesOf: (
+ fnId: string
+ ) => readonly (readonly FunctionNode[])[];
+};
+
+const NONE: readonly never[] = [];
+
+const byName = (a: FunctionNode, b: FunctionNode): number =>
+ a.qualifiedName === b.qualifiedName
+ ? a.id.localeCompare(b.id)
+ : a.qualifiedName.localeCompare(b.qualifiedName);
+
+/** Member by member, then the shorter first. */
+const byMembers = (
+ a: readonly FunctionNode[],
+ b: readonly FunctionNode[]
+): number => {
+ for (const [index, fn] of a.entries()) {
+ const other = b[index];
+ if (other === undefined) {
+ return 1;
+ }
+ const order = byName(fn, other);
+ if (order !== 0) {
+ return order;
+ }
+ }
+ return a.length - b.length;
+};
+
+const pushTo = (map: Map, key: string, value: T) => {
+ const list = map.get(key);
+ if (list === undefined) {
+ map.set(key, [value]);
+ } else {
+ list.push(value);
+ }
+};
+
+/** One index per graph. */
+const known = new WeakMap();
+
+const callIndexOf = (graph: CodeGraph): CallIndex => {
+ const found = known.get(graph);
+ if (found !== undefined) {
+ return found;
+ }
+ const functions = new Map(graph.functions.map((fn) => [fn.id, fn]));
+ const callers = new Map();
+ const callees = new Map();
+ for (const edge of graph.edges) {
+ const source = functions.get(edge.source);
+ const target = functions.get(edge.target);
+ if (edge.type !== "call" || source === undefined || target === undefined) {
+ continue;
+ }
+ const recursive = source === target;
+ pushTo(callers, target.id, { fn: source, recursive });
+ pushTo(callees, source.id, { fn: target, recursive });
+ }
+ const maybe = new Map>();
+ for (const site of graph.callSites) {
+ if (site.resolution !== "ambiguous" || !functions.has(site.callerId)) {
+ continue;
+ }
+ const group = (site.candidateIds ?? [])
+ .flatMap((id) => {
+ const fn = functions.get(id);
+ return fn === undefined ? [] : [fn];
+ })
+ .toSorted(byName);
+ const unique = group.filter((fn, index) => group[index - 1] !== fn);
+ if (unique.length < 2) {
+ continue;
+ }
+ const groups =
+ maybe.get(site.callerId) ?? new Map();
+ groups.set(unique.map((fn) => fn.id).join("\n"), unique);
+ maybe.set(site.callerId, groups);
+ }
+ const sorted = (links: readonly CallLink[]) =>
+ links.toSorted((a, b) => byName(a.fn, b.fn));
+ for (const [id, links] of callers) {
+ callers.set(id, sorted(links));
+ }
+ for (const [id, links] of callees) {
+ callees.set(id, sorted(links));
+ }
+ const index: CallIndex = {
+ callersOf: (fnId) => callers.get(fnId) ?? NONE,
+ calleesOf: (fnId) => callees.get(fnId) ?? NONE,
+ maybeCalleesOf: (fnId) => {
+ const groups = maybe.get(fnId);
+ return groups === undefined
+ ? NONE
+ : [...groups.values()].toSorted(byMembers);
+ },
+ };
+ known.set(graph, index);
+ return index;
+};
+
+export { callIndexOf };
+export type { CallIndex, CallLink };