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,55 @@
/*
* 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.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<EtsAssignStmt>().map { it.rhv }

assertTrue(values.any { it is EtsNewArrayExpr })
assertTrue(values.filterIsInstance<EtsPtrCallExpr>().any { it.callee.name == "next" && it.receiver != null })
}
}
46 changes: 46 additions & 0 deletions jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<ts.SyntaxKind, RelationOp>> = {
[ts.SyntaxKind.EqualsEqualsToken]: "==",
Expand Down Expand Up @@ -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 },
);
Expand Down Expand Up @@ -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) {
Expand Down
121 changes: 121 additions & 0 deletions jacodb-ets/ts-frontend/src/lowering/iteratorLowering.ts
Original file line number Diff line number Diff line change
@@ -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 } };
}
}
83 changes: 83 additions & 0 deletions jacodb-ets/ts-frontend/test/array-spread-semantics.spec.ts
Original file line number Diff line number Diff line change
@@ -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<number>; 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]);
});

});
14 changes: 14 additions & 0 deletions jacodb-ets/ts-frontend/test/array-test-consumers.ts
Original file line number Diff line number Diff line change
@@ -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<string, any> = {}) {
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() };
}