From c5b8871c3aa145cd078d1caa8e3e8b1348b6968f Mon Sep 17 00:00:00 2001 From: sergeych Date: Mon, 16 Feb 2026 19:08:32 +0300 Subject: [PATCH] Add support for closed classes and enhancements to the Kotlin reflection bridge --- docs/embedding.md | 71 ++++++++++ docs/whats_new.md | 8 ++ .../net/sergeych/lyng/ClassDeclStatement.kt | 2 + .../kotlin/net/sergeych/lyng/Compiler.kt | 19 ++- .../sergeych/lyng/bridge/BridgeResolver.kt | 121 +++++++++++++++--- .../net/sergeych/lyng/bridge/ClassBridge.kt | 83 ++++++++++-- .../src/commonTest/kotlin/ClosedClassTest.kt | 59 +++++++++ 7 files changed, 327 insertions(+), 36 deletions(-) create mode 100644 lynglib/src/commonTest/kotlin/ClosedClassTest.kt diff --git a/docs/embedding.md b/docs/embedding.md index bada46f..59cf657 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -182,6 +182,77 @@ instance.value = 42 println(instance.value) // -> 42 ``` +### 6.5) Preferred: bind Kotlin implementations to declared Lyng classes + +For extensions and libraries, the **preferred** workflow is Lyng‑first: declare the class and its members in Lyng, then bind the Kotlin implementations using the bridge. + +This keeps Lyng semantics (visibility, overrides, type checks) in Lyng, while Kotlin supplies the behavior. + +```lyng +// Lyng side (in a module) +class Counter { + extern var value: Int + extern fun inc(by: Int): Int +} +``` + +Note: members must be marked `extern` so the compiler emits the ABI slots that Kotlin bindings attach to. This applies to functions and properties bound via `addFun` / `addVal` / `addVar`. + +```kotlin +// Kotlin side (binding) +val moduleScope = Script.newScope() // or an existing module scope +moduleScope.eval("class Counter { extern var value: Int; extern fun inc(by: Int): Int }") + +moduleScope.bind("Counter") { + addVar( + name = "value", + get = { _, self -> self.readField(this, "value").value }, + set = { _, self, v -> self.writeField(this, "value", v) } + ) + addFun("inc") { _, self, args -> + val by = args.requiredArg(0).value + val current = self.readField(this, "value").value as ObjInt + val next = ObjInt(current.value + by) + self.writeField(this, "value", next) + next + } +} +``` + +Notes: + +- Binding must happen **before** the first instance is created. +- Use [LyngClassBridge] to bind by name/module, or by an already resolved `ObjClass`. +- Use `ObjInstance.data` / `ObjClass.classData` to attach Kotlin‑side state when needed. + +### 6.6) Preferred: Kotlin reflection bridge for call‑by‑name + +For Kotlin code that needs dynamic access to Lyng variables, functions, or members, use the bridge resolver. +It provides explicit, cached handles and predictable lookup rules. + +```kotlin +val scope = Script.newScope() +scope.eval(""" + val x = 40 + fun add(a, b) = a + b + class Box { var value = 1 } +""") + +val resolver = scope.resolver() + +// Read a top‑level value +val x = resolver.resolveVal("x").get(scope) + +// Call a function by name (cached inside the resolver) +val sum = (resolver as BridgeCallByName).callByName(scope, "add", Arguments(ObjInt(1), ObjInt(2))) + +// Member access +val box = scope.eval("Box()") +val valueHandle = resolver.resolveMemberVar(box, "value") +valueHandle.set(scope, ObjInt(10)) +val value = valueHandle.get(scope) +``` + ### 7) Read variable values back in Kotlin The simplest approach: evaluate an expression that yields the value and convert it. diff --git a/docs/whats_new.md b/docs/whats_new.md index ccfe62a..75aa99c 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -243,3 +243,11 @@ You can enable it in **Settings | Lyng Formatter | Enable Lyng autocompletion**. ### Kotlin API: Exception Handling The `Obj.getLyngExceptionMessageWithStackTrace()` extension method has been added to simplify retrieving detailed error information from Lyng exception objects in Kotlin. Additionally, `getLyngExceptionMessage()` and `raiseAsExecutionError()` now accept an optional `Scope`, making it easier to use them when a scope is not immediately available. + +### Kotlin API: Bridge Reflection and Class Binding (Preferred Extensions) +Lyng now provides a public Kotlin reflection bridge and a Lyng‑first class binding workflow. This is the **preferred** way to write Kotlin extensions and library integrations: + +- **Bridge resolver**: explicit handles for values, vars, and callables with predictable lookup rules. +- **Class bridge binding**: declare classes/members in Lyng (marked `extern`) and bind the implementations in Kotlin before the first instance is created. + +See **Embedding Lyng** for full samples and usage details. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt index 659ec14..de06657 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ClassDeclStatement.kt @@ -31,6 +31,7 @@ data class ClassDeclSpec( val startPos: Pos, val isExtern: Boolean, val isAbstract: Boolean, + val isClosed: Boolean, val isObject: Boolean, val isAnonymous: Boolean, val baseSpecs: List, @@ -118,6 +119,7 @@ internal suspend fun executeClassDecl( val newClass = ObjInstanceClass(spec.className, *parentClasses.toTypedArray()).also { it.isAbstract = spec.isAbstract + it.isClosed = spec.isClosed it.instanceConstructor = constructorCode it.constructorMeta = spec.constructorArgs for (i in parentClasses.indices) { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index c230712..4334b2f 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -5331,8 +5331,8 @@ class Compiler( val isMember = (codeContexts.lastOrNull() is CodeContext.ClassBody) - if (!isMember && isClosed) - throw ScriptError(currentToken.pos, "modifier closed is only allowed for class members") + if (!isMember && isClosed && currentToken.value != "class") + throw ScriptError(currentToken.pos, "modifier closed at top level is only allowed for classes") if (!isMember && isOverride && currentToken.value != "fun" && currentToken.value != "fn") throw ScriptError(currentToken.pos, "modifier override outside class is only allowed for extension functions") @@ -5345,12 +5345,12 @@ class Compiler( "var" -> parseVarDeclaration(true, visibility, isAbstract, isClosed, isOverride, isStatic, isExtern) "fun", "fn" -> parseFunctionDeclaration(visibility, isAbstract, isClosed, isOverride, isExtern, isStatic) "class" -> { - if (isStatic || isClosed || isOverride) + if (isStatic || isOverride) throw ScriptError( currentToken.pos, "unsupported modifiers for class: ${modifiers.joinToString(" ")}" ) - parseClassDeclaration(isAbstract, isExtern) + parseClassDeclaration(isAbstract, isExtern, isClosed) } "object" -> { @@ -5478,8 +5478,11 @@ class Compiler( "type" -> { pendingDeclStart = id.pos pendingDeclDoc = consumePendingDoc() - if (!looksLikeTypeAliasDeclaration()) return null - parseTypeAliasDeclaration() + if (looksLikeTypeAliasDeclaration()) { + parseTypeAliasDeclaration() + } else { + null + } } "try" -> parseTryStatement() @@ -6116,6 +6119,7 @@ class Compiler( startPos = startPos, isExtern = false, isAbstract = false, + isClosed = false, isObject = true, isAnonymous = nameToken == null, baseSpecs = baseSpecs.map { ClassDeclBaseSpec(it.name, it.args) }, @@ -6127,7 +6131,7 @@ class Compiler( return ClassDeclStatement(spec) } - private suspend fun parseClassDeclaration(isAbstract: Boolean = false, isExtern: Boolean = false): Statement { + private suspend fun parseClassDeclaration(isAbstract: Boolean = false, isExtern: Boolean = false, isClosed: Boolean = false): Statement { val nameToken = cc.requireToken(Token.Type.ID) val startPos = pendingDeclStart ?: nameToken.pos val doc = pendingDeclDoc ?: consumePendingDoc() @@ -6474,6 +6478,7 @@ class Compiler( startPos = startPos, isExtern = isExtern, isAbstract = isAbstract, + isClosed = isClosed, isObject = false, isAnonymous = false, baseSpecs = baseSpecs.map { ClassDeclBaseSpec(it.name, it.args) }, diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bridge/BridgeResolver.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bridge/BridgeResolver.kt index 19bc46c..5e459e4 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bridge/BridgeResolver.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bridge/BridgeResolver.kt @@ -1,15 +1,27 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * 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. + * + */ + /* * Kotlin bridge reflection facade: handle-based access for fast get/set/call. */ package net.sergeych.lyng.bridge -import net.sergeych.lyng.Arguments -import net.sergeych.lyng.Pos -import net.sergeych.lyng.Scope -import net.sergeych.lyng.ScopeFacade -import net.sergeych.lyng.canAccessMember -import net.sergeych.lyng.extensionCallableName +import net.sergeych.lyng.* import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.ObjClass import net.sergeych.lyng.obj.ObjIllegalAccessException @@ -20,54 +32,85 @@ import net.sergeych.lyng.obj.ObjString import net.sergeych.lyng.obj.ObjUnset import net.sergeych.lyng.obj.ObjVoid import net.sergeych.lyng.requireScope -import net.sergeych.lyng.ModuleScope -/** Where to resolve names from. */ +/** + * Where a bridge resolver should search for names. + * + * Used by [LookupSpec] to control reflection scope for Kotlin-side tooling and bindings. + */ enum class LookupTarget { + /** Resolve from the current frame only (locals/params declared in the active scope). */ CurrentFrame, + /** Resolve by walking the raw parent chain of frames (locals only, no member fallback). */ ParentChain, + /** Resolve against the module frame (top-level declarations in the module). */ ModuleFrame } -/** Explicit receiver view (like this@Base). */ +/** + * Explicit receiver view, similar to `this@Base` in Lyng. + * + * When provided, the resolver will treat `this` as the requested type + * for member resolution and visibility checks. + */ data class ReceiverView( val type: ObjClass? = null, val typeName: String? = null ) -/** Lookup rules for bridge resolution. */ +/** + * Lookup rules for bridge resolution. + * + * @property targets where to resolve names from + * @property receiverView optional explicit receiver for member lookup (like `this@Base`) + */ data class LookupSpec( val targets: Set = setOf(LookupTarget.CurrentFrame, LookupTarget.ModuleFrame), val receiverView: ReceiverView? = null ) -/** Base handle type. */ +/** + * Base handle type returned by the Kotlin reflection bridge. + * + * Handles are inexpensive to keep and cache; they resolve lazily and + * may internally cache slots/records once a frame is known. + */ sealed interface BridgeHandle { + /** Name of the underlying symbol (as written in Lyng). */ val name: String } -/** Read-only value handle. */ +/** Read-only value handle resolved in a [ScopeFacade]. */ interface ValHandle : BridgeHandle { + /** Read the current value. */ suspend fun get(scope: ScopeFacade): Obj } -/** Read/write value handle. */ +/** Read/write value handle resolved in a [ScopeFacade]. */ interface VarHandle : ValHandle { + /** Assign a new value. */ suspend fun set(scope: ScopeFacade, value: Obj) } /** Callable handle (function/closure/method). */ interface CallableHandle : BridgeHandle { + /** + * Call the target with optional [args]. + * + * @param newThisObj overrides receiver for member calls (defaults to current `this`/record receiver). + */ suspend fun call(scope: ScopeFacade, args: Arguments = Arguments.EMPTY, newThisObj: Obj? = null): Obj } /** Member handle resolved against an instance or receiver view. */ interface MemberHandle : BridgeHandle { + /** Declaring class resolved for the last call/get/set (if known). */ val declaringClass: ObjClass? + /** Explicit receiver view used for resolution (if any). */ val receiverView: ReceiverView? } -/** Member field/property. */ +/** Member field/property (read-only). */ interface MemberValHandle : MemberHandle, ValHandle /** Member var/property with write access. */ @@ -76,41 +119,64 @@ interface MemberVarHandle : MemberHandle, VarHandle /** Member callable (method or extension). */ interface MemberCallableHandle : MemberHandle, CallableHandle -/** Direct record handle (debug/inspection). */ +/** + * Direct record handle (debug/inspection). + * + * Exposes raw [ObjRecord] access and should be used only in tooling. + */ interface RecordHandle : BridgeHandle { + /** Resolve and return the raw [ObjRecord]. */ fun record(): ObjRecord } -/** Bridge resolver API (entry point for Kotlin bindings). */ +/** + * Bridge resolver API (entry point for Kotlin reflection and bindings). + * + * Obtain via [ScopeFacade.resolver] and reuse for multiple lookups. + * Resolver methods return handles that can be cached and reused across calls. + */ interface BridgeResolver { + /** Source position used for error reporting. */ val pos: Pos + /** Treat `this` as [type] for member lookup (like `this@Type`). */ fun selfAs(type: ObjClass): BridgeResolver + /** Treat `this` as [typeName] for member lookup (like `this@Type`). */ fun selfAs(typeName: String): BridgeResolver + /** Resolve a read-only value by name using [lookup]. */ fun resolveVal(name: String, lookup: LookupSpec = LookupSpec()): ValHandle + /** Resolve a mutable value by name using [lookup]. */ fun resolveVar(name: String, lookup: LookupSpec = LookupSpec()): VarHandle + /** Resolve a callable by name using [lookup]. */ fun resolveCallable(name: String, lookup: LookupSpec = LookupSpec()): CallableHandle + /** Resolve a member value on [receiver]. */ fun resolveMemberVal( receiver: Obj, name: String, lookup: LookupSpec = LookupSpec() ): MemberValHandle + /** Resolve a mutable member on [receiver]. */ fun resolveMemberVar( receiver: Obj, name: String, lookup: LookupSpec = LookupSpec() ): MemberVarHandle + /** Resolve a member callable on [receiver]. */ fun resolveMemberCallable( receiver: Obj, name: String, lookup: LookupSpec = LookupSpec() ): MemberCallableHandle - /** Extension function treated as a member for reflection. */ + /** + * Resolve an extension function treated as a member for reflection. + * + * This uses the extension wrapper name (same rules as Lyng compiler). + */ fun resolveExtensionCallable( receiverClass: ObjClass, name: String, @@ -119,14 +185,20 @@ interface BridgeResolver { /** Debug: resolve locals by name (optional, for tooling). */ fun resolveLocalVal(name: String): ValHandle + /** Debug: resolve mutable locals by name (optional, for tooling). */ fun resolveLocalVar(name: String): VarHandle /** Debug: access raw record handles if needed. */ fun resolveRecord(name: String, lookup: LookupSpec = LookupSpec()): RecordHandle } -/** Convenience: call by name with implicit caching in resolver implementation. */ +/** + * Convenience: call by name with implicit caching in resolver implementation. + * + * Implemented by the default resolver; useful for lightweight call-by-name flows. + */ interface BridgeCallByName { + /** Resolve and call [name] with [args] using [lookup]. */ suspend fun callByName( scope: ScopeFacade, name: String, @@ -135,12 +207,21 @@ interface BridgeCallByName { ): Obj } -/** Optional typed wrappers (sugar). */ +/** + * Optional typed wrapper (sugar) around [ValHandle]. + * + * Performs a runtime cast to [T] and raises a class cast error on mismatch. + */ interface TypedHandle : ValHandle { + /** Read value and cast it to [T]. */ suspend fun getTyped(scope: ScopeFacade): T } -/** Factory for bridge resolver. */ +/** + * Factory for bridge resolver. + * + * Prefer this over ad-hoc lookups when writing Kotlin extensions or tooling. + */ fun ScopeFacade.resolver(): BridgeResolver = BridgeResolverImpl(this) private class BridgeResolverImpl( diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bridge/ClassBridge.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bridge/ClassBridge.kt index 93279f8..8e85e11 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bridge/ClassBridge.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bridge/ClassBridge.kt @@ -1,13 +1,28 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * 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. + * + */ + /* * Kotlin bridge bindings for Lyng classes (Lyng-first workflow). */ package net.sergeych.lyng.bridge -import net.sergeych.lyng.Arguments -import net.sergeych.lyng.Pos -import net.sergeych.lyng.ScopeFacade -import net.sergeych.lyng.Script +import net.sergeych.lyng.* +import net.sergeych.lyng.bytecode.BytecodeStatement import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.ObjClass import net.sergeych.lyng.obj.ObjExternCallable @@ -16,24 +31,41 @@ import net.sergeych.lyng.obj.ObjProperty import net.sergeych.lyng.obj.ObjRecord import net.sergeych.lyng.obj.ObjVoid import net.sergeych.lyng.pacman.ImportManager -import net.sergeych.lyng.ModuleScope -import net.sergeych.lyng.ScriptError import net.sergeych.lyng.requiredArg -import net.sergeych.lyng.InstanceFieldInitStatement -import net.sergeych.lyng.Statement -import net.sergeych.lyng.bytecode.BytecodeStatement +/** + * Per-instance bridge context passed to init hooks. + * + * Exposes the underlying [instance] and a mutable [data] slot for Kotlin-side state. + */ interface BridgeInstanceContext { + /** The Lyng instance being initialized. */ val instance: Obj + /** Arbitrary Kotlin-side data attached to the instance. */ var data: Any? } +/** + * Binder DSL for attaching Kotlin implementations to a declared Lyng class. + * + * Use [LyngClassBridge.bind] to obtain a binder and register implementations. + * Bindings must happen before the first instance of the class is created. + * + * Important: members you bind here must be declared as `extern` in Lyng so the + * compiler emits the ABI slots that Kotlin bindings attach to. + */ interface ClassBridgeBinder { + /** Arbitrary Kotlin-side data attached to the class. */ var classData: Any? + /** Register an initialization hook that runs for each instance. */ fun init(block: suspend BridgeInstanceContext.(ScopeFacade) -> Unit) + /** Register an initialization hook with direct access to the instance. */ fun initWithInstance(block: suspend (ScopeFacade, Obj) -> Unit) + /** Bind a Lyng function/member to a Kotlin implementation (requires `extern` in Lyng). */ fun addFun(name: String, impl: suspend (ScopeFacade, Obj, Arguments) -> Obj) + /** Bind a read-only member (val/property getter) declared as `extern`. */ fun addVal(name: String, impl: suspend (ScopeFacade, Obj) -> Obj) + /** Bind a mutable member (var/property getter/setter) declared as `extern`. */ fun addVar( name: String, get: suspend (ScopeFacade, Obj) -> Obj, @@ -41,7 +73,20 @@ interface ClassBridgeBinder { ) } +/** + * Entry point for Kotlin bindings to declared Lyng classes. + * + * The workflow is Lyng-first: declare the class and its members in Lyng, + * then bind the implementations from Kotlin. Bound members must be marked + * `extern` so the compiler emits the ABI slots for Kotlin to attach to. + */ object LyngClassBridge { + /** + * Resolve a Lyng class by [className] and bind Kotlin implementations. + * + * @param module module name used for resolution (required when [module] scope is not provided) + * @param importManager import manager used to resolve the module + */ suspend fun bind( className: String, module: String? = null, @@ -52,6 +97,9 @@ object LyngClassBridge { return bind(cls, block) } + /** + * Resolve a Lyng class within an existing [moduleScope] and bind Kotlin implementations. + */ suspend fun bind( moduleScope: ModuleScope, className: String, @@ -61,6 +109,11 @@ object LyngClassBridge { return bind(cls, block) } + /** + * Bind Kotlin implementations to an already resolved [clazz]. + * + * This must run before the first instance is created. + */ fun bind(clazz: ObjClass, block: ClassBridgeBinder.() -> Unit): ObjClass { val binder = ClassBridgeBinderImpl(clazz) binder.block() @@ -69,10 +122,22 @@ object LyngClassBridge { } } +/** + * Sugar for [LyngClassBridge.bind] on a module scope. + * + * Bound members must be declared as `extern` in Lyng. + */ +suspend fun ModuleScope.bind( + className: String, + block: ClassBridgeBinder.() -> Unit +): ObjClass = LyngClassBridge.bind(this, className, block) + +/** Kotlin-side data slot attached to a Lyng instance. */ var ObjInstance.data: Any? get() = kotlinInstanceData set(value) { kotlinInstanceData = value } +/** Kotlin-side data slot attached to a Lyng class. */ var ObjClass.classData: Any? get() = kotlinClassData set(value) { kotlinClassData = value } diff --git a/lynglib/src/commonTest/kotlin/ClosedClassTest.kt b/lynglib/src/commonTest/kotlin/ClosedClassTest.kt new file mode 100644 index 0000000..e9ebe87 --- /dev/null +++ b/lynglib/src/commonTest/kotlin/ClosedClassTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com + * + * 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. + * + */ + + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.Script +import net.sergeych.lyng.ScriptError +import kotlin.test.Test +import kotlin.test.assertFailsWith + +class ClosedClassTest { + @Test + fun testClosedClass() = runTest { + val scope = Script.newScope() + scope.eval(""" + closed class MyClosedClass { + fun foo() = 42 + } + """.trimIndent()) + + assertFailsWith { + scope.eval(""" + class SubClass : MyClosedClass() + """.trimIndent()) + } + } + + @Test + fun testStdlibClosedClasses() = runTest { + val scope = Script.newScope() + + assertFailsWith { + scope.eval("class MyInt : Int()") + } + assertFailsWith { + scope.eval("class MyReal : Real()") + } + assertFailsWith { + scope.eval("class MyString : String()") + } + assertFailsWith { + scope.eval("class MyBool : Bool()") + } + } +}