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 @@ -52,4 +52,15 @@ class EtsTsArrayParameterTest {
assertTrue(values.any { it is EtsNewArrayExpr })
assertTrue(values.filterIsInstance<EtsPtrCallExpr>().any { it.callee.name == "next" && it.receiver != null })
}

@Test
fun `array rest survives frontend JSON and model conversion`() {
val dto = roundTrip("export function tail(input: number[]): number[] { const [first, ...rest] = input; return rest; }")

val method = dto.toEtsFile().classes.flatMap { it.methods }.single { it.name == "tail" }
val values = method.cfg.stmts.filterIsInstance<EtsAssignStmt>().map { it.rhv }

assertTrue(values.any { it is EtsNewArrayExpr })
assertTrue(values.filterIsInstance<EtsPtrCallExpr>().any { it.callee.name == "next" && it.receiver != null })
}
}
2 changes: 1 addition & 1 deletion jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2058,7 +2058,7 @@ function lvalueType(target: LValueDto): TypeDto {
}
}

function arrayElementType(array: Extract<TypeDto, { _: "ArrayType" }>): TypeDto {
export function arrayElementType(array: Extract<TypeDto, { _: "ArrayType" }>): TypeDto {
if (array.dimensions <= 1) {
return array.elementType;
}
Expand Down
56 changes: 55 additions & 1 deletion jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/** Iterator protocol for eager array spread. */
/** Shared iterator protocol for eager array spread and array-rest bindings. */
import { CONSTRUCTOR_NAME } from "../dto/constants";
import { UNKNOWN_CLASS_SIGNATURE, UNKNOWN_FILE_SIGNATURE } from "../dto/signatures";
import { BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, TypeDto, UNKNOWN_TYPE } from "../dto/types";
Expand Down Expand Up @@ -48,6 +48,27 @@ export class IteratorLowerer {
m.cfg.emit({ _: "AssignStmt", left: this.exhausted, right: bool(false) });
}

/** Yield undefined after exhaustion without calling next again. */
take(type: TypeDto, options: { readValue?: boolean } = {}): LocalDto {
const cfg = this.m.cfg;
const value = this.m.newTemp(type);
cfg.emit({ _: "AssignStmt", left: value, right: { _: "Constant", value: "undefined", type: { _: "UndefinedType" } } });
const step = cfg.newLabel();
const read = cfg.newLabel();
const done = cfg.newLabel();
cfg.branch(this.isExhausted(), done, step);
cfg.placeLabel(step);
const result = this.advance();
cfg.branch(this.isExhausted(), done, read);
cfg.placeLabel(read);
if (options.readValue !== false) {
cfg.emit({ _: "AssignStmt", left: value, right: this.fieldRef(result, "value", type) });
}
cfg.goto(done);
cfg.placeLabel(done);
return value;
}

/** Drain into a fresh or partially built array in iteration order. */
appendTo(target: LocalDto, offset: LocalDto, elementType: TypeDto): void {
const cfg = this.m.cfg;
Expand All @@ -68,6 +89,39 @@ export class IteratorLowerer {
cfg.placeLabel(done);
}

/** Abrupt binding initialization closes the iterator; the original throw wins. */
closeOnException(action: () => void): void {
const cfg = this.m.cfg;
const close = cfg.newLabel();
const lookup = cfg.newLabel();
const invoke = cfg.newLabel();
const rethrow = cfg.newLabel();
const continuation = cfg.newLabel();
cfg.withExceptionTarget(close, action);
cfg.goto(continuation);
cfg.placeLabel(close);
const original = this.m.newTemp(UNKNOWN_TYPE);
cfg.emit({ _: "AssignStmt", left: original, right: { _: "CaughtExceptionRef", type: UNKNOWN_TYPE } });
cfg.branch(this.isExhausted(), rethrow, lookup);
cfg.placeLabel(lookup);
cfg.withExceptionTarget(rethrow, () => {
const method = this.field(this.iterator, "return", UNKNOWN_TYPE);
cfg.branch({ _: "ConditionExpr", op: "==", left: method, right: { _: "Constant", value: "null", type: { _: "NullType" } } }, rethrow, invoke);
cfg.placeLabel(invoke);
cfg.emit({
_: "CallStmt",
expr: {
_: "PtrCallExpr", ptr: method, receiver: this.iterator,
method: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "return", parameters: [], returnType: UNKNOWN_TYPE }, args: [],
},
});
cfg.goto(rethrow);
});
cfg.placeLabel(rethrow);
cfg.throwValue(original);
cfg.placeLabel(continuation);
}

private advance(): LocalDto {
const cfg = this.m.cfg;
const result = this.m.newTemp(UNKNOWN_TYPE);
Expand Down
32 changes: 30 additions & 2 deletions jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ import { BOOLEAN_TYPE, NUMBER_TYPE, STRING_TYPE, TypeDto, UNDEFINED_TYPE, UNKNOW
import { ImmediateDto, LValueDto, LocalDto, ValueDto } from "../dto/values";
import { exportAssignmentSupport } from "./astUtils";
import { Label } from "./cfg";
import { IteratorLowerer } from "./iteratorLowering";
import { unsupportedStmt } from "./diagnostics";
import { ExprLowerer, LoweringError, constant } from "./exprLowering";
import { ExprLowerer, LoweringError, arrayElementType, constant } from "./exprLowering";
import { MethodContext } from "./methodBuilder";

/** The lowered loop variable of a for-of/for-in statement. */
Expand Down Expand Up @@ -891,7 +892,34 @@ export class StmtLowerer {
}
return;
}
// Array pattern.
// Array patterns with rest consume the iterator, including omitted elements.
if (pattern.elements.some((element) => ts.isBindingElement(element) && element.dotDotDotToken !== undefined)) {
if (pattern.elements.some((element) => ts.isBindingElement(element)
&& !ts.isIdentifier(element.name))) {
throw new LoweringError("nested bindings in array rest are not represented with iterator semantics");
}
const iterator = new IteratorLowerer(this.m, source);
for (const element of pattern.elements) {
if (ts.isOmittedExpression(element)) {
iterator.take(UNKNOWN_TYPE, { readValue: false });
continue;
}
if (element.dotDotDotToken === undefined) {
const value = iterator.take(this.bindingType(element.name));
iterator.closeOnException(() => this.bindDestructured(element.name, value, element.initializer));
continue;
}
const type = this.bindingType(element.name);
const elementType = type._ === "ArrayType" ? arrayElementType(type) : UNKNOWN_TYPE;
const rest = this.m.newTemp(type._ === "ArrayType" ? type : { _: "ArrayType", elementType, dimensions: 1 });
this.m.cfg.emit({ _: "AssignStmt", left: rest, right: { _: "NewArrayExpr", elementType, size: constant("0", NUMBER_TYPE) } });
const offset = this.m.newTemp(NUMBER_TYPE);
this.m.cfg.emit({ _: "AssignStmt", left: offset, right: constant("0", NUMBER_TYPE) });
iterator.appendTo(rest, offset, elementType);
this.bindDestructured(element.name, rest, undefined);
}
return;
}
pattern.elements.forEach((element, index) => {
if (ts.isOmittedExpression(element)) {
return;
Expand Down
89 changes: 89 additions & 0 deletions jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { consumers } from "./array-test-consumers";

describe("array iterator behavior after JSON round trip", () => {
it("packs a fresh tail for empty, one-element and longer arrays", () => {
const { ir, native } = consumers("function tail(input: number[]): number[] { const [first, ...rest] = input; return rest; }", "tail");

for (const input of [[], [1], [1, 2, 3]]) {
const result = ir(input);
expect(result).toEqual(native(input));
expect(result).not.toBe(input);
}
});

for (const source of [
"function collect(input: any): any[] { const [...rest] = input; return rest; }",
]) {
it(`uses the selected iterator and caches next: ${source}`, () => {
const { ir, native } = consumers(source, "collect");
const input = () => {
let nextReads = 0;
let count = 0;
const next = function (this: any) {
expect(this.marker).toBe(42);
return { value: ++count, done: count > 2 ? "finished" : 0 };
};
next.call = () => { throw new Error("A user-defined .call must not be read"); };
return {
length: 100,
[Symbol.iterator]() {
return { marker: 42, get next() { expect(++nextReads).toBe(1); return next; } };
},
};
};

expect(ir(input())).toEqual(native(input()));
expect(ir(input())).toEqual([1, 2]);
});

it(`rejects invalid iterator results: ${source}`, () => {
const { ir, native } = consumers(source, "collect");
const malformed = () => ({ [Symbol.iterator]: () => ({ next: () => 1 }) });

expect(() => native(malformed())).toThrow(TypeError);
expect(() => ir(malformed())).toThrow(TypeError);
});
}

it("does not call next after done in a rest binding", () => {
const { ir, native } = consumers("function tail(input: any) { const [a, b, ...rest] = input; return rest; }", "tail");
const input = () => {
let calls = 0;
return { [Symbol.iterator]: () => ({ next() { if (++calls > 1) throw new Error("next after done"); return { done: true }; } }) };
};

expect(ir(input())).toEqual(native(input()));
});

it("skips an omitted binding without reading its value getter", () => {
const { ir, native } = consumers("function tail(input: any) { const [, ...rest] = input; return rest; }", "tail");
const input = () => {
let calls = 0;
return { [Symbol.iterator]: () => ({ next() {
if (calls++ === 0) return { done: false, get value() { throw new Error("omitted value"); } };
return { done: true };
} }) };
};

expect(ir(input())).toEqual(native(input()));
});

it("closes on a default initializer throw and preserves the original exception", () => {
const original = new Error("binding default");
const { ir, native } = consumers(
"declare function fail(): never; function tail(input: any) { const [a = fail(), ...rest] = input; return rest; }",
"tail", { fail: () => { throw original; } },
);
const run = (collect: any) => {
let closed = 0;
const input = { [Symbol.iterator]: () => ({ next: () => ({ value: undefined, done: false }), return() { closed++; throw new Error("close"); } }) };

expect(() => collect(input)).toThrow(original);
expect(closed).toBe(1);
};

run(native);
run(ir);
});
});