From c34371b78405cb2b1696b9ae31ce9687721bcc9a Mon Sep 17 00:00:00 2001 From: sergeych Date: Wed, 21 Oct 2026 00:01:11 +0400 Subject: [PATCH] Preserve static return types and initialize singleton static methods --- docs/ai_language_reference.md | 1 + inner_bugs/fixed/2.md | 65 ++++++++++++++++ .../net/sergeych/lyng/ClassDeclStatement.kt | 3 + .../kotlin/net/sergeych/lyng/Compiler.kt | 15 ++-- .../lyng/bytecode/BytecodeCompiler.kt | 11 ++- .../kotlin/net/sergeych/lyng/obj/ObjRef.kt | 1 + .../kotlin/StaticReturnTypeRegressionTest.kt | 76 +++++++++++++++++++ 7 files changed, 162 insertions(+), 10 deletions(-) create mode 100644 inner_bugs/fixed/2.md create mode 100644 lynglib/src/commonTest/kotlin/StaticReturnTypeRegressionTest.kt diff --git a/docs/ai_language_reference.md b/docs/ai_language_reference.md index df1b154..f24c130 100644 --- a/docs/ai_language_reference.md +++ b/docs/ai_language_reference.md @@ -126,6 +126,7 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T - extension functions: `fun Type.name(...) { ... }`. - context-aware extension functions: `context(Tag) fun String.unaryPlus() { this@Tag.addText(this) }`. - named singleton `object` declarations can be extension receivers too: `fun Config.describe(...) { ... }`, `val Config.tag get() = ...`. + - Static class/object methods preserve declared or inferable return types at call sites, including chained member access and generic return types inferred from arguments. For example, `static fun of(v: T): Box = Box(v)` lets `Box.of("abc").value.length` retain the `String` member type. - static extension functions are callable on the type object: `static fun List.fill(...)` -> `List.fill(...)`. - delegated callable: `fun f(...) by delegate`. - Type aliases: diff --git a/inner_bugs/fixed/2.md b/inner_bugs/fixed/2.md new file mode 100644 index 0000000..4b818a4 --- /dev/null +++ b/inner_bugs/fixed/2.md @@ -0,0 +1,65 @@ +### Ticket 2 — Static member calls: declared return type is not propagated, receiver becomes `Object` + +**Summary.** The **invocation** of a `static` member (class or `object` singleton) loses its declared return type at the call site; the result is treated as a general receiver, so every type-specific member access fails to compile. The runtime value is correct — this is purely a static-typing defect. Constructor calls, instance-method calls and top-level function calls all preserve the declared type correctly, so the inconsistency is specific to static members. + +**Repro** +```lyng +class S(x) { + static fun make(y): S = S(y) // return type is explicitly declared + fun hi(): String = "hi" +} + +val a: S = S.make(1) // OK — annotation rescues it +println(a.hi()) + +val b = S.make(2) // b is S == true at runtime +println(b.hi()) // Error: member access requires compile-time receiver type: hi +println(S.make(1).hi())// Error: same, chained call +println((S.make(3) as S).hi()) // OK — explicit cast rescues it +fun need(v: S) = v.hi(); need(S.make(4)) // OK — typed argument position rescues it +``` +Not limited to self-returning factories — any declared return type is dropped: +```lyng +class K(x) { static fun text(y): String = "t"; static fun num(y): Int = y } +val s = K.text(1); println(s.length) // Error: ... compile-time receiver type: length +val n = K.num(1); println(n + 1) // same class of failure +object O { static fun text(y): String = "o" } +val o = O.text(1); println(o.length) // Error: ... on O.text result +class B(x) { static fun noRet(y) = B(y) } // untyped static: also general +class Box(val value: T) { static fun of(v: T): Box = Box(v) } +val bx = Box.of(42); println(bx.value) // Error; val bx2: Box = Box.of(43) works +``` +Controls that **do** work correctly: `val d = K8(9); d.hi()` (constructor), `val c = S(3).again(4); c.hi()` (instance method), `val t = topLevelMake(1); t.x` (top-level function). + +**Expected.** `StaticClass.member(args)` must produce the declared return type as the static compile-time receiver type, exactly like instance and top-level calls. Only genuinely untyped statics (`fun` without a return type and uninferrable bodies) should fall back to `Object`/`Obj`. + +**Suggested tests.** Matrix over `{constructor | instance method | top-level fun | static fun} × {declared ret type | inferred | omitted}` asserting that `.length`, arithmetic, `is T`-then-member, chained calls and generic instantiation behave identically for statics as for the other three; plus `object` singletons and generic statics. + + + +## Resolution + +Valid: static method signatures were excluded from the compiler's member type +metadata, both before body compilation and when inferred returns were recorded. +They now use the same callable signature and generic substitution path as +instance methods. Explicit annotations, inferred expression returns, primitive +returns, chained calls, and inferred/explicit generic arguments are covered by +`StaticReturnTypeRegressionTest`. + +The singleton example additionally exposed runtime bugs once typing was +corrected: object static initializers were discarded, and a static call on an +object was sent to instance-method dispatch. Object declarations now preserve +and execute their static initialization statements before constructing the +singleton instance. +The compiler now retains its class-scope call decision for bytecode generation; +singleton static calls use the object's class and the existing class-scope +access opcode. Instance methods retain their dispatch path. Tests also cover +static calls with a trailing lambda. + +Before the fix, all 5 original regression cases failed with missing compile-time +receiver types. The initial type-only correction passed 4, with the singleton +case then failing at runtime; dispatch is corrected in this same report. + +Validation: all 6 focused JVM regression tests passed, followed by all 1,040 +`:lynglib:jvmTest` tests (benchmarks excluded by the repository default). +`git diff --check` also passed. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt index 57368f3..d98e293 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt @@ -88,6 +88,9 @@ internal suspend fun executeClassDecl( classScope.addConst("object", newClass) spec.bodyInit?.let { executeBytecodeWithSeed(classScope, it, "object body init") } + for (statement in spec.initScope) { + executeBytecodeWithSeed(classScope, statement, "object static init") + } val instance = newClass.callOn(scope.createChildScope(Arguments.EMPTY)) if (spec.declaredName != null) { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 556fd52..45b5879 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -3121,7 +3121,7 @@ class Compiler( val field = FieldRef(left, next.value, isOptional) operand = CallRef(field, args, tailBlock, isOptional, memberCallTypeArgs) } else { - operand = MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs) + operand = MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs, classScopeCall = true) } } else { // instance method call @@ -3238,14 +3238,14 @@ class Compiler( resolutionSink?.referenceReflection(name.value, next.pos) } } - MethodCallRef(left, next.value, args, true, isOptional) + MethodCallRef(left, next.value, args, true, isOptional, classScopeCall = shouldTreatAsClassScopeCall(left, next.value)) } else { 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) + MethodCallRef(left, next.value, args, true, isOptional, classScopeCall = shouldTreatAsClassScopeCall(left, next.value)) } } is QualifiedThisRef -> @@ -3265,7 +3265,7 @@ class Compiler( unionCall } else { enforceReceiverTypeForMember(left, next.value, next.pos) - MethodCallRef(left, next.value, args, true, isOptional) + MethodCallRef(left, next.value, args, true, isOptional, classScopeCall = shouldTreatAsClassScopeCall(left, next.value)) } } } @@ -8686,6 +8686,7 @@ class Compiler( val initScope = popInitScope() val wrappedBodyInit = bodyInit?.let { wrapClassBodyBytecode(it, "object@$className") } + val wrappedInitScope = initScope.map { wrapClassBodyBytecode(it, "objectInit@$className") } val spec = ClassDeclSpec( declaredName = declaredName, @@ -8701,7 +8702,7 @@ class Compiler( constructorArgs = null, constructorFieldIds = null, bodyInit = wrappedBodyInit, - initScope = emptyList() + initScope = wrappedInitScope ) return ClassDeclStatement(spec) } @@ -10071,7 +10072,7 @@ class Compiler( pendingTypeParamStack.removeLast() } - if (parentContext is CodeContext.ClassBody && !isStatic && extTypeName == null) { + if (parentContext is CodeContext.ClassBody && extTypeName == null) { classMemberTypeDeclByName.getOrPut(parentContext.name) { mutableMapOf() }[name] = TypeDecl.Function( receiver = receiverTypeDecl, contextReceivers = contextReceiverTypeDecls, @@ -10245,7 +10246,7 @@ class Compiler( val inferredReturnDecl = returnTypeDecl ?: inferReturnTypeDeclFromStatement(rawFnStatements) val inferredReturnClass = inferredReturnDecl?.let { resolveTypeDeclObjClass(it) } ?: inferReturnClassFromStatement(rawFnStatements) - if (parentContext is CodeContext.ClassBody && !isStatic && extTypeName == null) { + if (parentContext is CodeContext.ClassBody && extTypeName == null) { val ownerClassName = parentContext.name run { val memberTypeDecl = TypeDecl.Function( diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt index b20da72..5cf4bf3 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt @@ -4876,11 +4876,16 @@ class BytecodeCompiler( val receiverClass = resolveReceiverClass(ref.receiver) ?: ObjDynamic.type val receiver = compileRefWithFallback(ref.receiver, null, refPosOrCurrent(ref.receiver)) ?: return null val dst = allocSlot() - if (shouldUseClassScopeReceiver(ref.receiver, receiver, receiverClass)) { + val classReceiver = shouldUseClassScopeReceiver(ref.receiver, receiver, receiverClass) + if (ref.classScopeCall || classReceiver) { + // Singleton objects carry their static members on the instance's class. + val classSlot = if (classReceiver) receiver.slot else allocSlot().also { + builder.emit(Opcode.GET_OBJ_CLASS, receiver.slot, it) + } val nameId = builder.addConst(BytecodeConst.StringVal(ref.name)) val memberSlot = allocSlot() if (!ref.isOptional) { - builder.emit(Opcode.GET_CLASS_SCOPE, receiver.slot, nameId, memberSlot) + builder.emit(Opcode.GET_CLASS_SCOPE, classSlot, nameId, memberSlot) } else { val nullSlot = allocSlot() builder.emit(Opcode.CONST_NULL, nullSlot) @@ -4892,7 +4897,7 @@ class BytecodeCompiler( Opcode.JMP_IF_TRUE, listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel)) ) - builder.emit(Opcode.GET_CLASS_SCOPE, receiver.slot, nameId, memberSlot) + builder.emit(Opcode.GET_CLASS_SCOPE, classSlot, nameId, memberSlot) builder.emit(Opcode.JMP, listOf(CmdBuilder.Operand.LabelRef(endLabel))) builder.mark(nullLabel) builder.emit(Opcode.CONST_NULL, memberSlot) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt index 755957b..a2e16fe 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -484,6 +484,7 @@ class MethodCallRef( internal val tailBlock: Boolean, internal val isOptional: Boolean, internal val explicitTypeArgs: List? = null, + internal val classScopeCall: Boolean = false, ) : ObjRef { // 4-entry PIC for method invocations (guarded by PerfFlags.METHOD_PIC) private var mKey1: Long = 0L; private var mVer1: Int = -1; private var mInvoker1: (suspend (Obj, Scope, Arguments) -> Obj)? = null diff --git a/lynglib/src/commonTest/kotlin/StaticReturnTypeRegressionTest.kt b/lynglib/src/commonTest/kotlin/StaticReturnTypeRegressionTest.kt new file mode 100644 index 0000000..6f0b416 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/StaticReturnTypeRegressionTest.kt @@ -0,0 +1,76 @@ +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test +import kotlin.test.assertEquals + +class StaticReturnTypeRegressionTest { + @Test + fun declaredFactoryReturnSupportsInferredVariablesAndChains() = runTest { + assertEquals("hi hi hi", eval(""" + class Factory(x) { + static fun make(y): Factory = Factory(y) + fun hi(): String = "hi" + } + val a: Factory = Factory.make(1) + val b = Factory.make(2) + a.hi() + " " + b.hi() + " " + Factory.make(3).hi() + """.trimIndent()).toString()) + } + + @Test + fun declaredScalarReturnsSupportMembersAndArithmetic() = runTest { + assertEquals("4", eval(""" + class Scalars { + static fun text(y): String = "abc" + static fun num(y: Int): Int = y + } + val s = Scalars.text(1) + val n = Scalars.num(1) + s.length + n + """.trimIndent()).toString()) + } + + @Test + fun singletonStaticReturnSupportsMembers() = runTest { + assertEquals("3", eval(""" + object Singleton { static fun text(): String = "abc" } + val text = Singleton.text() + text.length + """.trimIndent()).toString()) + } + + @Test + fun singletonStaticTailBlockAndInstanceMethodsUseTheirOwnDispatch() = runTest { + assertEquals("6", eval(""" + object Singleton { + static fun text(block: ()->String): String = block() + fun instanceText(): String = "abc" + } + Singleton.text { "abc" }.length + Singleton.instanceText().length + """.trimIndent()).toString()) + } + + @Test + fun inferredStaticReturnMatchesInstanceAndTopLevelFunctions() = runTest { + assertEquals("9", eval(""" + class Factory { + static fun text() = "abc" + fun instanceText() = "abc" + } + fun topText() = "abc" + Factory.text().length + Factory().instanceText().length + topText().length + """.trimIndent()).toString()) + } + + @Test + fun genericStaticReturnSubstitutesArgumentType() = runTest { + assertEquals("7", eval(""" + class Box(val value: T) { + static fun of(v: T): Box = Box(v) + } + val box = Box.of("abc") + val explicit = Box.of("abcd") + box.value.length + explicit.value.length + """.trimIndent()).toString()) + } +}