Add support for closed classes and enhancements to the Kotlin reflection bridge

This commit is contained in:
Sergey Chernov 2026-02-16 19:08:32 +03:00
parent 7f2f99524f
commit c5b8871c3a
7 changed files with 327 additions and 36 deletions

View File

@ -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<ObjInt>(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.

View File

@ -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.

View File

@ -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<ClassDeclBaseSpec>,
@ -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) {

View File

@ -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
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) },

View File

@ -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<LookupTarget> = 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<T : Obj> : 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(

View File

@ -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 }

View File

@ -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<ScriptError> {
scope.eval("""
class SubClass : MyClosedClass()
""".trimIndent())
}
}
@Test
fun testStdlibClosedClasses() = runTest {
val scope = Script.newScope()
assertFailsWith<ScriptError> {
scope.eval("class MyInt : Int()")
}
assertFailsWith<ScriptError> {
scope.eval("class MyReal : Real()")
}
assertFailsWith<ScriptError> {
scope.eval("class MyString : String()")
}
assertFailsWith<ScriptError> {
scope.eval("class MyBool : Bool()")
}
}
}