diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt index c6781c721..1c7655731 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsFrontendTest.kt @@ -125,6 +125,38 @@ class EtsTsFrontendTest { } } + @Test + fun `computed object binding reuses its normalized key in rest through JSON conversion`() { + val frontendDto = runFrontend( + """ + export function pick(input: any, key: any): number { + const { [key]: value, ...rest } = input; + return value + rest.y; + } + """.trimIndent(), + ) + val serialized = Json { serializersModule = dtoModule }.encodeToString(frontendDto) + val roundTripped = EtsFileDto.loadFromJson(serialized) + val dtoStmts = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == "pick" }.body!!.cfg.blocks.flatMap { it.stmts } + val assignments = dtoStmts.filterIsInstance() + val read = assignments.mapNotNull { it.right as? PropertyRefDto }.single() + val copy = dtoStmts.filterIsInstance().single() + + assertEquals(expected = 1, actual = assignments.count { it.right is ToPropertyKeyExprDto }) + assertEquals(expected = read.key, actual = copy.excludedKeys.single()) + assertTrue(assignments.none { it.right is RawValueDto }) + + val method = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == "pick" } + val converted = method.cfg.stmts.filterIsInstance() + val modelRead = converted.mapNotNull { it.rhv as? EtsPropertyRef }.single() + val modelCopy = method.cfg.stmts.filterIsInstance().single() + + assertEquals(expected = 1, actual = converted.count { it.rhv is EtsToPropertyKeyExpr }) + assertEquals(expected = modelRead.key, actual = modelCopy.excludedKeys.single()) + } + @Test fun `object rest preserves excluded keys through JSON conversion`() { val frontendDto = runFrontend( diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index a9c0c45fa..6f666c481 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -858,6 +858,19 @@ export class StmtLowerer { this.bindDestructured(element.name, rest, element.initializer); continue; } + if (element.propertyName !== undefined && ts.isComputedPropertyName(element.propertyName)) { + const key = this.expr.lowerPropertyKey(element.propertyName.expression); + const ref: ValueDto = { + _: "PropertyRef", + instance: stableSource, + key, + type: this.bindingType(element.name), + }; + excludedKeys.push(key); + this.bindDestructured(element.name, ref, element.initializer); + continue; + } + const propName = element.propertyName !== undefined ? propertyNameText(element.propertyName) diff --git a/jacodb-ets/ts-frontend/test/object-bindings-computed.spec.ts b/jacodb-ets/ts-frontend/test/object-bindings-computed.spec.ts new file mode 100644 index 000000000..6f635ad34 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/object-bindings-computed.spec.ts @@ -0,0 +1,128 @@ +import { runInNewContext } from "node:vm"; +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { AssignStmtDto } from "../src/dto/stmts"; +import { compile, lower, methodByName } from "./util"; +import { executeObjectIr } from "./object-runtime"; + +function evaluate(source: string, expression: string): unknown { + const javascript = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + return runInNewContext(`${javascript}\n${expression}`, { exports: {} }); +} + +describe("computed object binding keys", () => { + it("reads a computed destructuring key from the original source", () => { + const source = ` + export function pick(input: Record, key: string): number { + const { [key]: value } = input; + return value; + } + `; + const compiled = compile(source); + expect(compiled.program.getSemanticDiagnostics(compiled.sourceFile)).toEqual([]); + + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "pick").body!.cfg.blocks.flatMap((block) => block.stmts); + + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + left: expect.objectContaining({ _: "Local", name: "value" }), + right: expect.objectContaining({ _: "PropertyRef", key: expect.objectContaining({ _: "Local" }) }), + })); + expect(stmts).toContainEqual(expect.objectContaining({ + _: "AssignStmt", + left: expect.objectContaining({ _: "Local", name: expect.stringMatching(/^%/) }), + right: expect.objectContaining({ _: "ToPropertyKeyExpr", arg: expect.objectContaining({ name: "key" }) }), + })); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.pick({ x: 3 }, "x")`)).toBe(3); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "pick", [{ x: 3 }, "x"])).toBe(3); + }); + + it("preserves the destructuring source when a computed key mutates its binding", () => { + const source = ` + let input = { x: 3 }; + let calls = 0; + function change(): string { calls++; input = { x: 9 }; return "x"; } + export function pick(): number { + const { [change()]: value } = input; + return value + calls; + } + `; + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "pick").body!.cfg.blocks.flatMap((block) => block.stmts); + const callIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" + && stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "change"); + const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef"); + const read = stmts[readIndex] as AssignStmtDto; + + expect(callIndex).toBeGreaterThanOrEqual(0); + expect(readIndex).toBeGreaterThan(callIndex); + expect(read.right).toMatchObject({ _: "PropertyRef", instance: { _: "Local", name: expect.stringMatching(/^%/) } }); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.pick()`)).toBe(4); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "pick")).toBe(4); + }); + + it("rejects nullish binding sources before converting the key", () => { + const source = `export function pick(input: any, key: any) { const { [key]: value } = input; return value; }`; + const { file, diagnostics } = lower(source); + let conversions = 0; + const key = { toString() { conversions++; return "x"; } }; + + expect(diagnostics.messages).toEqual([]); + expect(() => executeObjectIr(JSON.parse(JSON.stringify(file)), "pick", [null, key])).toThrow(TypeError); + expect(conversions).toBe(0); + expect(evaluate(source, `(() => { + let conversions = 0; + try { exports.pick(null, { toString() { conversions++; return "x"; } }); } catch (error) {} + return conversions; + })()`)).toBe(0); + }); + + it("reuses one evaluated computed key for both binding and rest exclusion", () => { + const source = ` + export function remainder(input: Record, key: string): number { + const { [key]: value, ...rest } = input; + return value + rest.y; + } + `; + const { file, diagnostics } = lower(source); + const stmts = methodByName(file, "remainder").body!.cfg.blocks.flatMap((block) => block.stmts); + const read = stmts.find((stmt): stmt is AssignStmtDto => stmt._ === "AssignStmt" + && stmt.right._ === "PropertyRef"); + const copy = stmts.find((stmt) => stmt._ === "CopyDataPropertiesStmt"); + + expect(read?.right).toMatchObject({ _: "PropertyRef", key: { _: "Local" } }); + expect(copy).toMatchObject({ + _: "CopyDataPropertiesStmt", + excludedKeys: [(read?.right as { key: unknown }).key], + }); + expect(diagnostics.messages).toEqual([]); + expect(evaluate(source, `exports.remainder({ x: 3, y: 2 }, "x")`)).toBe(5); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "remainder", [{ x: 3, y: 2 }, "x"])).toBe(5); + }); + + it("normalizes a computed key once before reading and excluding it from rest", () => { + const source = ` + export function remainder(input: any, key: any): number { + const { [key]: value, ...rest } = input; + return value + rest.y; + } + `; + const { file, diagnostics } = lower(source); + let conversions = 0; + const key = { toString() { conversions++; return conversions === 1 ? "x" : "y"; } }; + + expect(diagnostics.messages).toEqual([]); + expect(executeObjectIr(JSON.parse(JSON.stringify(file)), "remainder", [{ x: 3, y: 2 }, key])).toBe(5); + expect(conversions).toBe(1); + expect(evaluate(source, `(() => { + let conversions = 0; + const key = { toString() { conversions++; return conversions === 1 ? "x" : "y"; } }; + return exports.remainder({ x: 3, y: 2 }, key) * 10 + conversions; + })()`)).toBe(51); + }); +}); diff --git a/jacodb-ets/ts-frontend/test/regressions.spec.ts b/jacodb-ets/ts-frontend/test/regressions.spec.ts index 1b7b8bb06..4a3ed8da2 100644 --- a/jacodb-ets/ts-frontend/test/regressions.spec.ts +++ b/jacodb-ets/ts-frontend/test/regressions.spec.ts @@ -14,7 +14,6 @@ describe("unsupported loop bindings do not break the whole file", () => { const cases: Record = { "array pattern with rest": "declare const xs: any[]; for (const [a, ...rest] of xs) { console.log(a); }", "member expression target": "declare const xs: any[]; const obj: any = {}; for (obj.x of xs) { console.log(obj.x); }", - "computed property pattern": "declare const xs: any[]; const k = 'a'; for (const { [k]: v } of xs) { console.log(v); }", "rest in for-in": "declare const o: any; for (const [a, ...rest] in o) { console.log(a); }", }; @@ -29,6 +28,19 @@ describe("unsupported loop bindings do not break the whole file", () => { } }); +it("lowers a computed object key in a for-of binding", () => { + const { file, diagnostics } = lower(` + const xs = [{ a: 1 }]; + const key = "a"; + for (const { [key]: value } of xs) { console.log(value); } + `); + const stmts = file.classes.flatMap((clazz) => clazz.methods.flatMap((method) => allStmts(method))); + + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(true); + expect(stmts.some((stmt) => stmt._ === "UnsupportedStmt")).toBe(false); + expect(diagnostics.messages).toEqual([]); +}); + describe("SyntaxKind names", () => { it("resolves real names instead of marker aliases", () => { expect(syntaxKindName(ts.SyntaxKind.VariableStatement)).toBe("VariableStatement");