From e4280a42d5b459689330643a9ff8bc1b743fa695 Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 4 Oct 2026 22:59:31 +0300 Subject: [PATCH] [TS] Lower array rest bindings with iterators (#383) --- .../ets/test/EtsTsArrayParameterTest.kt | 11 +++ .../ts-frontend/src/lowering/exprLowering.ts | 2 +- .../src/lowering/iteratorLowering.ts | 56 +++++++++++- .../ts-frontend/src/lowering/stmtLowering.ts | 32 ++++++- .../test/array-rest-semantics.spec.ts | 89 +++++++++++++++++++ 5 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt index 596cceb29..2431ad28b 100644 --- a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt @@ -52,4 +52,15 @@ class EtsTsArrayParameterTest { assertTrue(values.any { it is EtsNewArrayExpr }) assertTrue(values.filterIsInstance().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().map { it.rhv } + + assertTrue(values.any { it is EtsNewArrayExpr }) + assertTrue(values.filterIsInstance().any { it.callee.name == "next" && it.receiver != null }) + } } diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index f9cd8d0a7..40ac7b06d 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -2058,7 +2058,7 @@ function lvalueType(target: LValueDto): TypeDto { } } -function arrayElementType(array: Extract): TypeDto { +export function arrayElementType(array: Extract): TypeDto { if (array.dimensions <= 1) { return array.elementType; } diff --git a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts index 0c62bad97..4a7e67235 100644 --- a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts @@ -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"; @@ -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; @@ -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); diff --git a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts index 6f666c481..af169dd25 100644 --- a/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/stmtLowering.ts @@ -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. */ @@ -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; diff --git a/jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts new file mode 100644 index 000000000..5461c52e5 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/array-rest-semantics.spec.ts @@ -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); + }); +});