From 50b36cb8c021a96a715fe45b152ae2d7b283739f Mon Sep 17 00:00:00 2001 From: Aleksei Menshutin Date: Sun, 4 Oct 2026 22:49:52 +0300 Subject: [PATCH] [TS] Preserve receivers of captured pointer calls --- .../main/kotlin/org/jacodb/ets/dto/Convert.kt | 1 + .../main/kotlin/org/jacodb/ets/dto/Values.kt | 1 + .../main/kotlin/org/jacodb/ets/model/Expr.kt | 1 + .../org/jacodb/ets/utils/GetOperands.kt | 2 +- .../kotlin/org/jacodb/ets/utils/Handler.kt | 1 + .../jacodb/ets/test/EtsPtrCallReceiverTest.kt | 92 +++++++++++++++++++ jacodb-ets/ts-frontend/src/dto/values.ts | 1 + jacodb-ets/ts-frontend/src/validate.ts | 5 +- .../ts-frontend/test/pointer-receiver.spec.ts | 22 +++++ 9 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsPtrCallReceiverTest.kt create mode 100644 jacodb-ets/ts-frontend/test/pointer-receiver.spec.ts diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt index 5ac274829..fba34efa4 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Convert.kt @@ -459,6 +459,7 @@ class EtsMethodBuilder( callee = method.toEtsMethodSignature(), args = args.map { ensureLocal(it.toEtsEntity()) }, type = type.toEtsType(), + receiver = receiver?.let { ensureLocal(it.toEtsEntity()) }, ) is ThisRefDto -> EtsThis( diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt index 9a360a3f6..020c90c19 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/dto/Values.kt @@ -288,6 +288,7 @@ data class PtrCallExprDto( val ptr: ValueDto, // Local or FieldRef override val method: MethodSignatureDto, override val args: List, + val receiver: ValueDto? = null, ) : CallExprDto @Serializable diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt index 3d90e8cfc..085138395 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/model/Expr.kt @@ -798,6 +798,7 @@ data class EtsPtrCallExpr( override val callee: EtsMethodSignature, override val args: List, override val type: EtsType, + val receiver: EtsLocal? = null, ) : EtsCallExpr { override fun toString(): String { return "ptr_call ${ptr}(${args.joinToString()})" diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt index d8de23c5d..4ffd6e686 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/GetOperands.kt @@ -300,7 +300,7 @@ private object EntityGetOperands : EtsEntity.Visitor> { expr.args.asSequence() override fun visit(expr: EtsPtrCallExpr): Sequence = - sequenceOf(expr.ptr) + expr.args.asSequence() + sequenceOf(expr.ptr) + listOfNotNull(expr.receiver).asSequence() + expr.args.asSequence() override fun visit(value: EtsRawEntity): Sequence = emptySequence() diff --git a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt index 43b31e18e..1632cf9f5 100644 --- a/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt +++ b/jacodb-ets/src/main/kotlin/org/jacodb/ets/utils/Handler.kt @@ -465,6 +465,7 @@ abstract class AbstractHandler : EtsEntity.Visitor, EtsStmt.Visitor final override fun visit(expr: EtsPtrCallExpr) { handle(expr) expr.ptr.accept(this) + expr.receiver?.accept(this) expr.args.forEach { it.accept(this) } } final override fun visit(expr: EtsSpreadExpansionExpr) { diff --git a/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsPtrCallReceiverTest.kt b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsPtrCallReceiverTest.kt new file mode 100644 index 000000000..d4333c4ef --- /dev/null +++ b/jacodb-ets/src/test/kotlin/org/jacodb/ets/test/EtsPtrCallReceiverTest.kt @@ -0,0 +1,92 @@ +/* + * 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.EtsFileDto +import org.jacodb.ets.dto.dtoModule +import org.jacodb.ets.dto.toEtsFile +import org.jacodb.ets.model.EtsAssignStmt +import org.jacodb.ets.model.EtsEntity +import org.jacodb.ets.model.EtsLocal +import org.jacodb.ets.model.EtsPtrCallExpr +import org.jacodb.ets.model.EtsStmt +import org.jacodb.ets.utils.AbstractHandler +import org.jacodb.ets.utils.getOperands +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class EtsPtrCallReceiverTest { + private fun call(includeReceiver: Boolean): EtsPtrCallExpr { + val fileSignature = """{"projectName":"test","fileName":"receiver.ts"}""" + val classSignature = """{"name":"Container","declaringFile":$fileSignature}""" + val receiver = if (includeReceiver) """, "receiver":{"_":"Local","name":"object","type":{"_":"UnknownType"}}""" else "" + val source = """ + { + "signature":$fileSignature,"namespaces":[],"importInfos":[],"exportInfos":[], + "classes":[{ + "signature":$classSignature,"modifiers":0,"decorators":[], + "superClassName":"","implementedInterfaceNames":[],"fields":[], + "methods":[{ + "signature":{"name":"run","declaringClass":$classSignature,"parameters":[],"returnType":{"_":"UnknownType"}}, + "modifiers":0,"decorators":[], + "body":{"locals":[],"cfg":{"blocks":[{"id":0,"successors":[],"stmts":[{ + "_":"AssignStmt","left":{"_":"Local","name":"result","type":{"_":"UnknownType"}}, + "right":{"_":"PtrCallExpr","ptr":{"_":"Local","name":"fn","type":{"_":"UnknownType"}}, + "method":{"name":"invoke","declaringClass":$classSignature,"parameters":[],"returnType":{"_":"UnknownType"}}, + "args":[]$receiver} + },{"_":"ReturnVoidStmt"}]}]}} + }] + }] + } + """.trimIndent() + val dto = EtsFileDto.loadFromJson(source) + val roundTripped = EtsFileDto.loadFromJson(Json { serializersModule = dtoModule }.encodeToString(dto)) + + return roundTripped.toEtsFile().classes.single().methods.single().cfg.stmts + .filterIsInstance().single().rhv as EtsPtrCallExpr + } + + @Test + fun `receiver survives JSON conversion and is traversed once`() { + val call = call(includeReceiver = true) + val visited = mutableListOf() + val handler = object : AbstractHandler() { + override fun handle(value: EtsEntity) { + if (value is EtsLocal) visited += value.name + } + + override fun handle(stmt: EtsStmt) = Unit + } + + call.accept(handler) + + assertEquals("object", call.receiver?.name) + assertEquals(listOf("fn", "object"), call.getOperands().filterIsInstance().map { it.name }.toList()) + assertEquals(listOf("fn", "object"), visited) + } + + @Test + fun `old payload without receiver remains readable`() { + val call = call(includeReceiver = false) + + assertNull(call.receiver) + assertEquals(listOf("fn"), call.getOperands().filterIsInstance().map { it.name }.toList()) + } +} diff --git a/jacodb-ets/ts-frontend/src/dto/values.ts b/jacodb-ets/ts-frontend/src/dto/values.ts index 4ce843372..be76c050e 100644 --- a/jacodb-ets/ts-frontend/src/dto/values.ts +++ b/jacodb-ets/ts-frontend/src/dto/values.ts @@ -196,6 +196,7 @@ export interface PtrCallExprDto { ptr: ValueDto; // Local or FieldRef (must be a value, not an expr) method: MethodSignatureDto; args: ValueDto[]; + receiver?: LocalDto; // Receiver of a captured property callable. } export interface ThisRefDto { diff --git a/jacodb-ets/ts-frontend/src/validate.ts b/jacodb-ets/ts-frontend/src/validate.ts index c4a6bb2c0..7a92e26a7 100644 --- a/jacodb-ets/ts-frontend/src/validate.ts +++ b/jacodb-ets/ts-frontend/src/validate.ts @@ -364,6 +364,9 @@ function validateValue( } break; case "PtrCallExpr": + if (value.receiver !== undefined && value.receiver._ !== "Local") { + err(`${ctx}: PtrCallExpr.receiver must be Local`); + } // Kotlin Convert casts ptr to EtsValue — expr kinds would throw a ClassCastException. if (EXPR_KINDS.has(value.ptr._)) { err(`${ctx}: PtrCallExpr.ptr has expr kind '${value.ptr._}', must be an immediate or ref`); @@ -409,7 +412,7 @@ export function valueOperands(value: ValueDto): ValueDto[] { case "StaticCallExpr": return [...value.args]; case "PtrCallExpr": - return [value.ptr, ...value.args]; + return [value.ptr, ...(value.receiver === undefined ? [] : [value.receiver]), ...value.args]; case "GlobalRef": return value.ref !== null ? [value.ref] : []; case "ClosureFieldRef": diff --git a/jacodb-ets/ts-frontend/test/pointer-receiver.spec.ts b/jacodb-ets/ts-frontend/test/pointer-receiver.spec.ts new file mode 100644 index 000000000..ad634bc44 --- /dev/null +++ b/jacodb-ets/ts-frontend/test/pointer-receiver.spec.ts @@ -0,0 +1,22 @@ +import { expect, it } from "vitest"; +import { UNKNOWN_CLASS_SIGNATURE } from "../src/dto/signatures"; +import { UNKNOWN_TYPE } from "../src/dto/types"; +import { LocalDto, PtrCallExprDto } from "../src/dto/values"; +import { valueOperands } from "../src/validate"; + +it("preserves and traverses a captured pointer-call receiver exactly once", () => { + const ptr: LocalDto = { _: "Local", name: "fn", type: UNKNOWN_TYPE }; + const receiver: LocalDto = { _: "Local", name: "object", type: UNKNOWN_TYPE }; + const argument: LocalDto = { _: "Local", name: "arg", type: UNKNOWN_TYPE }; + const call: PtrCallExprDto = { + _: "PtrCallExpr", ptr, receiver, + method: { declaringClass: UNKNOWN_CLASS_SIGNATURE, name: "call", parameters: [], returnType: UNKNOWN_TYPE }, + args: [argument], + }; + + const roundTripped: PtrCallExprDto = JSON.parse(JSON.stringify(call)); + + expect(roundTripped.receiver).toEqual(receiver); + expect(valueOperands(roundTripped)).toEqual([ptr, receiver, argument]); + expect(valueOperands({ ...call, receiver: undefined })).toEqual([ptr, argument]); +});