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,90 @@
/*
* 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.AssignStmtDto
import org.jacodb.ets.dto.ClassValueRefDto
import org.jacodb.ets.dto.EtsFileDto
import org.jacodb.ets.dto.LocalDto
import org.jacodb.ets.dto.NewExprDto
import org.jacodb.ets.dto.RawValueDto
import org.jacodb.ets.dto.dtoModule
import org.jacodb.ets.dto.toEtsFile
import org.jacodb.ets.model.EtsAssignStmt
import org.jacodb.ets.model.EtsNewExpr
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 EtsTsComputedConstructorTest {
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 `comma constructor values decode as locals after computed access`() {
val dto = frontendRoundTrip(
"""
class A { x = 1; }
export function computed(holder: any): A { return new (holder['sideEffect'], A)(); }
export function direct(): A { return new (0, A)(); }
""".trimIndent(),
)

for (name in listOf("computed", "direct")) {
val stmts = assignments(dto, name)
val allocation = stmts.single { it.right is NewExprDto }
val constructor = (allocation.right as NewExprDto).constructorValue as LocalDto
val capture = stmts.single { it.left == constructor && it.right is ClassValueRefDto }

assertEquals("A", (capture.right as ClassValueRefDto).signature.name)
assertTrue(stmts.indexOf(capture) < stmts.indexOf(allocation))
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 newExpr = model.single { it.rhv is EtsNewExpr }.rhv as EtsNewExpr
assertTrue(newExpr.constructorValue != null)
}
}

}
42 changes: 18 additions & 24 deletions jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -421,20 +421,26 @@ export class ExprLowerer {
};
}

private lowerElementAccess(node: ts.ElementAccessExpression): ValueDto {
if (this.computedConstructorCalleeDepth > 0) {
this.lowerToImmediate(node.expression);
this.lowerToImmediate(node.argumentExpression);
throw new LoweringError("computed constructor access is not represented in EtsIR");
}
private isNamespaceObject(node: ts.Expression): boolean {
const expression = unwrapTransparentExpression(node);
const symbol = ts.isIdentifier(expression) ? this.m.converter.symbolOf(expression) : undefined;
const typeSymbol = this.m.checker.getTypeAtLocation(node).getSymbol();
return [symbol, typeSymbol].some((candidate) => candidate?.declarations?.some(ts.isModuleDeclaration));
}

private lowerElementAccess(node: ts.ElementAccessExpression): ValueDto {
const chain = optionalChain(node);
if (chain !== undefined) {
return this.lowerOptionalChain(node, chain);
}
const receiverType = this.m.checker.getTypeAtLocation(node.expression);
if (!this.m.checker.isArrayType(receiverType)
&& !this.m.checker.isTupleType(receiverType)) {
if (this.computedConstructorCalleeDepth > 0 && this.isNamespaceObject(node.expression)) {
this.lowerToImmediate(node.expression);
this.lowerToImmediate(node.argumentExpression);
throw new LoweringError("computed namespace constructor access has no runtime namespace object in EtsIR");
}
const instance = this.snapshotToLocal(node.expression);
return {
_: "PropertyRef",
Expand Down Expand Up @@ -576,6 +582,10 @@ export class ExprLowerer {
}
const receiverType = this.m.checker.getTypeAtLocation(node.expression);
if (!this.m.checker.isArrayType(receiverType) && !this.m.checker.isTupleType(receiverType)) {
if (this.computedConstructorCalleeDepth > 0 && this.isNamespaceObject(node.expression)) {
this.lowerToImmediate(node.argumentExpression);
throw new LoweringError("computed namespace constructor access has no runtime namespace object in EtsIR");
}
return {
_: "PropertyRef",
instance,
Expand Down Expand Up @@ -1157,10 +1167,6 @@ export class ExprLowerer {

private lowerNew(node: ts.NewExpression): ValueDto {
const args = node.arguments ?? ts.factory.createNodeArray();
if (containsElementAccess(node.expression)) {
this.lowerDynamicConstructorValue(node.expression, args);
throw new LoweringError("computed constructor access is not represented in EtsIR");
}

if (this.isProjectClassProperty(node.expression)) {
this.evaluateProjectClassPropertyReceiver(node.expression);
Expand Down Expand Up @@ -1225,7 +1231,8 @@ export class ExprLowerer {
private lowerDynamicConstructorValue(node: ts.Expression, args: readonly ts.Expression[]): ImmediateDto {
this.computedConstructorCalleeDepth++;
try {
return this.lowerImmediateBefore(node, args);
const value = this.lowerImmediateBefore(node, args);
return value._ === "ClassValueRef" ? this.materialize(value, value.type) : value;
} finally {
this.computedConstructorCalleeDepth--;
}
Expand Down Expand Up @@ -1785,19 +1792,6 @@ function containsPossibleSideEffect(node: ts.Node): boolean {
return found;
}

function containsElementAccess(node: ts.Node): boolean {
let found = false;
const visit = (current: ts.Node): void => {
if (found) return;
if (ts.isElementAccessExpression(current)) {
found = true;
return;
}
ts.forEachChild(current, visit);
};
visit(node);
return found;
}

/** Erase syntax that leaves an expression's runtime value unchanged. */
function unwrapTransparentExpression(node: ts.Expression): ts.Expression {
Expand Down
38 changes: 27 additions & 11 deletions jacodb-ets/ts-frontend/test/classes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Modifier } from "../src/dto/constants";
import { ClassDto, EtsFileDto, MethodDto } from "../src/dto/model";
import { StmtDto } from "../src/dto/stmts";
import { InstanceCallExprDto } from "../src/dto/values";
import { serializeEtsFile } from "../src/serialize";
import { compile, lower, singleBlockStmts } from "./util";

