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
6 changes: 6 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import mu.KotlinLogging
import org.jacodb.ets.model.EtsDefineDataPropertyStmt
import org.jacodb.ets.model.EtsPropertyRef
import org.jacodb.ets.model.EtsToPropertyKeyExpr
import org.jacodb.ets.model.EtsToNumericExpr
import org.jacodb.ets.toArrayType
import org.jacodb.ets.model.BasicBlock
import org.jacodb.ets.model.EtsAddExpr
Expand Down Expand Up @@ -394,6 +395,11 @@ class EtsMethodBuilder(
arg = ensureOneAddress(arg.toEtsEntity()),
)

is ToNumericExprDto -> EtsToNumericExpr(
arg = ensureOneAddress(arg.toEtsEntity()),
type = type.toEtsType(),
)

is PropertyRefDto -> EtsPropertyRef(
instance = (instance as LocalDto).toEtsLocal(),
key = ensureOneAddress(key.toEtsEntity()),
Expand Down
7 changes: 7 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ data class ToPropertyKeyExprDto(
override val type: TypeDto get() = UnknownTypeDto
}

@Serializable
@SerialName("ToNumericExpr")
data class ToNumericExprDto(
val arg: ValueDto,
override val type: TypeDto,
) : ExprDto

@Serializable
@SerialName("PropertyRef")
data class PropertyRefDto(
Expand Down
15 changes: 15 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ interface EtsExpr : EtsEntity {
if (this is Default) return defaultVisit(expr)
error("Cannot handle ${expr::class.java.simpleName}: $expr")
}
fun visit(expr: EtsToNumericExpr): R {
if (this is Default) return defaultVisit(expr)
error("Cannot handle ${expr::class.java.simpleName}: $expr")
}
fun visit(expr: EtsRequireObjectCoercibleExpr): R {
if (this is Default) return defaultVisit(expr)
error("Cannot handle ${expr::class.java.simpleName}: $expr")
Expand Down Expand Up @@ -105,6 +109,7 @@ interface EtsExpr : EtsEntity {
override fun visit(expr: EtsNewClassExpr): R = defaultVisit(expr)
override fun visit(expr: EtsSpreadExpansionExpr): R = defaultVisit(expr)
override fun visit(expr: EtsToPropertyKeyExpr): R = defaultVisit(expr)
override fun visit(expr: EtsToNumericExpr): R = defaultVisit(expr)
override fun visit(expr: EtsRequireObjectCoercibleExpr): R = defaultVisit(expr)
override fun visit(expr: EtsNewArrayExpr): R = defaultVisit(expr)
override fun visit(expr: EtsCastExpr): R = defaultVisit(expr)
Expand Down Expand Up @@ -213,6 +218,16 @@ data class EtsToPropertyKeyExpr(val arg: EtsValue) : EtsExpr {
override fun <R> accept(visitor: EtsExpr.Visitor<R>): R = visitor.visit(this)
}

/** ECMAScript ToNumeric: ToPrimitive with a number hint, then Number or unchanged BigInt. */
data class EtsToNumericExpr(
val arg: EtsValue,
override val type: EtsType,
) : EtsExpr {
override fun toString(): String = "toNumeric($arg)"

override fun <R> accept(visitor: EtsExpr.Visitor<R>): R = visitor.visit(this)
}

/** Evaluation of a class expression creates a fresh constructor for [signature]. */
data class EtsNewClassExpr(
val signature: EtsClassSignature,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import org.jacodb.ets.model.EtsSubExpr
import org.jacodb.ets.model.EtsThis
import org.jacodb.ets.model.EtsThrowStmt
import org.jacodb.ets.model.EtsToPropertyKeyExpr
import org.jacodb.ets.model.EtsToNumericExpr
import org.jacodb.ets.model.EtsTypeOfExpr
import org.jacodb.ets.model.EtsUnaryPlusExpr
import org.jacodb.ets.model.EtsUndefinedConstant
Expand Down Expand Up @@ -197,6 +198,9 @@ private object EntityGetOperands : EtsEntity.Visitor<Sequence<EtsEntity>> {
override fun visit(expr: EtsToPropertyKeyExpr): Sequence<EtsEntity> =
sequenceOf(expr.arg)

override fun visit(expr: EtsToNumericExpr): Sequence<EtsEntity> =
sequenceOf(expr.arg)

override fun visit(value: EtsPropertyRef): Sequence<EtsEntity> =
sequenceOf(value.instance, value.key)

Expand Down
6 changes: 6 additions & 0 deletions jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import org.jacodb.ets.model.EtsSubExpr
import org.jacodb.ets.model.EtsThis
import org.jacodb.ets.model.EtsThrowStmt
import org.jacodb.ets.model.EtsToPropertyKeyExpr
import org.jacodb.ets.model.EtsToNumericExpr
import org.jacodb.ets.model.EtsTypeOfExpr
import org.jacodb.ets.model.EtsUnaryPlusExpr
import org.jacodb.ets.model.EtsUndefinedConstant
Expand Down Expand Up @@ -265,6 +266,11 @@ abstract class AbstractHandler : EtsEntity.Visitor<Unit>, EtsStmt.Visitor<Unit>
expr.arg.accept(this)
}

final override fun visit(expr: EtsToNumericExpr) {
handle(expr)
expr.arg.accept(this)
}

final override fun visit(value: EtsPropertyRef) {
handle(value)
value.instance.accept(this)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/*
* 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.BigIntTypeDto
import org.jacodb.ets.dto.EtsFileDto
import org.jacodb.ets.dto.LocalDto
import org.jacodb.ets.dto.NumberTypeDto
import org.jacodb.ets.dto.ReturnStmtDto
import org.jacodb.ets.dto.ToNumericExprDto
import org.jacodb.ets.dto.UnionTypeDto
import org.jacodb.ets.dto.dtoModule
import org.jacodb.ets.dto.toEtsFile
import org.jacodb.ets.model.EtsAssignStmt
import org.jacodb.ets.model.EtsBigIntType
import org.jacodb.ets.model.EtsEntity
import org.jacodb.ets.model.EtsExpr
import org.jacodb.ets.model.EtsNumberType
import org.jacodb.ets.model.EtsStmt
import org.jacodb.ets.model.EtsToNumericExpr
import org.jacodb.ets.model.EtsUnionType
import org.jacodb.ets.utils.AbstractHandler
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.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlin.test.assertEquals
import kotlin.test.assertTrue

class EtsTsNumericUpdateTest {
@Test
fun `numeric old values survive production JSON decoding and model traversal`() {
val source = """
export function numberLocal(x: number): number { return x++; }
export function bigintLocal(x: bigint): bigint { return x--; }
export function computed(object: any, key: any): unknown { return object[key()]++; }
export function array(values: (number | bigint)[]): unknown { return values[0]--; }
""".trimIndent()
val directory = createTempDirectory("ts-numeric-update-test")
val input = directory.resolve("test.ts")
input.writeText(source)

val output = generateEtsIR(
input,
isProject = false,
timeout = null,
provider = EtsIrProvider.TS_FRONTEND,
)
assertTrue(output.exists(), message = "production frontend did not produce output")
val frontendDto = EtsFileDto.loadFromJson(output.readText())
val roundTripped = EtsFileDto.loadFromJson(
Json { serializersModule = dtoModule }.encodeToString(frontendDto),
)
val dtoClass = roundTripped.classes.single { it.signature.name == DEFAULT_ARK_CLASS_NAME }
val modelClass = roundTripped.toEtsFile().classes.single { it.name == DEFAULT_ARK_CLASS_NAME }
val numericDto = UnionTypeDto(types = listOf(NumberTypeDto, BigIntTypeDto))
val numericModel = EtsUnionType(types = listOf(EtsNumberType, EtsBigIntType))
val expectedDtoTypes = mapOf(
"numberLocal" to NumberTypeDto,
"bigintLocal" to BigIntTypeDto,
"computed" to numericDto,
"array" to numericDto,
)
val expectedModelTypes = mapOf(
"numberLocal" to EtsNumberType,
"bigintLocal" to EtsBigIntType,
"computed" to numericModel,
"array" to numericModel,
)

for ((name, expectedType) in expectedDtoTypes) {
val dtoStmts = dtoClass.methods.single { it.signature.name == name }
.body!!.cfg.blocks.flatMap { it.stmts }
val conversion = dtoStmts.filterIsInstance<AssignStmtDto>().single { it.right is ToNumericExprDto }
val numeric = conversion.right as ToNumericExprDto
val result = dtoStmts.filterIsInstance<ReturnStmtDto>().single().arg as LocalDto

assertEquals(expected = expectedType, actual = numeric.type)
assertEquals(expected = (conversion.left as LocalDto).name, actual = result.name)

val modelStmts = modelClass.methods.single { it.name == name }.cfg.stmts
val modelNumeric = modelStmts.filterIsInstance<EtsAssignStmt>()
.mapNotNull { it.rhv as? EtsToNumericExpr }.single()
assertEquals(expected = expectedModelTypes.getValue(name), actual = modelNumeric.type)
assertEquals(expected = listOf(modelNumeric.arg), actual = modelNumeric.getOperands().toList())

val visited = mutableListOf<EtsEntity>()
val handler = object : AbstractHandler() {
override fun handle(value: EtsEntity) {
visited += value
}

override fun handle(stmt: EtsStmt) = Unit
}
modelNumeric.accept(handler)
assertEquals(expected = listOf(modelNumeric, modelNumeric.arg), actual = visited)

val defaultVisitor = object : EtsExpr.Visitor.Default<String> {
override fun defaultVisit(expr: EtsExpr): String = expr.javaClass.simpleName
}
assertEquals(expected = "EtsToNumericExpr", actual = modelNumeric.accept(defaultVisitor))
}
}
}
8 changes: 8 additions & 0 deletions jacodb-ets/ts-frontend/src/dto/values.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export type ExprDto =
| NewClassExprDto
| SpreadExpansionExprDto
| ToPropertyKeyExprDto
| ToNumericExprDto
| RequireObjectCoercibleExprDto
| NewArrayExprDto
| DeleteExprDto
Expand Down Expand Up @@ -176,6 +177,13 @@ export interface ToPropertyKeyExprDto {
arg: ImmediateDto;
}

/** ECMAScript ToNumeric; uses a number hint and preserves a BigInt primitive. */
export interface ToNumericExprDto {
readonly _: "ToNumericExpr";
arg: ImmediateDto;
type: TypeDto;
}

/** Generic property Get/Set, with an already converted runtime key. */
export interface PropertyRefDto {
readonly _: "PropertyRef";
Expand Down
57 changes: 48 additions & 9 deletions jacodb-ets/ts-frontend/src/lowering/exprLowering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -986,19 +986,34 @@ export class ExprLowerer {
returnOld: boolean,
): ValueDto {
const op: UnaryOp = operator === ts.SyntaxKind.PlusPlusToken ? "++" : "--";
const target = this.lowerLValue(operand);
let target = this.lowerLValue(operand);

// Get and ToNumeric can replace source bindings. Keep the selected
// receiver and raw index for Put, without converting the index early.
if (target._ === "InstanceFieldRef" || target._ === "PropertyRef") {
target = { ...target, instance: this.m.snapshotToLocal(target.instance, target.instance.type) };
} else if (target._ === "ArrayRef") {
const array = target.array;
const index = target.index;
target = {
...target,
array: array._ === "Local" ? this.m.snapshotToLocal(array, array.type) : array,
index: index._ === "Local" ? this.m.snapshotToLocal(index, index.type) : index,
};
}

const rawValue = target._ === "Local"
? target
: this.materialize(this.propertyReferenceForOperation(target), lvalueType(target));
const numericType = toNumericType(lvalueType(target));
const oldValue = this.materialize({ _: "ToNumericExpr", arg: rawValue, type: numericType }, numericType);

if (target._ === "Local") {
// Postfix needs a copy of the old value BEFORE the update.
const saved = returnOld ? this.materialize(target, target.type) : undefined;
this.m.cfg.emit({ _: "AssignStmt", left: target, right: { _: "UnopExpr", op, arg: target } });
return saved ?? target;
this.m.cfg.emit({ _: "AssignStmt", left: target, right: { _: "UnopExpr", op, arg: oldValue } });
return returnOld ? oldValue : target;
}

// Field/array target: load old, compute updated, store back.
// %old := ref; %new := %old ++; ref := %new
const oldValue = this.materialize(this.propertyReferenceForOperation(target), lvalueType(target));
const updated = this.materialize({ _: "UnopExpr", op, arg: oldValue }, lvalueType(target));
const updated = this.materialize({ _: "UnopExpr", op, arg: oldValue }, numericType);
this.m.cfg.emit({ _: "AssignStmt", left: this.propertyReferenceForOperation(target), right: updated });
return returnOld ? oldValue : updated;
}
Expand Down Expand Up @@ -2112,6 +2127,30 @@ function lvalueType(target: LValueDto): TypeDto {
}
}

/** Type hint for the converted primitive; opaque objects can produce either numeric kind. */
function toNumericType(type: TypeDto): TypeDto {
switch (type._) {
case "BigIntType":
return BIGINT_TYPE;
case "NumberType":
case "StringType":
case "BooleanType":
case "NullType":
case "UndefinedType":
case "LiteralType":
return NUMBER_TYPE;
case "AliasType":
return toNumericType(type.originalType);
case "UnionType": {
const numericTypes = type.types.map(toNumericType);
if (numericTypes.every((numeric) => numeric._ === "NumberType")) return NUMBER_TYPE;
if (numericTypes.every((numeric) => numeric._ === "BigIntType")) return BIGINT_TYPE;
break;
}
}
return { _: "UnionType", types: [NUMBER_TYPE, BIGINT_TYPE] };
}

export function arrayElementType(array: Extract<TypeDto, { _: "ArrayType" }>): TypeDto {
if (array.dimensions <= 1) {
return array.elementType;
Expand Down
2 changes: 2 additions & 0 deletions jacodb-ets/ts-frontend/src/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const EXPR_KINDS = new Set([
"NewClassExpr",
"SpreadExpansionExpr",
"ToPropertyKeyExpr",
"ToNumericExpr",
"RequireObjectCoercibleExpr",
"NewArrayExpr",
"DeleteExpr",
Expand Down Expand Up @@ -422,6 +423,7 @@ export function valueOperands(value: ValueDto): ValueDto[] {
case "TypeOfExpr":
case "CastExpr":
case "ToPropertyKeyExpr":
case "ToNumericExpr":
case "RequireObjectCoercibleExpr":
case "UnopExpr":
return [value.arg];
Expand Down
12 changes: 9 additions & 3 deletions jacodb-ets/ts-frontend/test/async-iterator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ async function execute(method: MethodDto, input: unknown): Promise<unknown> {
case "UndefinedType": return undefined;
case "NullType": return null;
case "NumberType": return Number(value.value);
case "BigIntType": return BigInt(value.value);
case "BooleanType": return value.value === "true";
default: return value.value;
}
Expand All @@ -38,9 +39,14 @@ async function execute(method: MethodDto, input: unknown): Promise<unknown> {
case "InstanceFieldRef": return read(value.instance)[value.field.name];
case "PropertyRef": return read(value.instance)[read(value.key)];
case "TypeOfExpr": return typeof read(value.arg);
case "UnopExpr":
if (value.op !== "!") throw new Error(`Unexpected unary op ${value.op}`);
return !read(value.arg);
case "ToNumericExpr": return -(-read(value.arg));
case "UnopExpr": {
if (value.op === "!") return !read(value.arg);
let numeric = read(value.arg);
if (value.op === "++") return ++numeric;
if (value.op === "--") return --numeric;
throw new Error(`Unexpected unary op ${value.op}`);
}
case "BinopExpr":
case "ConditionExpr": {
const left = read(value.left);
Expand Down
8 changes: 7 additions & 1 deletion jacodb-ets/ts-frontend/test/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export function executor(file: EtsFileDto, externals: Record<string, any> = {})
case "UndefinedType": return undefined;
case "NullType": return null;
case "NumberType": return Number(v.value);
case "BigIntType": return BigInt(v.value);
case "BooleanType": return v.value === "true";
default: return v.value;
}
Expand All @@ -40,10 +41,15 @@ export function executor(file: EtsFileDto, externals: Record<string, any> = {})
}
case "TypeOfExpr": return typeof value(v.arg);
case "CastExpr": return value(v.arg);
case "UnopExpr":
case "ToNumericExpr": return -(-value(v.arg));
case "UnopExpr": {
if (v.op === "!") return !value(v.arg);
if (v.op === "-") return -value(v.arg);
let numeric = value(v.arg);
if (v.op === "++") return ++numeric;
if (v.op === "--") return --numeric;
throw new Error(`Unsupported unary op: ${v.op}`);
}
case "BinopExpr":
case "ConditionExpr": {
const left = value(v.left);
Expand Down
Loading