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
5 changes: 4 additions & 1 deletion apps/web/src/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,10 @@ function bar() {}
`,
};

/** Methods as rooms: this.*, static and constructor calls resolve. */
/**
* Methods as rooms: this.*, static and constructor calls resolve, and so
* does a call on `other`, which holds a `new UserService(…)`.
*/
const service: SourceFile = {
path: "service.ts",
source: `class UserService {
Expand Down
48 changes: 29 additions & 19 deletions apps/web/src/world-from-code-lobbies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,38 +33,48 @@ const doorsOf = (world: GeneratedWorld, roomId: string) =>
.map(({ to }) => to);

describe("class lobbies", () => {
it("opens the service example's hub onto main and the UserService lobby", () => {
const { codeGraph, sources } = parsed("service");
it("needs no lobby once main reaches every UserService method", () => {
const { codeGraph } = parsed("service");
const world = worldFromCode(codeGraph, 1);
const lobby = "service.ts//class:UserService";
expect(lobbiesOf(world)).toEqual([lobby]);
expect(doorsOf(world, "service.ts")).toEqual([lobby, "service.ts::main"]);
// create and the constructor are reached by doors from main; load by nobody.
expect(doorsOf(world, lobby)).toEqual(["service.ts::UserService.load"]);
expect(describeRoom(codeGraph, lobby)).toBe(
"service.ts · class UserService"
// `other` holds a `new UserService(…)`, so `other.load()` is a door from
// main like create and the constructor: no method is left for a lobby.
expect(lobbiesOf(world)).toEqual([]);
expect(doorsOf(world, "service.ts")).toEqual(["service.ts::main"]);
expect(codeGraph.edges).toContainEqual(
expect.objectContaining({
type: "call",
source: "service.ts::main",
target: "service.ts::UserService.load",
})
);
});

it("names and shows a class lobby in the HUD", () => {
const { codeGraph, sources } = parsed("repo");
const path = "src/handlers/auth.ts";
const lobby = `${path}//class:Session`;
expect(describeRoom(codeGraph, lobby)).toBe(`${path} · class Session`);
expect(
promptOf(codeGraph, [], {
kind: "door",
roomId: "service.ts",
roomId: path,
targetRoomId: lobby,
})
).toBe("→ class UserService");
expect(breadcrumbOf(codeGraph, [], lobby)).toBe("class UserService");
).toBe("→ class Session");
expect(breadcrumbOf(codeGraph, [], lobby)).toBe("class Session");
const view = sourceView(codeGraph, sources, lobby);
const classLines = (sources.get("service.ts") ?? "")
.split("\n")
.findIndex((line) => line === "}");
const lines = (sources.get(path) ?? "").split("\n");
const start = lines.findIndex((line) => line.startsWith("export class"));
const end = lines.findIndex((line) => line === "}");
expect(view).toMatchObject({
path: "service.ts",
fn: "class UserService",
path,
fn: "class Session",
region: null,
lines: `1–${classLines + 1}`,
lines: `${start + 1}–${end + 1}`,
});
const first = view?.code[0];
expect(`${first?.before}${first?.marked}${first?.after}`).toBe(
"class UserService {"
"export class Session {"
);
expect(view?.code.every((line) => line.marked === "")).toBe(true);
});
Expand Down
4 changes: 4 additions & 0 deletions packages/code-graph/src/code-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,12 +255,16 @@ type CallKind = "call" | "optional-call" | "new";
* How a call reaches an import binding of its module: `helper()` and
* `new Svc()` name it directly (`member` null), `ns.run()` and `Svc.make()`
* through one member access, `new ns.Svc()` too. Deeper chains (`ns.a.b()`) carry none.
* `svc.run()` on a receiver typed as an imported class (`svc: Svc`, `import
* type` included) names that class, with `instance` set.
*/
type ImportVia = {
/** The import binding's local name. */
readonly localName: string;
readonly member: string | null;
readonly isNew: boolean;
/** The binding is the receiver's class and `member` an instance member. */
readonly instance?: boolean;
};

type CallSite = {
Expand Down
43 changes: 29 additions & 14 deletions packages/parser/src/calls.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@ import { callSiteId } from "@repo/code-graph/ids";

import type { Discovered } from "./functions";
import { resolveCallee } from "./resolve";
import type { Dispatch } from "./resolve";
import type { Scope } from "./scope";
import { spanOf } from "./span";
import { childrenOf, guardDepth } from "./walk";

type CallCollector = {
readonly discovered: Discovered;
readonly sites: CallSite[];
readonly dispatch: Map<CallSite, Dispatch>;
};

const callOf = (node: Node): { kind: CallKind; callee: Node } | null => {
Expand Down Expand Up @@ -66,14 +68,10 @@ const visitCalls = (
}
const call = callOf(node);
if (call !== null) {
const { calleeName, target, resolution, candidates, via } = resolveCallee(
call.kind,
call.callee,
scope,
collector.discovered.classes
);
const { calleeName, target, resolution, candidates, via, dispatch } =
resolveCallee(call.kind, call.callee, scope, collector.discovered);
const span = spanOf(node);
collector.sites.push({
const site: CallSite = {
id: callSiteId(callerId, span.start),
callerId,
calleeName,
Expand All @@ -86,7 +84,11 @@ const visitCalls = (
awaited,
span,
...(via === undefined ? {} : { via }),
});
};
collector.sites.push(site);
if (dispatch !== undefined) {
collector.dispatch.set(site, dispatch);
}
}
for (const child of childrenOf(node)) {
visitCalls(collector, callerId, scope, root, child, false, depth + 1);
Expand All @@ -112,14 +114,20 @@ const uniqueIds = (sites: readonly CallSite[]): readonly CallSite[] => {
type CallPass = {
readonly callSites: readonly CallSite[];
readonly edges: readonly CallEdge[];
/** By call site id: calls on typed receivers the linker checks for overrides. */
readonly dispatch: ReadonlyMap<string, Dispatch>;
};

const collectCalls = (
moduleId: string,
program: Node,
discovered: Discovered
): CallPass => {
const collector: CallCollector = { discovered, sites: [] };
const collector: CallCollector = {
discovered,
sites: [],
dispatch: new Map(),
};
visitCalls(
collector,
moduleId,
Expand All @@ -135,11 +143,18 @@ const collectCalls = (
visitCalls(collector, fn.id, scope, node, node, false);
}
}
const callSites = uniqueIds(
collector.sites.toSorted(
(a, b) => a.span.start - b.span.start || a.span.end - b.span.end
)
const sorted = collector.sites.toSorted(
(a, b) => a.span.start - b.span.start || a.span.end - b.span.end
);
const callSites = uniqueIds(sorted);
const dispatch = new Map<string, Dispatch>();
sorted.forEach((site, position) => {
const found = collector.dispatch.get(site);
const unique = callSites[position];
if (found !== undefined && unique !== undefined) {
dispatch.set(unique.id, found);
}
});
const edges = new Map<string, CallEdge>();
for (const site of callSites) {
if (site.calleeId === null || site.callerId === moduleId) {
Expand All @@ -154,7 +169,7 @@ const collectCalls = (
callSiteIds: [...(existing?.callSiteIds ?? []), site.id],
});
}
return { callSites, edges: [...edges.values()] };
return { callSites, edges: [...edges.values()], dispatch };
};

export { collectCalls };
42 changes: 39 additions & 3 deletions packages/parser/src/functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import type {
} from "@repo/code-graph";
import { classId, functionId, uniqueNames } from "@repo/code-graph/ids";

import { declareTypes, recordFields } from "./receiver-types";
import {
bindingNames,
fieldFunction,
Expand All @@ -44,6 +45,8 @@ import type {
} from "./scope";
import { spanOf } from "./span";
import { childrenOf, guardDepth } from "./walk";
import { writesOf } from "./writes";
import type { Writes } from "./writes";

type Discovered = {
readonly functions: DiscoveredFunction[];
Expand All @@ -60,6 +63,8 @@ type Discovered = {
readonly classes: ClassTable[];
/** The same classes as graph nodes. */
readonly classNodes: ClassNode[];
/** What the module writes to (see writes.ts). */
readonly writes: Writes;
};

type Context = {
Expand All @@ -74,6 +79,8 @@ type Context = {
* enclosing scope: a class declared there is not top-level.
*/
readonly enclosed: boolean;
/** What the module writes to, which unsettles a class inferred from `new`. */
readonly writes: Writes;
readonly out: Discovered;
};

Expand Down Expand Up @@ -124,7 +131,14 @@ const register = (
if (table === null) {
scope.locals.set(name, [...(scope.locals.get(name) ?? []), fn]);
}
const inner = functionScope(scope, node, fn, table, isStatic);
const inner = functionScope(
scope,
node,
fn,
table,
isStatic,
context.writes.names
);
context.out.scopeOf.set(fn, inner);
for (const child of childrenOf(node)) {
visit(context, inner, child, false, depth + 1);
Expand All @@ -145,6 +159,9 @@ const visitClassBody = (
instance: new Map(),
static: new Map(),
ctor: null,
fields: new Map(),
superName: isIdentifier(node.superClass) ? node.superClass.name : null,
scope,
};
scope.classes.set(name, table);
context.out.classes.push(table);
Expand All @@ -158,6 +175,9 @@ const visitClassBody = (
});
for (const member of node.body.body) {
const field = fieldFunction(member);
if (field === null) {
recordFields(context.writes, table, member);
}
if (isClassMethod(member) || isClassPrivateMethod(member)) {
const method = memberName(member);
if (method === null) {
Expand Down Expand Up @@ -280,6 +300,7 @@ const visit = (
});
continue;
}
declareTypes(context.writes, scope, id, init);
bindingNames(id, scope.bindings);
if (init !== null && init !== undefined) {
next(init, scope);
Expand Down Expand Up @@ -320,7 +341,14 @@ const visit = (
// class expressions are not rooms: their calls belong to the enclosing
// function, but their parameters are their own.
if (isFunctionLike(node)) {
const inner = functionScope(scope, node, null, null, false);
const inner = functionScope(
scope,
node,
null,
null,
false,
context.writes.names
);
context.out.scopeAt.set(node, inner);
for (const child of childrenOf(node)) {
next(child, inner);
Expand All @@ -336,14 +364,20 @@ const discover = (
moduleId: string,
program: Program,
imports: readonly ImportRecord[],
exportedNames: ReadonlySet<string>
exportedNames: ReadonlySet<string>,
typeImports: readonly ImportRecord[] = []
): Discovered => {
const moduleScope = newScope(null, null, null, false);
for (const record of imports) {
if (record.localName !== null) {
moduleScope.imports.set(record.localName, record);
}
}
for (const record of typeImports) {
if (record.localName !== null) {
moduleScope.typeImports.set(record.localName, record);
}
}
const out: Discovered = {
functions: [],
edges: [],
Expand All @@ -354,13 +388,15 @@ const discover = (
moduleScope,
classes: [],
classNodes: [],
writes: writesOf(program),
};
const context: Context = {
moduleId,
nextName: uniqueNames(),
nextClassName: uniqueNames(),
enclosed: false,
exportedNames,
writes: out.writes,
out,
};
for (const statement of program.body) {
Expand Down
Loading
Loading