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
new file mode 100644
index 000000000..596cceb29
--- /dev/null
+++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsArrayParameterTest.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright 2022 UnitTestBot contributors (utbot.org)
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.jacodb.ets.test
+
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import org.jacodb.ets.dto.EtsFileDto
+import org.jacodb.ets.dto.dtoModule
+import org.jacodb.ets.dto.toEtsFile
+import org.jacodb.ets.model.EtsAssignStmt
+import org.jacodb.ets.model.EtsNewArrayExpr
+import org.jacodb.ets.model.EtsPtrCallExpr
+import org.jacodb.ets.utils.EtsIrProvider
+import org.jacodb.ets.utils.generateEtsIR
+import org.junit.jupiter.api.Test
+import kotlin.io.path.createTempDirectory
+import kotlin.io.path.readText
+import kotlin.io.path.writeText
+import kotlin.test.assertTrue
+
+class EtsTsArrayParameterTest {
+ private fun roundTrip(source: String): EtsFileDto {
+ val input = createTempDirectory("ts-array-parameters").resolve("test.ts")
+ input.writeText(source)
+ val output = generateEtsIR(input, isProject = false, timeout = null, provider = EtsIrProvider.TS_FRONTEND)
+ val dto = EtsFileDto.loadFromJson(output.readText())
+
+ return EtsFileDto.loadFromJson(Json { serializersModule = dtoModule }.encodeToString(dto))
+ }
+
+ @Test
+ fun `dynamic array spread survives frontend JSON and model conversion`() {
+ val dto = roundTrip("export function copy(input: number[]): number[] { return [0, ...input, 9]; }")
+
+ val method = dto.toEtsFile().classes.flatMap { it.methods }.single { it.name == "copy" }
+ 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 5508c5b1c..f9cd8d0a7 100644
--- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
+++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
@@ -68,6 +68,7 @@ import {
} from "../dto/values";
import { syntaxKindName, unsupportedValue } from "./diagnostics";
import { MethodContext } from "./methodBuilder";
+import { IteratorLowerer } from "./iteratorLowering";
const RELATION_BY_SYNTAX: Partial> = {
[ts.SyntaxKind.EqualsEqualsToken]: "==",
@@ -1312,6 +1313,10 @@ export class ExprLowerer {
? contextualType
: inferredType;
const elementType: TypeDto = arrayType._ === "ArrayType" ? arrayElementType(arrayType) : UNKNOWN_TYPE;
+ if (node.elements.some(ts.isSpreadElement)) {
+ return this.lowerArrayLiteralWithSpread(node, arrayType, elementType);
+ }
+
const temp = this.m.newTemp(
arrayType._ === "ArrayType" ? arrayType : { _: "ArrayType", elementType, dimensions: 1 },
);
@@ -1463,6 +1468,47 @@ export class ExprLowerer {
}
/** `a${x}b` -> chain of string `+` binops. */
+ private lowerArrayLiteralWithSpread(
+ node: ts.ArrayLiteralExpression,
+ arrayType: TypeDto,
+ elementType: TypeDto,
+ ): LocalDto {
+ const result = this.m.newTemp(
+ arrayType._ === "ArrayType" ? arrayType : { _: "ArrayType", elementType, dimensions: 1 },
+ );
+ this.m.cfg.emit({
+ _: "AssignStmt", left: result,
+ 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) });
+
+ for (const element of node.elements) {
+ if (ts.isSpreadElement(element)) {
+ const source = this.m.snapshotToLocal(this.lowerToLocal(element.expression), this.safeTypeOf(element.expression));
+ new IteratorLowerer(this.m, source).appendTo(result, offset, elementType);
+ continue;
+ }
+ if (!ts.isOmittedExpression(element)) {
+ const value = this.lowerToImmediate(element);
+ this.m.cfg.emit({
+ _: "AssignStmt", left: { _: "ArrayRef", array: result, index: offset, type: elementType }, right: value,
+ });
+ }
+ this.m.cfg.emit({
+ _: "AssignStmt", left: offset,
+ right: { _: "BinopExpr", op: "+", left: offset, right: constant("1", NUMBER_TYPE), type: NUMBER_TYPE },
+ });
+ // Advancing a hole still grows the fresh array's length.
+ this.m.cfg.emit({
+ _: "AssignStmt",
+ left: { _: "InstanceFieldRef", instance: result, field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "length", type: NUMBER_TYPE } },
+ right: offset,
+ });
+ }
+ return result;
+ }
+
private lowerTemplate(node: ts.TemplateExpression): ValueDto {
let acc: ImmediateDto = constant(node.head.text, STRING_TYPE);
for (const span of node.templateSpans) {
diff --git a/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts
new file mode 100644
index 000000000..0c62bad97
--- /dev/null
+++ b/jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts
@@ -0,0 +1,121 @@
+/** Iterator protocol for eager array spread. */
+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";
+import { ImmediateDto, LocalDto } from "../dto/values";
+import { MethodContext } from "./methodBuilder";
+
+const number = (value: number): ImmediateDto => ({ _: "Constant", value: String(value), type: NUMBER_TYPE });
+const bool = (value: boolean): ImmediateDto => ({ _: "Constant", value: String(value), type: BOOLEAN_TYPE });
+
+export function throwRuntimeError(m: MethodContext, name: string, message: string): void {
+ const classType: TypeDto = { _: "ClassType", signature: { name, declaringFile: UNKNOWN_FILE_SIGNATURE } };
+ const error = m.newTemp(classType);
+ m.cfg.emit({ _: "AssignStmt", left: error, right: { _: "NewExpr", classType } });
+ m.cfg.emit({
+ _: "AssignStmt", left: error,
+ right: {
+ _: "InstanceCallExpr", instance: error,
+ method: {
+ declaringClass: classType.signature, name: CONSTRUCTOR_NAME,
+ parameters: [{ name: "message", type: STRING_TYPE }], returnType: classType,
+ },
+ args: [{ _: "Constant", value: message, type: STRING_TYPE }],
+ },
+ });
+ m.cfg.throwValue(error);
+}
+
+/** Capture next once, as required by ECMAScript's Iterator Record. */
+export class IteratorLowerer {
+ private readonly iterator: LocalDto;
+ private readonly next: LocalDto;
+ private readonly exhausted: LocalDto;
+
+ constructor(private readonly m: MethodContext, source: LocalDto) {
+ this.iterator = m.newTemp(UNKNOWN_TYPE);
+ m.cfg.emit({
+ _: "AssignStmt", left: this.iterator,
+ right: {
+ _: "InstanceCallExpr", instance: source,
+ method: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "Symbol.iterator", parameters: [], returnType: UNKNOWN_TYPE },
+ args: [],
+ },
+ });
+ this.requireObject(this.iterator);
+ this.next = this.field(this.iterator, "next", UNKNOWN_TYPE);
+ this.exhausted = m.newTemp(BOOLEAN_TYPE);
+ m.cfg.emit({ _: "AssignStmt", left: this.exhausted, right: bool(false) });
+ }
+
+ /** Drain into a fresh or partially built array in iteration order. */
+ appendTo(target: LocalDto, offset: LocalDto, elementType: TypeDto): void {
+ const cfg = this.m.cfg;
+ const head = cfg.newLabel();
+ const step = cfg.newLabel();
+ const store = cfg.newLabel();
+ const done = cfg.newLabel();
+ cfg.placeLabel(head);
+ cfg.branch(this.isExhausted(), done, step);
+ cfg.placeLabel(step);
+ const result = this.advance();
+ cfg.branch(this.isExhausted(), done, store);
+ cfg.placeLabel(store);
+ const value = this.field(result, "value", elementType);
+ cfg.emit({ _: "AssignStmt", left: { _: "ArrayRef", array: target, index: offset, type: elementType }, right: value });
+ cfg.emit({ _: "AssignStmt", left: offset, right: { _: "BinopExpr", op: "+", left: offset, right: number(1), type: NUMBER_TYPE } });
+ cfg.goto(head);
+ cfg.placeLabel(done);
+ }
+
+ private advance(): LocalDto {
+ const cfg = this.m.cfg;
+ const result = this.m.newTemp(UNKNOWN_TYPE);
+ cfg.emit({
+ _: "AssignStmt", left: result,
+ right: {
+ _: "PtrCallExpr", ptr: this.next, receiver: this.iterator,
+ method: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "next", parameters: [], returnType: UNKNOWN_TYPE },
+ args: [],
+ },
+ });
+ this.requireObject(result);
+ const done = this.field(result, "done", UNKNOWN_TYPE);
+ const notDone = this.m.newTemp(BOOLEAN_TYPE);
+ cfg.emit({ _: "AssignStmt", left: notDone, right: { _: "UnopExpr", op: "!", arg: done } });
+ cfg.emit({ _: "AssignStmt", left: this.exhausted, right: { _: "UnopExpr", op: "!", arg: notDone } });
+ return result;
+ }
+
+ private requireObject(value: LocalDto): void {
+ const cfg = this.m.cfg;
+ const kind = this.m.newTemp(STRING_TYPE);
+ cfg.emit({ _: "AssignStmt", left: kind, right: { _: "TypeOfExpr", arg: value } });
+ const object = cfg.newLabel();
+ const functionCheck = cfg.newLabel();
+ const valid = cfg.newLabel();
+ const invalid = cfg.newLabel();
+ cfg.branch({ _: "ConditionExpr", op: "===", left: kind, right: { _: "Constant", value: "object", type: STRING_TYPE } }, object, functionCheck);
+ cfg.placeLabel(object);
+ cfg.branch({ _: "ConditionExpr", op: "===", left: value, right: { _: "Constant", value: "null", type: { _: "NullType" } } }, invalid, valid);
+ cfg.placeLabel(functionCheck);
+ cfg.branch({ _: "ConditionExpr", op: "===", left: kind, right: { _: "Constant", value: "function", type: STRING_TYPE } }, valid, invalid);
+ cfg.placeLabel(invalid);
+ throwRuntimeError(this.m, "TypeError", "Iterator result is not an object");
+ cfg.placeLabel(valid);
+ }
+
+ private isExhausted() {
+ return { _: "ConditionExpr" as const, op: "===" as const, left: this.exhausted, right: bool(true) };
+ }
+
+ private field(instance: LocalDto, name: string, type: TypeDto): LocalDto {
+ const local = this.m.newTemp(type);
+ this.m.cfg.emit({ _: "AssignStmt", left: local, right: this.fieldRef(instance, name, type) });
+ return local;
+ }
+
+ private fieldRef(instance: LocalDto, name: string, type: TypeDto) {
+ return { _: "InstanceFieldRef" as const, instance, field: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name, type } };
+ }
+}
diff --git a/jacodb-ets/ts-frontend/test/array-spread-semantics.spec.ts b/jacodb-ets/ts-frontend/test/array-spread-semantics.spec.ts
new file mode 100644
index 000000000..2ee404ab6
--- /dev/null
+++ b/jacodb-ets/ts-frontend/test/array-spread-semantics.spec.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from "vitest";
+import { consumers } from "./array-test-consumers";
+
+describe("array iterator behavior after JSON round trip", () => {
+ it("keeps the dynamic spread length for empty, one-element and longer arrays", () => {
+ const { ir, native } = consumers("function size(input: number[]): number { return [0, ...input].length; }", "size");
+
+ for (const input of [[], [1], [1, 2, 3]]) {
+ expect(ir(input)).toBe(native(input));
+ expect(ir(input)).toBe(input.length + 1);
+ }
+ });
+
+ for (const source of [
+ "function collect(input: any): any[] { return [...input]; }",
+ ]) {
+ 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("observes changing array length and turns sparse iterator values into stored undefined", () => {
+ const { ir, native } = consumers("function copy(input: number[]): number[] { return [0, ...input, 9]; }", "copy");
+ const input = () => {
+ const values = [1];
+ Object.defineProperty(values, 0, { get() { values[1] = 2; return 1; } });
+ return values;
+ };
+
+ expect(ir(input())).toEqual(native(input()));
+ const sparse = new Array(2);
+ const result = ir(sparse);
+ expect(result).toEqual(native(sparse));
+ expect(Object.hasOwn(result, 1)).toBe(true);
+ expect(Object.hasOwn(result, 2)).toBe(true);
+ });
+
+ it("evaluates the source once and drains it before the following element", () => {
+ const source = "declare function mark(n: number): number; declare function get(): Iterable; function copy() { return [mark(0), ...get(), mark(9)]; }";
+ const observed = (consumer: "ir" | "native") => {
+ const order: any[] = [];
+ const externals = {
+ mark(n: number) { order.push(n); return n; },
+ get() {
+ order.push("source");
+ return { *[Symbol.iterator]() { order.push("first"); yield 1; order.push("second"); yield 2; order.push("done"); } };
+ },
+ };
+ const functions = consumers(source, "copy", externals);
+ return { result: functions[consumer](), order };
+ };
+
+ expect(observed("ir")).toEqual(observed("native"));
+ expect(observed("ir").order).toEqual([0, "source", "first", "second", "done", 9]);
+ });
+
+});
diff --git a/jacodb-ets/ts-frontend/test/array-test-consumers.ts b/jacodb-ets/ts-frontend/test/array-test-consumers.ts
new file mode 100644
index 000000000..8128b79cc
--- /dev/null
+++ b/jacodb-ets/ts-frontend/test/array-test-consumers.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+import * as ts from "typescript";
+import { serializeEtsFile } from "../src/serialize";
+import { executor } from "./execute";
+import { lower } from "./util";
+
+export function consumers(source: string, name: string, externals: Record = {}) {
+ const { file, diagnostics } = lower(source);
+ expect(diagnostics.messages).toEqual([]);
+ const ir = executor(JSON.parse(serializeEtsFile(file)), externals);
+ const js = ts.transpileModule(source, { compilerOptions: { target: ts.ScriptTarget.ES2020 } }).outputText;
+ const native = new Function(...Object.keys(externals), `${js}; return ${name};`)(...Object.values(externals));
+ return { ir: (...args: any[]) => ir.call(name, ...args), native, initialize: () => ir.initialize() };
+}