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
57 changes: 42 additions & 15 deletions jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ export class ExprLowerer {
return this.materialize({ _: "ToPropertyKeyExpr", arg: this.lowerToImmediate(node) }, UNKNOWN_TYPE);
}

/** Get/Put rejects a nullish base before converting the already evaluated key. */
private propertyAccessKey(key: ImmediateDto, instance: LocalDto): ImmediateDto {
this.materialize({ _: "RequireObjectCoercibleExpr", arg: instance, type: instance.type }, instance.type);
return this.materialize({ _: "ToPropertyKeyExpr", arg: key }, UNKNOWN_TYPE);
}

private propertyReferenceForOperation(target: LValueDto): LValueDto {
return target._ === "PropertyRef"
? { ...target, key: this.propertyAccessKey(target.key, target.instance) }
: target;
}

/** Hoist a value into a fresh temp: `%t := value`. */
materialize(value: ValueDto, type: TypeDto = UNKNOWN_TYPE): LocalDto {
const temp = this.m.newTemp(type);
Expand Down Expand Up @@ -469,7 +481,7 @@ export class ExprLowerer {
return {
_: "PropertyRef",
instance,
key: this.lowerPropertyKey(node.argumentExpression),
key: this.propertyAccessKey(this.lowerToImmediate(node.argumentExpression), instance),
type: this.safeTypeOf(node),
};
}
Expand Down Expand Up @@ -630,7 +642,7 @@ export class ExprLowerer {
return {
_: "PropertyRef",
instance,
key: this.lowerPropertyKey(node.argumentExpression),
key: this.propertyAccessKey(this.lowerToImmediate(node.argumentExpression), instance),
type: this.safeTypeOf(node),
};
}
Expand Down Expand Up @@ -672,12 +684,15 @@ export class ExprLowerer {
throw new LoweringError("property access did not produce a field ref");
}
if (ts.isElementAccessExpression(node)) {
const ref = this.lowerElementAccess(node);
if (ref._ === "PropertyRef") {
return this.mayReassign(node.expression, laterExpression)
? { ...ref, instance: this.m.snapshotToLocal(ref.instance, ref.instance.type) }
: ref;
const receiverType = this.m.checker.getTypeAtLocation(node.expression);
if (!this.m.checker.isArrayType(receiverType) && !this.m.checker.isTupleType(receiverType)) {
const instance = this.snapshotToLocal(node.expression);
const key = this.snapshotToLocal(node.argumentExpression);

// Reference evaluation retains the raw key; Get and Put each convert it.
return { _: "PropertyRef", instance, key, type: this.safeTypeOf(node) };
}
const ref = this.lowerElementAccess(node);
if (ref._ === "ArrayRef") {
return {
...ref,
Expand Down Expand Up @@ -862,7 +877,9 @@ export class ExprLowerer {
const compoundOp = COMPOUND_ASSIGN_BY_SYNTAX[opKind];
if (compoundOp !== undefined) {
// load-op-store (note: no short-circuit for &&= / ||= / ??= — approximation)
const oldValue = target._ === "Local" ? target : this.materialize(target, lvalueType(target));
const oldValue = target._ === "Local"
? target
: this.materialize(this.propertyReferenceForOperation(target), lvalueType(target));
rhs = {
_: "BinopExpr",
op: compoundOp,
Expand All @@ -879,13 +896,19 @@ export class ExprLowerer {
if (target._ !== "Local" && rhs._ !== "Local" && rhs._ !== "Constant") {
rhs = this.materialize(rhs, lvalueType(target));
}
this.m.cfg.emit({ _: "AssignStmt", left: target, right: rhs });
if (target._ === "PropertyRef" && rhs._ === "Local") {
rhs = this.materialize(rhs, rhs.type);
}

this.m.cfg.emit({ _: "AssignStmt", left: this.propertyReferenceForOperation(target), right: rhs });
return target._ === "Local" ? target : (rhs as ImmediateDto);
}

private lowerLogicalAssignment(node: ts.BinaryExpression, target: LValueDto): LocalDto {
const cfg = this.m.cfg;
const oldValue = target._ === "Local" ? target : this.materialize(target, lvalueType(target));
const oldValue = target._ === "Local"
? target
: this.materialize(this.propertyReferenceForOperation(target), lvalueType(target));
const result = this.m.newTemp(this.safeTypeOf(node));
const assignLabel = cfg.newLabel();
const keepLabel = cfg.newLabel();
Expand All @@ -911,7 +934,11 @@ export class ExprLowerer {
if (target._ !== "Local" && rhs._ !== "Local" && rhs._ !== "Constant") {
rhs = this.materialize(rhs, lvalueType(target));
}
cfg.emit({ _: "AssignStmt", left: target, right: rhs });
if (target._ === "PropertyRef" && rhs._ === "Local") {
rhs = this.materialize(rhs, rhs.type);
}

cfg.emit({ _: "AssignStmt", left: this.propertyReferenceForOperation(target), right: rhs });
const assigned = rhs._ === "Local" || rhs._ === "Constant" ? rhs : target;
cfg.emit({ _: "AssignStmt", left: result, right: assigned });
cfg.goto(joinLabel);
Expand Down Expand Up @@ -970,9 +997,9 @@ export class ExprLowerer {

// Field/array target: load old, compute updated, store back.
// %old := ref; %new := %old ++; ref := %new
const oldValue = this.materialize(target, lvalueType(target));
const oldValue = this.materialize(this.propertyReferenceForOperation(target), lvalueType(target));
const updated = this.materialize({ _: "UnopExpr", op, arg: oldValue }, lvalueType(target));
this.m.cfg.emit({ _: "AssignStmt", left: target, right: updated });
this.m.cfg.emit({ _: "AssignStmt", left: this.propertyReferenceForOperation(target), right: updated });
return returnOld ? oldValue : updated;
}

Expand Down Expand Up @@ -1109,7 +1136,7 @@ export class ExprLowerer {
const ptr = this.materialize({
_: "PropertyRef",
instance: receiver,
key: this.lowerPropertyKey(callee.argumentExpression),
key: this.propertyAccessKey(this.lowerToImmediate(callee.argumentExpression), receiver),
type: this.safeTypeOf(callee),
}, this.safeTypeOf(callee));
return {
Expand Down Expand Up @@ -1419,7 +1446,7 @@ export class ExprLowerer {
receiver = this.snapshotToLocal(tag.expression);
const key = ts.isPropertyAccessExpression(tag)
? constant(tag.name.text, STRING_TYPE)
: this.lowerPropertyKey(tag.argumentExpression);
: this.propertyAccessKey(this.lowerToImmediate(tag.argumentExpression), receiver);
ptr = this.materialize({
_: "PropertyRef",
instance: receiver,
Expand Down
166 changes: 166 additions & 0 deletions jacodb-ets/ts-frontend/test/computed-access-order.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { runInNewContext } from "node:vm";
import * as ts from "typescript";
import { describe, expect, it } from "vitest";
import { serializeEtsFile } from "../src/serialize";
import { executeObjectIr } from "./object-runtime";
import { lower } from "./util";

function runners(expression: string, setup = ""): ((args: unknown[]) => unknown)[] {
const source = `export function value(object: any, key: () => any, arg: () => number): any {
${setup}
return ${expression};
}`;
const { file, diagnostics } = lower(source);
const serialized = JSON.parse(serializeEtsFile(file));
const javascript = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
}).outputText;
const native = runInNewContext(`${javascript}\nexports.value`, { exports: {} });

expect(diagnostics.messages).toEqual([]);
return [(args) => native(...args), (args) => executeObjectIr(serialized, "value", args)];
}

describe("computed access evaluation order", () => {
const expressions = [
"object[key()]",
"object[key()](arg())",
"object[key()]?.(arg())",
"new (object[key()])(arg())",
"object[key()]`text${arg()}`",
];

for (const expression of expressions) {
it.each([null, undefined])(`rejects a nullish receiver before coercion: ${expression}`, (receiver) => {
for (const run of runners(expression)) {
const events: string[] = [];
const key = () => {
events.push("key");
return {
[Symbol.toPrimitive]: () => {
events.push("coerce");
throw new RangeError("key coercion must not run");
},
};
};
const arg = () => { events.push("arg"); return 7; };

let failure: unknown;
try { run([receiver, key, arg]); } catch (error) { failure = error; }

expect(failure).toMatchObject({ name: "TypeError" });
expect(events).toEqual(["key"]);
}
});
}

it.each([null, undefined])("skips the entire key expression for an optional receiver", (receiver) => {
for (const run of runners("object?.[key()]?.(arg())")) {
const events: string[] = [];
const key = () => { events.push("key"); throw new RangeError("unexpected key"); };
const arg = () => { events.push("arg"); return 7; };

const result = run([receiver, key, arg]);

expect(result).toBeUndefined();
expect(events).toEqual([]);
}
});

it("evaluates the key expression before rejecting the receiver", () => {
for (const run of runners("object[key()]?.(arg())")) {
const events: string[] = [];
const key = () => { events.push("key"); throw new RangeError("key expression"); };
const arg = () => { events.push("arg"); return 7; };

expect(() => run([null, key, arg])).toThrow("key expression");
expect(events).toEqual(["key"]);
}
});

it("converts once and selects the getter before arguments on a present receiver", () => {
for (const run of runners("object[key()]?.(arg())")) {
const events: string[] = [];
const object = {
base: 3,
get m() {
events.push("get");
return function(this: { base: number }, arg: number) {
events.push("call");
return this.base + arg;
};
},
};
const key = () => {
events.push("key");
return { [Symbol.toPrimitive]: () => { events.push("coerce"); return "m"; } };
};
const arg = () => { events.push("arg"); return 7; };

const result = run([object, key, arg]);

expect(result).toBe(10);
expect(events).toEqual(["key", "coerce", "get", "arg", "call"]);
}
});

it.each([null, undefined])("evaluates the assigned value before rejecting the receiver", (receiver) => {
for (const run of runners("object[key()] = arg()")) {
const events: string[] = [];
const key = () => {
events.push("key");
return { [Symbol.toPrimitive]: () => { events.push("coerce"); return "m"; } };
};
const arg = () => { events.push("arg"); return 7; };

let failure: unknown;
try { run([receiver, key, arg]); } catch (error) { failure = error; }

expect(failure).toMatchObject({ name: "TypeError" });
expect(events).toEqual(["key", "arg"]);
}
});

it.each([
{ expression: "object[key()] = arg()", expected: 7, stored: { m: 7, other: 1 }, events: ["key", "arg", "coerce"] },
{ expression: "object[key()] += arg()", expected: 10, stored: { m: 3, other: 10 }, events: ["key", "coerce", "arg", "coerce"] },
{ expression: "object[key()] ||= arg()", expected: 3, stored: { m: 3, other: 1 }, events: ["key", "coerce"] },
{ expression: "object[key()] &&= arg()", expected: 7, stored: { m: 3, other: 7 }, events: ["key", "coerce", "arg", "coerce"] },
{ expression: "object[key()]++", expected: 3, stored: { m: 3, other: 4 }, events: ["key", "coerce", "coerce"] },
])("converts a computed write key at each Get and Put: $expression", (scenario) => {
for (const run of runners(scenario.expression)) {
const events: string[] = [];
const object = { m: 3, other: 1 };
let conversion = 0;
const key = () => {
events.push("key");
return {
[Symbol.toPrimitive]: () => {
events.push("coerce");
return conversion++ === 0 ? "m" : "other";
},
};
};
const arg = () => { events.push("arg"); return 7; };

const result = run([object, key, arg]);

expect(result).toBe(scenario.expected);
expect(events).toEqual(scenario.events);
expect(object).toEqual(scenario.stored);
}
});

it("saves the assigned value before key conversion changes its binding", () => {
const setup = `let rhs = 3;
const keyValue = { toString: function() { rhs = 9; return "m"; } };`;
for (const run of runners("object[keyValue as any] = rhs", setup)) {
const object = { m: 1 };

const result = run([object]);

expect(result).toBe(3);
expect(object.m).toBe(3);
}
});
});
Loading