mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-27 06:43:19 +08:00
Call factory-defined static methods with their receiver class
A static method that a function-local JavaScript class defines at
runtime and a program class inherits (Animal.extend("Dog")) is now
called directly with this bound to the receiver class when it captures
nothing from the factory's scope, so the subclass it builds has a static
base and constructs like Node.
Fixes #938
Co-authored-by: phall <25888173+phall1@users.noreply.github.com>
This commit is contained in:
@@ -1431,6 +1431,17 @@ export function implicitCallInstance(
|
||||
shapes.push({ type: bound, mode: "required" });
|
||||
return;
|
||||
}
|
||||
// `this` in a static method specialized for its receiver class is
|
||||
// that class: bind it as a call naming the class would.
|
||||
if (stored?.kind === "classval" && arg.kind === ts.SyntaxKind.ThisKeyword) {
|
||||
const classDecl = lowerer.classes.get(stored.className)?.decl;
|
||||
const classType = classDecl?.name ? lowerer.typeOf(classDecl.name) : null;
|
||||
if (classType) {
|
||||
argTypes.set(sym, classType);
|
||||
shapes.push({ type: stored, mode: "required" });
|
||||
return;
|
||||
}
|
||||
}
|
||||
// A represented native array must keep its storage when a direct
|
||||
// inferred helper receives it. Re-boxing a checker-any array would
|
||||
// copy it, losing writes (and attempting to read unfilled slots).
|
||||
|
||||
@@ -386,6 +386,9 @@ export interface ClassInfo {
|
||||
def: IrClassDef;
|
||||
expressionInitializationEmitted?: true;
|
||||
runtimeStatics?: readonly ts.ClassElement[];
|
||||
/** Direct calls of runtime static methods (directRuntimeStaticCall), by
|
||||
* member name. */
|
||||
runtimeStaticCalls?: Map<string, GenericFnInfo>;
|
||||
runtimePrototypeMembers?: readonly ts.ClassElement[];
|
||||
localPrototypeData?: true;
|
||||
prototypeMethodValues?: Map<string, IrExpr>;
|
||||
@@ -5642,6 +5645,8 @@ function staticCallOn(
|
||||
throughValue: boolean,
|
||||
): IrExpr | null {
|
||||
const loc = locOf(call);
|
||||
const runtimeStatic = directRuntimeStaticCall(lowerer, call, access, info, throughValue);
|
||||
if (runtimeStatic) return runtimeStatic;
|
||||
if (
|
||||
(hasRuntimeStatics(info) && !findStaticOn(lowerer, info, access.name.text)?.field) ||
|
||||
(info.callableBase &&
|
||||
@@ -5780,6 +5785,179 @@ function staticCallOn(
|
||||
return { kind: "call", callee: fnName, args, type: found.method.ret, loc };
|
||||
}
|
||||
|
||||
/** `C.m(...)` where `m` is a static method that a function-local
|
||||
* JavaScript class defines at runtime (a factory's `class extends base`),
|
||||
* inherited by `C` through its statically known base chain. When the body
|
||||
* captures nothing from the factory's scope, every evaluation of the class
|
||||
* defines the same method, so the call lowers directly with `this` bound
|
||||
* to `C`, as a free function given `C` would. A receiver-dependent result
|
||||
* (a class built from `this`) then keeps a static base. Null when another
|
||||
* declaration of `m` answers first, the program assigns that member, or
|
||||
* the method form has no direct lowering. */
|
||||
function directRuntimeStaticCall(
|
||||
lowerer: Lowerer,
|
||||
call: ts.CallExpression,
|
||||
access: ts.PropertyAccessExpression,
|
||||
info: ClassInfo,
|
||||
throughValue: boolean,
|
||||
): IrExpr | null {
|
||||
const name = access.name.text;
|
||||
if (throughValue || name.startsWith("#")) return null;
|
||||
let declarer: ClassInfo | null = null;
|
||||
let member: ts.MethodDeclaration | null = null;
|
||||
for (let c: ClassInfo | null = info; c; c = c.base) {
|
||||
if (
|
||||
c.staticFields.some((field) => field.name === name) ||
|
||||
c.staticMethods?.has(name) === true ||
|
||||
c.genericStatics?.has(name) === true
|
||||
)
|
||||
return null;
|
||||
const runtime = c.runtimeStatics?.find(
|
||||
(element) =>
|
||||
(element as { name?: ts.PropertyName }).name !== undefined &&
|
||||
ts.isIdentifier((element as { name: ts.PropertyName }).name) &&
|
||||
(element as { name: ts.Identifier }).name.text === name,
|
||||
);
|
||||
if (runtime) {
|
||||
if (!ts.isMethodDeclaration(runtime) || !runtime.body) return null;
|
||||
declarer = c;
|
||||
member = runtime;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!declarer || !member || !declarer.decl) return null;
|
||||
if (member.asteriskToken || member.typeParameters) return null;
|
||||
if (member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword))
|
||||
return null;
|
||||
if (hasLexicalSuper(member) || capturesEnclosingScope(lowerer, member, declarer.decl))
|
||||
return null;
|
||||
if (memberAssigned(lowerer, member, name) || bodyReadsArguments(member)) return null;
|
||||
const implicit =
|
||||
member.parameters.length === 0 ? null : implicitAnyParamSymbolsOf(lowerer, member, true);
|
||||
if (member.parameters.length > 0 && !implicit) return null;
|
||||
let calls = declarer.runtimeStaticCalls;
|
||||
if (!calls) {
|
||||
calls = new Map();
|
||||
declarer.runtimeStaticCalls = calls;
|
||||
}
|
||||
const method: GenericFnInfo = calls.get(name) ?? {
|
||||
decl: member,
|
||||
baseName: name,
|
||||
qualifiedName: `%${declarer.def.name}.runtimeStatic:${name}`,
|
||||
typeParams: [],
|
||||
instances: new Map(),
|
||||
member: { cls: declarer, kind: "static" },
|
||||
...(implicit ? { implicitParams: implicit } : {}),
|
||||
};
|
||||
calls.set(name, method);
|
||||
// A direct lowering the method form does not support keeps the dynamic
|
||||
// call: its refusal is not this program's error.
|
||||
const sink: ScrDiagnostic[] = [];
|
||||
const previousSink = lowerer.diagSink;
|
||||
lowerer.diagSink = sink;
|
||||
let lowered: IrExpr;
|
||||
try {
|
||||
const receiver = staticMethodForReceiver(lowerer, access, method, info, false);
|
||||
const instance = implicit
|
||||
? implicitCallInstance(lowerer, call, receiver)
|
||||
: genericCallInstance(lowerer, call, receiver);
|
||||
lowered = {
|
||||
kind: "call",
|
||||
callee: instance.name,
|
||||
args: lowerer.completeArgs(call.arguments, instance.params, locOf(call), call),
|
||||
type: instance.returnType,
|
||||
loc: locOf(call),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof PoisonError) return null;
|
||||
throw error;
|
||||
} finally {
|
||||
lowerer.diagSink = previousSink;
|
||||
}
|
||||
return sink.length === 0 ? lowered : null;
|
||||
}
|
||||
|
||||
/** `super` inside the method (arrows included) names the defining
|
||||
* class's base, which a receiver specialization does not track. */
|
||||
function hasLexicalSuper(root: ts.Node): boolean {
|
||||
let found = false;
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (node.kind === ts.SyntaxKind.SuperKeyword) found = true;
|
||||
if (
|
||||
(ts.isFunctionLike(node) && !ts.isArrowFunction(node)) ||
|
||||
ts.isClassDeclaration(node) ||
|
||||
ts.isClassExpression(node)
|
||||
)
|
||||
return;
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
root.forEachChild(visit);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The method reads a binding declared in a function that encloses its
|
||||
* class: each evaluation of the class can see a different value. */
|
||||
function capturesEnclosingScope(
|
||||
lowerer: Lowerer,
|
||||
member: ts.MethodDeclaration,
|
||||
classDecl: ts.ClassLikeDeclaration,
|
||||
): boolean {
|
||||
let scope: ts.Node | undefined = classDecl.parent;
|
||||
while (scope && !ts.isFunctionLike(scope) && !ts.isSourceFile(scope)) scope = scope.parent;
|
||||
if (!scope || ts.isSourceFile(scope)) return false;
|
||||
const inside = (node: ts.Node, container: ts.Node): boolean => {
|
||||
for (let n: ts.Node | undefined = node; n; n = n.parent) if (n === container) return true;
|
||||
return false;
|
||||
};
|
||||
let captured = false;
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (captured) return;
|
||||
if (ts.isIdentifier(node)) {
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(node);
|
||||
const decl = symbol ? lowerer.checker.valueDeclarationOf(symbol) : undefined;
|
||||
// The enclosing function's own name is the same binding for every
|
||||
// evaluation.
|
||||
if (decl && decl !== scope && inside(decl, scope) && !inside(decl, member)) captured = true;
|
||||
return;
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
member.forEachChild(visit);
|
||||
return captured;
|
||||
}
|
||||
|
||||
/** Some code assigns or deletes a member of this name, or defines it by
|
||||
* reflection: the member a runtime lookup finds can then differ. */
|
||||
function memberAssigned(lowerer: Lowerer, member: ts.MethodDeclaration, name: string): boolean {
|
||||
let found = false;
|
||||
for (const sf of lowerer.program.getSourceFiles()) {
|
||||
if (sf.isDeclarationFile || found) continue;
|
||||
if (!sf.text.includes(name)) continue;
|
||||
ts.walkPreorder(sf, (node) => {
|
||||
if (found) return "skip";
|
||||
if (ts.isPropertyAccessExpression(node) && node.name.text === name) {
|
||||
const parent = node.parent;
|
||||
if (
|
||||
parent &&
|
||||
((ts.isBinaryExpression(parent) &&
|
||||
parent.left === node &&
|
||||
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
|
||||
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
|
||||
ts.isDeleteExpression(parent))
|
||||
)
|
||||
found = true;
|
||||
}
|
||||
if (ts.isStringLiteral(node) && node.text === name && node !== member.name) {
|
||||
const parent = node.parent;
|
||||
if (parent && (ts.isCallExpression(parent) || ts.isElementAccessExpression(parent)))
|
||||
found = true;
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function staticMethodForReceiver(
|
||||
lowerer: Lowerer,
|
||||
access: ts.PropertyAccessExpression,
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// `class Dog extends Animal.extend("Dog")`: the static `extend` a factory
|
||||
// class defines is inherited by a program class and builds the next
|
||||
// subclass from its receiver, as Effect's Schema.Class does.
|
||||
import { Class } from "class-factory/classes.js";
|
||||
|
||||
class Animal extends Class("Animal") {}
|
||||
class Dog extends Animal.extend("Dog") {}
|
||||
class Puppy extends Dog.extend("Puppy") {}
|
||||
|
||||
const rex = new Dog("Rex");
|
||||
const plain = new Animal("Ada");
|
||||
const pup = new Puppy("Bit");
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
dog: rex instanceof Dog,
|
||||
animal: rex instanceof Animal,
|
||||
plainDog: plain instanceof Dog,
|
||||
plainAnimal: plain instanceof Animal,
|
||||
kind: rex.kind,
|
||||
value: rex.value,
|
||||
plainKind: plain.kind,
|
||||
}),
|
||||
);
|
||||
console.log(pup instanceof Puppy, pup instanceof Dog, pup instanceof Animal, rex instanceof Puppy);
|
||||
console.log(rex.describe(), plain.describe(), pup.describe());
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
// A class factory whose classes extend themselves through a static
|
||||
// method: `extend` builds a subclass of whichever class it is called on.
|
||||
function factory(base, name) {
|
||||
const out = class extends base {
|
||||
constructor(value) {
|
||||
super(value);
|
||||
this.kind = name;
|
||||
}
|
||||
static extend(name2) {
|
||||
return factory(this, name2);
|
||||
}
|
||||
describe() {
|
||||
return `${this.kind}:${this.value}`;
|
||||
}
|
||||
};
|
||||
return out;
|
||||
}
|
||||
class Root {
|
||||
constructor(value) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
export function Class(name) {
|
||||
return factory(Root, name);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
{ "name": "class-factory", "version": "1.0.0", "type": "module", "exports": { "./classes.js": "./classes.js" } }
|
||||
@@ -592,6 +592,32 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["release", "dev"] as const)(
|
||||
"static methods a factory class defines build subclasses of their receiver (%s)",
|
||||
async (optimization) => {
|
||||
const entry = join(pilotRoot, "class-factory-cli.ts");
|
||||
const outDir = join(cacheDir, `class-factory-${optimization}-${sanitize ? "san" : "plain"}`);
|
||||
const result = await compile(entry, {
|
||||
backend: "llvm",
|
||||
npmStatic: ["class-factory"],
|
||||
sanitize,
|
||||
optimization,
|
||||
outDir,
|
||||
outPath: join(outDir, "program"),
|
||||
});
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const [reference, native] = await Promise.all([
|
||||
runBinary(process.execPath, [entry]),
|
||||
runBinary(result.binaryPath, []),
|
||||
]);
|
||||
expect(reference.exitCode).toBe(0);
|
||||
expect(native.stdout).toEqual(reference.stdout);
|
||||
expect(comparableStderr(native.stderr)).toEqual(reference.stderr);
|
||||
expect(native.exitCode).toBe(reference.exitCode);
|
||||
},
|
||||
);
|
||||
|
||||
test.for([
|
||||
["escape-string-regexp", "escape-cli.ts"],
|
||||
["slash", "slash-cli.ts"],
|
||||
|
||||
Reference in New Issue
Block a user