From 565dbf98ed655e2c2da89312eb0e429be0602961 Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 9 Feb 2026 11:38:04 +0300 Subject: [PATCH] Step 19: union member access --- bytecode_migration_plan.md | 7 +- .../kotlin/net/sergeych/lyng/Compiler.kt | 148 ++++++++++++++++-- .../kotlin/BytecodeRecentOpsTest.kt | 42 +++++ 3 files changed, 182 insertions(+), 15 deletions(-) diff --git a/bytecode_migration_plan.md b/bytecode_migration_plan.md index abf444c..ef04005 100644 --- a/bytecode_migration_plan.md +++ b/bytecode_migration_plan.md @@ -62,9 +62,10 @@ Goal: migrate the compiler so all values live in frames/bytecode, keeping JVM te - [x] Step 18: Delegated member access in bytecode. - [x] Remove `containsDelegatedRefs` guard once bytecode emits delegated get/set/call correctly. - [x] Add JVM coverage for delegated member get/set/call in bytecode. -- [ ] Step 19: Unknown receiver member access in bytecode. - - [ ] Decide on allowed fallback behavior for unknown receiver types without runtime name resolution. - - [ ] Add JVM tests for member access on unresolved receiver types and keep compile-time-only resolution. +- [x] Step 19: Unknown receiver member access in bytecode. + - [x] Reject Object/unknown receiver member calls without explicit cast or Dynamic. + - [x] Add union-member dispatch with ordered type checks and runtime mismatch error. + - [x] Add JVM tests for unknown receiver and union member access. ## Notes diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 84fa9be..1cc1a0a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -2443,8 +2443,13 @@ class Compiler( } MethodCallRef(left, next.value, args, tailBlock, isOptional) } else { - enforceReceiverTypeForMember(left, next.value, next.pos) - MethodCallRef(left, next.value, args, tailBlock, isOptional) + val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, tailBlock) + if (unionCall != null) { + unionCall + } else { + enforceReceiverTypeForMember(left, next.value, next.pos) + MethodCallRef(left, next.value, args, tailBlock, isOptional) + } } is QualifiedThisRef -> QualifiedThisMethodSlotCallRef( @@ -2458,8 +2463,13 @@ class Compiler( resolutionSink?.referenceMember(next.value, next.pos, left.typeName) } else -> { - enforceReceiverTypeForMember(left, next.value, next.pos) - MethodCallRef(left, next.value, args, tailBlock, isOptional) + val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, tailBlock) + if (unionCall != null) { + unionCall + } else { + enforceReceiverTypeForMember(left, next.value, next.pos) + MethodCallRef(left, next.value, args, tailBlock, isOptional) + } } } } @@ -2496,8 +2506,13 @@ class Compiler( } MethodCallRef(left, next.value, args, true, isOptional) } else { - enforceReceiverTypeForMember(left, next.value, next.pos) - MethodCallRef(left, next.value, args, true, isOptional) + val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, true) + if (unionCall != null) { + unionCall + } else { + enforceReceiverTypeForMember(left, next.value, next.pos) + MethodCallRef(left, next.value, args, true, isOptional) + } } is QualifiedThisRef -> QualifiedThisMethodSlotCallRef( @@ -2511,8 +2526,13 @@ class Compiler( resolutionSink?.referenceMember(next.value, next.pos, left.typeName) } else -> { - enforceReceiverTypeForMember(left, next.value, next.pos) - MethodCallRef(left, next.value, args, true, isOptional) + val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, true) + if (unionCall != null) { + unionCall + } else { + enforceReceiverTypeForMember(left, next.value, next.pos) + MethodCallRef(left, next.value, args, true, isOptional) + } } } } @@ -2528,8 +2548,13 @@ class Compiler( val ids = resolveMemberIds(next.value, next.pos, implicitType) ThisFieldSlotRef(next.value, ids.fieldId, ids.methodId, isOptional) } else { - enforceReceiverTypeForMember(left, next.value, next.pos) - FieldRef(left, next.value, isOptional) + val unionField = buildUnionFieldAccess(left, next.value, next.pos, isOptional) + if (unionField != null) { + unionField + } else { + enforceReceiverTypeForMember(left, next.value, next.pos) + FieldRef(left, next.value, isOptional) + } } is QualifiedThisRef -> run { val ids = resolveMemberIds(next.value, next.pos, left.typeName) @@ -2544,8 +2569,13 @@ class Compiler( resolutionSink?.referenceMember(next.value, next.pos, left.typeName) } else -> { - enforceReceiverTypeForMember(left, next.value, next.pos) - FieldRef(left, next.value, isOptional) + val unionField = buildUnionFieldAccess(left, next.value, next.pos, isOptional) + if (unionField != null) { + unionField + } else { + enforceReceiverTypeForMember(left, next.value, next.pos) + FieldRef(left, next.value, isOptional) + } } } } @@ -4310,6 +4340,100 @@ class Compiler( } } + private class UnionTypeMismatchStatement( + private val message: String, + override val pos: Pos + ) : Statement() { + override suspend fun execute(scope: Scope): Obj { + throw ScriptError(pos, message) + } + } + + private fun resolveMemberHostForUnion(typeDecl: TypeDecl, memberName: String, pos: Pos): ObjClass { + val receiverClass = when (typeDecl) { + TypeDecl.TypeAny, TypeDecl.TypeNullableAny -> Obj.rootObjectType + else -> resolveTypeDeclObjClass(typeDecl) + } ?: throw ScriptError(pos, "member access requires compile-time receiver type: $memberName") + if (receiverClass == ObjDynamic.type) { + return receiverClass + } + registerExtensionWrapperBindings(receiverClass, memberName, pos) + val hasMember = receiverClass.instanceFieldIdMap()[memberName] != null || + receiverClass.instanceMethodIdMap(includeAbstract = true)[memberName] != null + if (!hasMember && !hasExtensionFor(receiverClass.className, memberName)) { + if (receiverClass == Obj.rootObjectType && isAllowedObjectMember(memberName)) { + return receiverClass + } + val ownerName = receiverClass.className + val message = if (receiverClass == Obj.rootObjectType) { + "member $memberName is not available on Object without explicit cast" + } else { + "unknown member $memberName on $ownerName" + } + throw ScriptError(pos, message) + } + return receiverClass + } + + private fun buildUnionMemberAccess( + left: ObjRef, + union: TypeDecl.Union, + memberName: String, + pos: Pos, + isOptional: Boolean, + makeRef: (ObjRef) -> ObjRef + ): ObjRef { + val options = union.options + if (options.isEmpty()) { + throw ScriptError(pos, "member access requires compile-time receiver type: $memberName") + } + for (option in options) { + resolveMemberHostForUnion(option, memberName, pos) + } + val unionName = typeDeclName(union) + val failStmt = UnionTypeMismatchStatement("value is not $unionName", pos) + var current: ObjRef = net.sergeych.lyng.obj.StatementRef(failStmt) + for (option in options.asReversed()) { + val typeRef = net.sergeych.lyng.obj.TypeDeclRef(option, pos) + val cond = BinaryOpRef(BinOp.IS, left, typeRef) + val casted = CastRef(left, typeRef, false, pos) + val branch = makeRef(casted) + current = ConditionalRef(cond, branch, current) + } + if (isOptional) { + val nullRef = ConstRef(ObjNull.asReadonly) + val nullCond = BinaryOpRef(BinOp.REF_EQ, left, nullRef) + current = ConditionalRef(nullCond, nullRef, current) + } + return current + } + + private fun buildUnionFieldAccess( + left: ObjRef, + memberName: String, + pos: Pos, + isOptional: Boolean + ): ObjRef? { + val receiverDecl = resolveReceiverTypeDecl(left) as? TypeDecl.Union ?: return null + return buildUnionMemberAccess(left, receiverDecl, memberName, pos, isOptional) { receiver -> + FieldRef(receiver, memberName, false) + } + } + + private fun buildUnionMethodCall( + left: ObjRef, + memberName: String, + pos: Pos, + isOptional: Boolean, + args: List, + tailBlock: Boolean + ): ObjRef? { + val receiverDecl = resolveReceiverTypeDecl(left) as? TypeDecl.Union ?: return null + return buildUnionMemberAccess(left, receiverDecl, memberName, pos, isOptional) { receiver -> + MethodCallRef(receiver, memberName, args, tailBlock, false) + } + } + private fun registerExtensionWrapperBindings(receiverClass: ObjClass, memberName: String, pos: Pos) { for (cls in receiverClass.mro) { val wrapperNames = listOf( diff --git a/lynglib/src/commonTest/kotlin/BytecodeRecentOpsTest.kt b/lynglib/src/commonTest/kotlin/BytecodeRecentOpsTest.kt index 9d982cc..afb4be4 100644 --- a/lynglib/src/commonTest/kotlin/BytecodeRecentOpsTest.kt +++ b/lynglib/src/commonTest/kotlin/BytecodeRecentOpsTest.kt @@ -18,11 +18,13 @@ import kotlinx.coroutines.test.runTest import net.sergeych.lyng.Compiler import net.sergeych.lyng.Script +import net.sergeych.lyng.ScriptError import net.sergeych.lyng.Source import net.sergeych.lyng.eval import net.sergeych.lyng.obj.toInt import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertTrue class BytecodeRecentOpsTest { @@ -195,6 +197,46 @@ class BytecodeRecentOpsTest { ) } + @Test + fun unionMemberDispatchSubtype() = runTest { + eval( + """ + class A { fun who() = "A" } + class B : A { override fun who() = "B" } + fun pick(x: A | B) { x.who() } + assertEquals("B", pick(B())) + """.trimIndent() + ) + } + + @Test + fun objectReceiverMemberError() = runTest { + val failed = try { + eval("fun bad(x) { x.missing() }") + false + } catch (_: ScriptError) { + true + } + assertTrue(failed) + } + + @Test + fun unionMissingMemberError() = runTest { + val failed = try { + eval( + """ + class A { fun who() = "A" } + class B { fun other() = "B" } + fun pick(x: A | B) { x.who() } + """.trimIndent() + ) + false + } catch (_: ScriptError) { + true + } + assertTrue(failed) + } + @Test fun qualifiedThisValueRef() = runTest { eval(