const FILE_SIG = { projectName: "proj", fileName: "test.ts" };
Expand Down Expand Up @@ -857,7 +858,7 @@ describe("namespace lowering", () => {
expect(keyIndex).toBeGreaterThan(receiverIndex);
expect(unsupportedIndex).toBeGreaterThan(keyIndex);
expect(diagnostics.messages).toEqual(expect.arrayContaining([
expect.stringContaining("computed constructor access"),
expect.stringContaining("computed namespace constructor access"),
]));

const js = ts.transpileModule(source, {
Expand Down Expand Up @@ -890,7 +891,7 @@ describe("namespace lowering", () => {
});
});

it("rejects computed constructor callees without treating property reads as array reads", () => {
it("preserves computed constructor callees as property reads rather than array reads", () => {
const source = `
class A { kind = "A"; }
class B { kind = "B"; }
Expand All @@ -909,6 +910,9 @@ describe("namespace lowering", () => {
export function array(constructors: Array<typeof A | typeof B>): A | B {
return new constructors[0]();
}
export function tuple(constructors: [typeof A, typeof B]): A | B {
return new constructors[0]();
}
export function replace(): void { holder.Ctor = B; }
export function readCounts(): number[] { return [receiverReads, keyReads]; }
`;
Expand All @@ -917,26 +921,38 @@ describe("namespace lowering", () => {

const { file, diagnostics } = lower(source);
const defaultClass = classByName(file, "%dflt");
for (const name of ["computed", "wrapped", "nested", "conditional", "throughCall", "array"]) {
for (const name of ["computed", "wrapped", "nested", "conditional", "throughCall"]) {
const stmts = methodOf(defaultClass, name).body!.cfg.blocks.flatMap((block) => block.stmts);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(true);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef")).toBe(false);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr")).toBe(false);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(true);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"
&& stmt.right.constructorValue?._ === "Local")).toBe(true);
}

for (const name of ["array", "tuple"]) {
const stmts = singleBlockStmts(methodOf(defaultClass, name));
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "UnsupportedValue")).toBe(false);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(false);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef")).toBe(true);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"
&& stmt.right.constructorValue?._ === "Local")).toBe(true);
}

const computedStmts = singleBlockStmts(methodOf(defaultClass, "computed"));
const receiverIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
&& stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "getHolder");
const keyIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
&& stmt.right._ === "StaticCallExpr" && stmt.right.method.name === "getKey");
const unsupportedIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
&& stmt.right._ === "UnsupportedValue");
const propertyIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
&& stmt.right._ === "PropertyRef");
const conversionIndex = computedStmts.findIndex((stmt) => stmt._ === "AssignStmt"
&& stmt.right._ === "ToPropertyKeyExpr");
expect(receiverIndex).toBeGreaterThanOrEqual(0);
expect(keyIndex).toBeGreaterThan(receiverIndex);
expect(unsupportedIndex).toBeGreaterThan(keyIndex);
expect(diagnostics.messages).toEqual(expect.arrayContaining([
expect.stringContaining("computed constructor access"),
]));
expect(conversionIndex).toBeGreaterThan(keyIndex);
expect(propertyIndex).toBeGreaterThan(conversionIndex);
expect(diagnostics.messages).toEqual([]);

const js = ts.transpileModule(source, {
compilerOptions: { target: ts.ScriptTarget.ES2020, module: ts.ModuleKind.CommonJS },
Expand Down
83 changes: 83 additions & 0 deletions jacodb-ets/ts-frontend/test/computed-new.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
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("computed constructors", () => {
it("keeps optional computed object constructors guarded and uses property semantics", () => {
const source = `
class A { x = 1; }
export function make(holder: { Ctor: typeof A } | undefined): number {
return new (holder?.["Ctor"])().x;
}
`;
const { file, diagnostics } = lower(source);
const method = methodByName(JSON.parse(serializeEtsFile(file)), "make");
const stmts = method.body!.cfg.blocks.flatMap((block) => block.stmts);

expect(method.body!.cfg.blocks.length).toBeGreaterThan(1);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "PropertyRef")).toBe(true);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef")).toBe(false);
expect(stmts.some((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"
&& stmt.right.constructorValue?._ === "Local")).toBe(true);
expect(diagnostics.messages).toEqual([]);
expect(runTypeScript(source, "exports.make({ Ctor: A })")).toBe(1);
});

it("reads an indexed constructor once before allocation and preserves JSON", () => {
const source = `
class A { kind = "A"; }
class B { kind = "B"; }
let reads = 0;
function constructors(): Array<typeof A | typeof B> { reads++; return [A, B]; }
function index(): number { reads++; return 1; }
export function make(): string { return new (constructors()[index()])().kind; }
export function count(): number { return reads; }
`;
const { file, diagnostics } = lower(source);
const roundTrip = JSON.parse(serializeEtsFile(file));
const stmts = singleBlockStmts(methodByName(roundTrip, "make"));
const functionCalls = stmts.flatMap((stmt, index) => stmt._ === "AssignStmt"
&& stmt.right._ === "StaticCallExpr" ? [{ name: stmt.right.method.name, index }] : []);
const readIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "ArrayRef");
const allocationIndex = stmts.findIndex((stmt) => stmt._ === "AssignStmt" && stmt.right._ === "NewExpr"
&& stmt.right.constructorValue?._ === "Local");

expect(functionCalls.map((call) => call.name)).toEqual(["constructors", "index"]);
expect(functionCalls[1]!.index).toBeLessThan(readIndex);
expect(readIndex).toBeLessThan(allocationIndex);
expect(diagnostics.messages).toEqual([]);
expect(runTypeScript(source, "{ kind: exports.make(), count: exports.count() }")).toEqual({ kind: "B", count: 2 });
});
});