Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
418db11e6d |
No files matched your search
@@ -0,0 +1,93 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js";
|
||||
|
||||
/** Group event methods by emitter key, keeping each operation's resolution. */
|
||||
const emitters = (source: string) => {
|
||||
const groups = new Map<string, string[]>();
|
||||
for (const { emitterKey, method, resolution } of analyzeJavaScriptSemantics(
|
||||
source,
|
||||
).eventOperations)
|
||||
groups.set(emitterKey, [
|
||||
...(groups.get(emitterKey) ?? []),
|
||||
`${method}:${resolution}`,
|
||||
]);
|
||||
return [...groups.values()];
|
||||
};
|
||||
|
||||
it("keeps this receivers of unrelated classes in separate emitters", () => {
|
||||
expect(
|
||||
emitters(`
|
||||
import { EventEmitter } from "node:events";
|
||||
class Producer extends EventEmitter {
|
||||
run() { this.on("ready", () => {}); }
|
||||
}
|
||||
class Unrelated extends EventEmitter {
|
||||
run() { this.emit("ready"); }
|
||||
}
|
||||
const stable = new EventEmitter();
|
||||
stable.on("ready", () => {});
|
||||
stable.emit("ready");
|
||||
`),
|
||||
).toEqual([
|
||||
["on:complete"],
|
||||
["emit:complete"],
|
||||
["on:complete", "emit:complete"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("shares one class receiver across its members", () => {
|
||||
expect(
|
||||
emitters(`
|
||||
class Service extends EventEmitter {
|
||||
constructor() { super(); this.on("ready", () => {}); }
|
||||
start() { this.emit("ready"); }
|
||||
stop = () => this.off("ready");
|
||||
}
|
||||
`),
|
||||
).toEqual([["on:complete", "emit:complete", "off:complete"]]);
|
||||
});
|
||||
|
||||
it("keeps a local class hierarchy as one candidate receiver", () => {
|
||||
expect(
|
||||
emitters(`
|
||||
class Base extends EventEmitter { run() { this.on("ready", () => {}); } }
|
||||
class Child extends Base { start() { this.emit("ready"); } }
|
||||
`),
|
||||
).toEqual([["on:partial", "emit:partial"]]);
|
||||
});
|
||||
|
||||
it("scopes this to the function or object that binds it", () => {
|
||||
expect(
|
||||
emitters(`
|
||||
class Owner extends EventEmitter {
|
||||
run() {
|
||||
this.on("ready", () => {});
|
||||
function detached() { this.emit("ready"); }
|
||||
const nested = () => this.emit("ready");
|
||||
}
|
||||
}
|
||||
const host = {
|
||||
open() { this.on("ready", () => {}); },
|
||||
close: function () { this.emit("ready"); },
|
||||
};
|
||||
this.emit("ready");
|
||||
`),
|
||||
).toEqual([
|
||||
["on:complete", "emit:complete"],
|
||||
["emit:complete"],
|
||||
["on:complete", "emit:complete"],
|
||||
["emit:complete"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps static members apart from instances", () => {
|
||||
expect(
|
||||
emitters(`
|
||||
class Registry extends EventEmitter {
|
||||
static boot() { this.emit("ready"); }
|
||||
run() { this.on("ready", () => {}); }
|
||||
}
|
||||
`),
|
||||
).toEqual([["emit:complete"], ["on:complete"]]);
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
type JavaScriptSemanticBindingState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { createSemanticThisReceivers } from "./javascriptSemanticThisReceivers.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import { range } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import {
|
||||
@@ -91,7 +92,12 @@ export const collectJavaScriptSemanticAsyncEffects = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
callables: readonly JavaScriptSemanticCallable[],
|
||||
): AsyncEffectAnalysis => {
|
||||
const events: JavaScriptSemanticEventOperation[] = [];
|
||||
const events: EventCollection = {
|
||||
state,
|
||||
receivers: createSemanticThisReceivers(state),
|
||||
output: [],
|
||||
classReceivers: new Map(),
|
||||
};
|
||||
const timers: TimerCandidate[] = [];
|
||||
const callableIds = new Set(callables.map(({ callableId }) => callableId));
|
||||
const callableStack = createSemanticCallableIdStack(callableIds);
|
||||
@@ -99,9 +105,10 @@ export const collectJavaScriptSemanticAsyncEffects = (
|
||||
traverseJavaScriptAst(program, {
|
||||
enter: (node, parent) => {
|
||||
callableStack.enter(node, parent);
|
||||
if (t.isClass(node)) events.receivers.record(node);
|
||||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
||||
const ownerCallableId = callableStack.callables.at(-1) ?? null;
|
||||
collectEvent(node, ownerCallableId, state, events);
|
||||
collectEvent(node, ownerCallableId, events);
|
||||
collectTimer({
|
||||
node,
|
||||
ownerCallableId,
|
||||
@@ -118,17 +125,43 @@ export const collectJavaScriptSemanticAsyncEffects = (
|
||||
},
|
||||
});
|
||||
return {
|
||||
eventOperations: events,
|
||||
eventOperations: resolveClassReceiverEvents(events),
|
||||
timerOperations: resolveTimers(timers),
|
||||
};
|
||||
};
|
||||
|
||||
interface EventCollection {
|
||||
readonly state: JavaScriptSemanticAnalysisState;
|
||||
readonly receivers: ReturnType<typeof createSemanticThisReceivers>;
|
||||
readonly output: JavaScriptSemanticEventOperation[];
|
||||
/** Class receivers, whose exactness is known once every class is recorded. */
|
||||
readonly classReceivers: Map<
|
||||
JavaScriptSemanticEventOperation,
|
||||
t.ClassDeclaration | t.ClassExpression
|
||||
>;
|
||||
}
|
||||
|
||||
/** A class receiver shared by several local classes is only a candidate. */
|
||||
const resolveClassReceiverEvents = ({
|
||||
output,
|
||||
receivers,
|
||||
classReceivers,
|
||||
}: EventCollection): JavaScriptSemanticEventOperation[] =>
|
||||
output.map((operation) => {
|
||||
const klass = classReceivers.get(operation);
|
||||
return klass === undefined ||
|
||||
operation.resolution !== "complete" ||
|
||||
receivers.exact(klass)
|
||||
? operation
|
||||
: { ...operation, resolution: "partial" };
|
||||
});
|
||||
|
||||
const collectEvent = (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
ownerCallableId: string | null,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
output: JavaScriptSemanticEventOperation[],
|
||||
collection: EventCollection,
|
||||
): void => {
|
||||
const { state, output } = collection;
|
||||
const member = dataEffectMemberCallee(node);
|
||||
if (member === null) return;
|
||||
const method = EVENT_METHODS.find(
|
||||
@@ -142,9 +175,9 @@ const collectEvent = (
|
||||
const listenerArgument = EVENT_LISTENER_METHODS.includes(method)
|
||||
? node.arguments[1]
|
||||
: undefined;
|
||||
const emitter = emitterIdentity(member.object, state);
|
||||
const emitter = emitterIdentity(member.object, collection);
|
||||
const eventName = dataEffectLiteralString(eventArgument);
|
||||
output.push({
|
||||
const operation: JavaScriptSemanticEventOperation = {
|
||||
eventId: `event:${kind}:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
|
||||
kind,
|
||||
method,
|
||||
@@ -163,7 +196,10 @@ const collectEvent = (
|
||||
: emitter.complete
|
||||
? "complete"
|
||||
: "partial",
|
||||
});
|
||||
};
|
||||
output.push(operation);
|
||||
if (emitter.klass !== null)
|
||||
collection.classReceivers.set(operation, emitter.klass);
|
||||
};
|
||||
|
||||
const eventKind = (
|
||||
@@ -178,11 +214,12 @@ const eventKind = (
|
||||
|
||||
const emitterIdentity = (
|
||||
node: t.Expression | t.Super,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
{ state, receivers }: EventCollection,
|
||||
): {
|
||||
readonly key: string;
|
||||
readonly bindingId: string | null;
|
||||
readonly complete: boolean;
|
||||
readonly klass: t.ClassDeclaration | t.ClassExpression | null;
|
||||
} => {
|
||||
if (t.isIdentifier(node)) {
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
@@ -191,19 +228,23 @@ const emitterIdentity = (
|
||||
key: `binding:${binding.bindingId}`,
|
||||
bindingId: binding.bindingId,
|
||||
complete: true,
|
||||
klass: null,
|
||||
};
|
||||
return {
|
||||
key: `global:${node.name}`,
|
||||
bindingId: null,
|
||||
complete: !semanticResolutionBlocked(state, node, node.name),
|
||||
klass: null,
|
||||
};
|
||||
}
|
||||
// Each binder of `this` supplies its own receiver.
|
||||
if (t.isThisExpression(node))
|
||||
return { key: "this", bindingId: null, complete: true };
|
||||
return { ...receivers.receiver(node), bindingId: null, complete: true };
|
||||
return {
|
||||
key: `expression:${String(node.start ?? -1)}:${String(node.end ?? -1)}`,
|
||||
bindingId: null,
|
||||
complete: false,
|
||||
klass: null,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import { semanticClassCandidates } from "./javascriptSemanticObjectReferences.js";
|
||||
import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js";
|
||||
|
||||
type SemanticClass = t.ClassDeclaration | t.ClassExpression;
|
||||
|
||||
/** A `this` receiver scoped to the class, object, function, or program binding it. */
|
||||
export interface SemanticThisReceiver {
|
||||
readonly key: string;
|
||||
/** The class whose instances or constructor supply this receiver. */
|
||||
readonly klass: SemanticClass | null;
|
||||
}
|
||||
|
||||
const nodeKey = (node: t.Node): string =>
|
||||
`${String(node.start ?? -1)}:${String(node.end ?? -1)}`;
|
||||
|
||||
const isComputedMemberKey = (owner: t.Node, child: t.Node): boolean =>
|
||||
(t.isClassMethod(owner) ||
|
||||
t.isClassPrivateMethod(owner) ||
|
||||
t.isObjectMethod(owner) ||
|
||||
t.isClassProperty(owner) ||
|
||||
t.isClassAccessorProperty(owner)) &&
|
||||
owner.key === child;
|
||||
|
||||
/**
|
||||
* Scope `this` receivers to the owner that binds them. A class member's
|
||||
* receiver is shared by its local class hierarchy, since a base method may run
|
||||
* on a subclass instance; a hierarchy with several classes, or an ambiguous
|
||||
* base, only identifies a candidate receiver.
|
||||
*/
|
||||
export const createSemanticThisReceivers = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
) => {
|
||||
const roots = new Map<SemanticClass, SemanticClass>();
|
||||
const classes: SemanticClass[] = [];
|
||||
// Hierarchy sizes are resolved only when a class receiver needs them.
|
||||
let familySizes: Map<SemanticClass, number> | undefined;
|
||||
const ambiguous = new Set<SemanticClass>();
|
||||
const rootOf = (klass: SemanticClass): SemanticClass => {
|
||||
const cached = roots.get(klass);
|
||||
if (cached !== undefined) return cached;
|
||||
const seen = new Set<SemanticClass>([klass]);
|
||||
let current = klass;
|
||||
while (current.superClass != null) {
|
||||
const bases = semanticClassCandidates(current.superClass, state);
|
||||
if (bases.length > 1) ambiguous.add(klass);
|
||||
const base = bases.length === 1 ? bases[0] : undefined;
|
||||
if (base === undefined || seen.has(base)) break;
|
||||
seen.add(base);
|
||||
current = base;
|
||||
}
|
||||
roots.set(klass, current);
|
||||
return current;
|
||||
};
|
||||
const classReceiver = (
|
||||
klass: SemanticClass,
|
||||
isStatic: boolean,
|
||||
): SemanticThisReceiver => ({
|
||||
key: `this:class:${nodeKey(rootOf(klass))}:${isStatic ? "static" : "instance"}`,
|
||||
klass,
|
||||
});
|
||||
const receiverOwner = (owner: t.Node): SemanticThisReceiver | undefined => {
|
||||
const parent = state.parentsByNode.get(owner);
|
||||
if (t.isClassMethod(owner) || t.isClassPrivateMethod(owner)) {
|
||||
const klass = parent && state.parentsByNode.get(parent);
|
||||
return klass !== undefined && t.isClass(klass)
|
||||
? classReceiver(klass, owner.static)
|
||||
: undefined;
|
||||
}
|
||||
if (
|
||||
t.isObjectMethod(owner) ||
|
||||
(t.isFunctionExpression(owner) &&
|
||||
t.isObjectProperty(parent) &&
|
||||
parent.value === owner)
|
||||
) {
|
||||
const object = t.isObjectMethod(owner)
|
||||
? parent
|
||||
: parent && state.parentsByNode.get(parent);
|
||||
if (object !== undefined && t.isObjectExpression(object))
|
||||
return { key: `this:object:${nodeKey(object)}`, klass: null };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
return {
|
||||
/** Record every class so hierarchy sizes are known after traversal. */
|
||||
record: (klass: SemanticClass): void => {
|
||||
classes.push(klass);
|
||||
},
|
||||
receiver: (node: t.ThisExpression): SemanticThisReceiver => {
|
||||
let child: t.Node = node;
|
||||
let owner = state.parentsByNode.get(node);
|
||||
while (owner !== undefined) {
|
||||
if (isComputedMemberKey(owner, child)) {
|
||||
// A computed key evaluates in the enclosing scope.
|
||||
} else if (t.isFunction(owner) && !t.isArrowFunctionExpression(owner)) {
|
||||
return (
|
||||
receiverOwner(owner) ?? {
|
||||
key: `this:function:${nodeKey(owner)}`,
|
||||
klass: null,
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
t.isClassProperty(owner) ||
|
||||
t.isClassPrivateProperty(owner) ||
|
||||
t.isClassAccessorProperty(owner) ||
|
||||
t.isStaticBlock(owner)
|
||||
) {
|
||||
// Field initializers and static blocks run with the class receiver.
|
||||
const body = state.parentsByNode.get(owner);
|
||||
const klass = body && state.parentsByNode.get(body);
|
||||
if (klass !== undefined && t.isClass(klass))
|
||||
return classReceiver(klass, t.isStaticBlock(owner) || owner.static);
|
||||
}
|
||||
child = owner;
|
||||
owner = state.parentsByNode.get(owner);
|
||||
}
|
||||
return { key: "this:program", klass: null };
|
||||
},
|
||||
/** A receiver is exact only for a lone class with an unambiguous base. */
|
||||
exact: (klass: SemanticClass): boolean => {
|
||||
if (familySizes === undefined) {
|
||||
familySizes = new Map();
|
||||
for (const member of classes) {
|
||||
const root = rootOf(member);
|
||||
familySizes.set(root, (familySizes.get(root) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
const root = rootOf(klass);
|
||||
return !ambiguous.has(klass) && familySizes.get(root) === 1;
|
||||
},
|
||||
};
|
||||
};
|
||||
Reference in new issue
Block a user