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
Original file line number Diff line number Diff line change
Expand Up @@ -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<AssignStmtDto>()
val read = assignments.mapNotNull { it.right as? PropertyRefDto }.single()
val copy = dtoStmts.filterIsInstance<CopyDataPropertiesStmtDto>().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<EtsAssignStmt>()
val modelRead = converted.mapNotNull { it.rhv as? EtsPropertyRef }.single()
val modelCopy = method.cfg.stmts.filterIsInstance<EtsCopyDataPropertiesStmt>().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(
Expand Down
13 changes: 13 additions & 0 deletions jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
128 changes: 128 additions & 0 deletions jacodb-ets/ts-frontend/test/object-bindings-computed.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>, 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<string, number>, 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);
});
});
14 changes: 13 additions & 1 deletion jacodb-ets/ts-frontend/test/regressions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ describe("unsupported loop bindings do not break the whole file", () => {
const cases: Record<string, string> = {
"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); }",
};

Expand All @@ -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");
Expand Down