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
@@ -0,0 +1,95 @@
/*
* Copyright 2022 UnitTestBot contributors (utbot.org)
* <p>
* 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
* <p>
* http://www.apache.org/licenses/LICENSE-2.0
* <p>
* 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<AssignStmtDto> =
dto.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME }
.methods.single { it.signature.name == method }.body!!.cfg.blocks
.flatMap { it.stmts }.filterIsInstance<AssignStmtDto>()

@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<EtsAssignStmt>()
val expanded = model.single { it.rhv is EtsSpreadExpansionExpr }.rhv as EtsSpreadExpansionExpr
assertEquals(2, expanded.expectedCount)
assertEquals(listOf(expanded.iterable), expanded.getOperands().toList())
}
}

}
4 changes: 1 addition & 3 deletions jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
78 changes: 78 additions & 0 deletions jacodb-ets/ts-frontend/test/spread-constructor.spec.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});