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() }; +}