diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index 2df839ba4..21f9f80da 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -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); @@ -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), }; } @@ -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), }; } @@ -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, @@ -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, @@ -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(); @@ -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); @@ -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; } @@ -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 { @@ -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, diff --git a/jacodb-ets/ts-frontend/test/computed-access-order.spec.ts b/jacodb-ets/ts-frontend/test/computed-access-order.spec.ts new file mode 100644 index 000000000..4c9295b91 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/computed-access-order.spec.ts @@ -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); + } + }); +});