diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsConstructorSpreadTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsConstructorSpreadTest.kt new file mode 100644 index 000000000..eaed01d30 --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsTsConstructorSpreadTest.kt @@ -0,0 +1,95 @@ +/* + * 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.ArrayRefDto +import org.jacodb.ets.dto.AssignStmtDto +import org.jacodb.ets.dto.CallExprDto +import org.jacodb.ets.dto.EtsFileDto +import org.jacodb.ets.dto.RawValueDto +import org.jacodb.ets.dto.SpreadExpansionExprDto +import org.jacodb.ets.dto.dtoModule +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsSpreadExpansionExpr +import org.jacodb.ets.utils.DEFAULT_ARK_CLASS_NAME +import org.jacodb.ets.utils.EtsIrProvider +import org.jacodb.ets.utils.generateEtsIR +import org.jacodb.ets.utils.getOperands +import org.junit.jupiter.api.Test +import kotlin.io.path.createTempDirectory +import kotlin.io.path.readText +import kotlin.io.path.writeText +import kotlin.test.assertEquals +import kotlin.test.assertTrue + + +class EtsTsConstructorSpreadTest { + private fun frontendRoundTrip(source: String): EtsFileDto { + val input = createTempDirectory("ts-calls-test").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)) + } + + private fun assignments(dto: EtsFileDto, method: String): List = + dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.signature.name == method }.body!!.cfg.blocks + .flatMap { it.stmts }.filterIsInstance() + + @Test + fun `constructor spreads preserve iterator expansion and ordered arguments`() { + val dto = frontendRoundTrip( + """ + function add(a: number, b: number): number { return a + b; } + class Point { constructor(public x: number, public y: number) {} } + export function sum(pair: [number, number]): number { return add(...pair); } + export function make(pair: [number, number]): number { return new Point(...pair).y; } + """.trimIndent(), + ) + + for ((name, target) in listOf("make" to "constructor")) { + val stmts = assignments(dto, name) + val expansion = stmts.single { it.right is SpreadExpansionExprDto } + val reads = stmts.filter { it.right is ArrayRefDto } + val call = stmts.single { (it.right as? CallExprDto)?.method?.name == target }.right as CallExprDto + + assertEquals(2, (expansion.right as SpreadExpansionExprDto).expectedCount) + assertEquals(2, reads.size) + assertEquals(reads.map { it.left }, call.args) + assertTrue(reads.all { (it.right as ArrayRefDto).array == expansion.left }) + assertTrue(stmts.indexOf(expansion) < stmts.indexOf(reads.first())) + assertTrue(stmts.none { it.right is RawValueDto }) + + val model = dto.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME } + .methods.single { it.name == name }.cfg.stmts.filterIsInstance() + val expanded = model.single { it.rhv is EtsSpreadExpansionExpr }.rhv as EtsSpreadExpansionExpr + assertEquals(2, expanded.expectedCount) + assertEquals(listOf(expanded.iterable), expanded.getOperands().toList()) + } + } + +} diff --git a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts index de1b47e5e..0610698c4 100644 --- a/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts +++ b/jacodb-ets/ts-frontend/src/lowering/exprLowering.ts @@ -1170,9 +1170,7 @@ export class ExprLowerer { throw new LoweringError("new constructor value cannot be represented in EtsIR"); } - const loweredArgs = args.map((a, index) => - ts.isSpreadElement(a) ? this.spreadFallback(a) : this.lowerImmediateBefore(a, args.slice(index + 1)), - ); + const loweredArgs = this.lowerArguments(args); const classType = staticConstructor ? this.newTargetClassType(node.expression) : inferredType; const temp = this.m.newTemp(classType); const allocation = constructorValue === undefined diff --git a/jacodb-ets/ts-frontend/test/spread-constructor.spec.ts b/jacodb-ets/ts-frontend/test/spread-constructor.spec.ts new file mode 100644 index 000000000..dbdc776bf --- /dev/null +++ b/jacodb-ets/ts-frontend/test/spread-constructor.spec.ts @@ -0,0 +1,78 @@ +import * as ts from "typescript"; +import { describe, expect, it } from "vitest"; +import { StmtDto } from "../src/dto/stmts"; +import { serializeEtsFile } from "../src/serialize"; +import { lower, methodByName, singleBlockStmts } from "./util"; + +function assertSpreadArgumentOrder(stmts: StmtDto[], methodName: string): void { + const expansion = stmts.find((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "SpreadExpansionExpr"); + const reads = stmts.filter((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef"); + const call = stmts.find((stmt) => stmt._ === "AssignStmt" + && (stmt.right._ === "StaticCallExpr" || stmt.right._ === "InstanceCallExpr") + && stmt.right.method.name === methodName); + + expect(reads).toHaveLength(2); + expect(expansion).toMatchObject({ right: { _: "SpreadExpansionExpr", expectedCount: 2 } }); + for (const read of reads) { + expect(read).toMatchObject({ right: { array: expansion?._ === "AssignStmt" ? expansion.left : undefined } }); + } + expect(reads.map((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef" + && stmt.right.index._ === "Constant" ? stmt.right.index.value : "invalid")).toEqual(["0", "1"]); + expect(call).toMatchObject({ + _: "AssignStmt", + right: { + args: reads.map((stmt) => stmt._ === "AssignStmt" ? stmt.left : undefined), + }, + }); +} + +function runTypeScript(source: string, expression: string): unknown { + const js = ts.transpileModule(source, { + compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS }, + }).outputText; + + return new Function("exports", `${js}\nreturn ${expression};`)({}); +} + +describe("constructor call spreads", () => { + it("expands a tuple in a constructor call and preserves JSON argument order", () => { + const source = ` + class Point { + y: number; + constructor(x: number, y: number) { this.y = y; } + } + export function make(pair: [number, number]): number { return new Point(...pair).y; } + `; + const { file, diagnostics } = lower(source); + const roundTrip = JSON.parse(serializeEtsFile(file)); + const stmts = singleBlockStmts(methodByName(roundTrip, "make")); + + assertSpreadArgumentOrder(stmts, "constructor"); + expect(diagnostics.messages).toEqual([]); + expect(runTypeScript(source, "exports.make([2, 3])")).toBe(3); + }); + + it("rejects an unbounded spread without calling the target", () => { + const source = ` + function add(a: number, b: number): number { return a + b; } + class Point { constructor(public x: number, public y: number) {} } + export function sum(values: number[]): number { return add(...values); } + export function make(values: number[]): Point { return new Point(...values); } + `; + const { file, diagnostics } = lower(source); + const stmts = singleBlockStmts(methodByName(file, "sum")); + + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "StaticCallExpr" + && stmt.right.method.name === "add")).toBe(false); + expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue" + && stmt.right.kindName === "CallExpression")).toBe(true); + expect(diagnostics.messages).toEqual(expect.arrayContaining([ + expect.stringContaining("spread argument has no statically known length"), + ])); + + const constructorStmts = singleBlockStmts(methodByName(file, "make")); + expect(constructorStmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr")).toBe(false); + expect(constructorStmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue" + && stmt.right.kindName === "NewExpression")).toBe(true); + }); +});