Add API for binding global functions and variables; implement tests and update embedding documentation.

This commit is contained in:
Sergey Chernov 2026-03-07 19:03:52 +03:00
parent d6a535590e
commit 1502a365bf
3 changed files with 492 additions and 18 deletions

View File

@ -4,7 +4,7 @@ Lyng is a tiny, embeddable, Kotlin‑first scripting language. This page shows,
- add Lyng to your build - add Lyng to your build
- create a runtime and execute scripts - create a runtime and execute scripts
- define functions and variables from Kotlin - declare extern globals in Lyng and bind them from Kotlin
- read variable values back in Kotlin - read variable values back in Kotlin
- call Lyng functions from Kotlin - call Lyng functions from Kotlin
- create your own packages and import them in Lyng - create your own packages and import them in Lyng
@ -65,30 +65,74 @@ val run2 = script.execute(scope)
`Scope.eval("...")` is a shortcut that compiles and executes on the given scope. `Scope.eval("...")` is a shortcut that compiles and executes on the given scope.
### 3) Define variables from Kotlin ### 3) Preferred: bind extern globals from Kotlin
To expose data to Lyng, add constants (read‑only) or mutable variables to the scope. All values in Lyng are `Obj` instances; the core types live in `net.sergeych.lyng.obj`. For module-level APIs, the default workflow is:
1. declare globals in Lyng using `extern fun` / `extern val` / `extern var`;
2. bind Kotlin implementation via `ModuleScope.globalBinder()`.
```kotlin ```kotlin
// Read‑only constant import net.sergeych.lyng.bridge.*
scope.addConst("pi", ObjReal(3.14159)) import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjString
// Mutable variable: create or update val im = Script.defaultImportManager.copy()
scope.addOrUpdateItem("counter", ObjInt(0)) im.addPackage("my.api") { module ->
module.eval("""
extern fun globalFun(v: Int): Int
extern var globalProp: String
extern val globalVersion: String
""".trimIndent())
// Use it from Lyng val binder = module.globalBinder()
scope.eval("counter = counter + 1")
binder.bindGlobalFun1<Int>("globalFun") { v ->
ObjInt.of((v + 1).toLong())
}
var prop = "initial"
binder.bindGlobalVar(
name = "globalProp",
get = { prop },
set = { prop = it }
)
binder.bindGlobalVar(
name = "globalVersion",
get = { "1.0.0" } // readonly: setter omitted
)
}
``` ```
Tip: Lyng values can be converted back to Kotlin with `toKotlin(scope)`: Usage from Lyng:
```lyng
import my.api
assertEquals(42, globalFun(41))
assertEquals("initial", globalProp)
globalProp = "changed"
assertEquals("changed", globalProp)
assertEquals("1.0.0", globalVersion)
```
For custom argument handling and full runtime access:
```kotlin ```kotlin
val current = (scope.eval("counter")).toKotlin(scope) // Any? (e.g., Int/Double/String/List) binder.bindGlobalFun("sum3") {
requireExactCount(3)
ObjInt.of((int(0) + int(1) + int(2)).toLong())
}
binder.bindGlobalFunRaw("echoRaw") { _, args ->
args.firstAndOnly()
}
``` ```
### 4) Add Kotlin‑backed functions ### 4) Low-level: direct functions/variables from Kotlin
Use `Scope.addFn`/`addVoidFn` to register functions implemented in Kotlin. Inside the lambda, use `this.args` to access arguments and return an `Obj`. Use this when you intentionally want raw `Scope` APIs. For most module APIs, prefer section 3.
```kotlin ```kotlin
// A function returning value // A function returning value
@ -280,6 +324,22 @@ Notes:
- Members must be marked `extern` so the compiler emits ABI slots for Kotlin bindings. - Members must be marked `extern` so the compiler emits ABI slots for Kotlin bindings.
- You can also bind by name/module via `LyngObjectBridge.bind(...)`. - You can also bind by name/module via `LyngObjectBridge.bind(...)`.
Minimal `extern fun` example:
```kotlin
val moduleScope = importManager.createModuleScope(Pos.builtIn, "bridge.ping")
moduleScope.eval("""
extern object HostObject {
extern fun ping(): Int
}
""".trimIndent())
moduleScope.bindObject("HostObject") {
addFun("ping") { _, _, _ -> ObjInt.of(7) }
}
```
### 6.6) Preferred: Kotlin reflection bridge for call‑by‑name ### 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. For Kotlin code that needs dynamic access to Lyng variables, functions, or members, use the bridge resolver.
@ -368,6 +428,9 @@ Key concepts:
Register a Kotlin‑built package: Register a Kotlin‑built package:
```kotlin ```kotlin
import net.sergeych.lyng.bridge.*
import net.sergeych.lyng.obj.ObjInt
val scope = Script.newScope() val scope = Script.newScope()
// Access the import manager behind this scope // Access the import manager behind this scope
@ -375,11 +438,19 @@ val im: ImportManager = scope.importManager
// Register a package "my.tools" // Register a package "my.tools"
im.addPackage("my.tools") { module: ModuleScope -> im.addPackage("my.tools") { module: ModuleScope ->
// Expose symbols inside the module scope module.eval(
module.addConst("version", ObjString("1.0")) """
module.addFn<ObjInt>("triple") { extern val version: String
val x = args.firstAndOnly() as ObjInt extern fun triple(x: Int): Int
ObjInt(x.value * 3) """.trimIndent()
)
val binder = module.globalBinder()
binder.bindGlobalVar(
name = "version",
get = { "1.0" }
)
binder.bindGlobalFun1<Int>("triple") { x ->
ObjInt.of((x * 3).toLong())
} }
} }

View File

@ -0,0 +1,264 @@
/*
* 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.
*
*/
package net.sergeych.lyng.bridge
import net.sergeych.lyng.*
import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjExternCallable
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjProperty
import net.sergeych.lyng.obj.ObjReal
import net.sergeych.lyng.obj.ObjRecord
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.toBool
import net.sergeych.lyng.obj.toDouble
import net.sergeych.lyng.obj.toInt
import net.sergeych.lyng.obj.toLong
import net.sergeych.lyng.obj.toObj
import net.sergeych.lyng.requiredArg
/**
* Global/module-level binding API for Lyng-first extern declarations.
*
* Typical flow:
* 1) declare `extern fun` / `extern val` / `extern var` in Lyng module;
* 2) bind Kotlin implementation using this API.
*/
interface LyngGlobalBinder {
fun bindGlobalFunRaw(
name: String,
fn: suspend (scope: ScopeFacade, args: Arguments) -> Obj
)
fun bindGlobalFun(
name: String,
fn: suspend GlobalArgReader.() -> Obj
)
fun bindGlobalVarRaw(
name: String,
get: suspend (scope: ScopeFacade) -> Obj,
set: (suspend (scope: ScopeFacade, value: Obj) -> Unit)? = null
)
}
/**
* Reader helper for Kotlin-typed argument access.
*/
interface GlobalArgReader {
val scope: ScopeFacade
val args: Arguments
val size: Int
fun requireExactCount(count: Int)
fun obj(index: Int): Obj
fun objOrNull(index: Int): Obj?
fun int(index: Int): Int
fun long(index: Int): Long
fun double(index: Int): Double
fun bool(index: Int): Boolean
fun string(index: Int): String
}
private class ModuleGlobalBinder(
private val module: ModuleScope
) : LyngGlobalBinder {
override fun bindGlobalFunRaw(
name: String,
fn: suspend (scope: ScopeFacade, args: Arguments) -> Obj
) {
val existing = module[name]
val callable = ObjExternCallable.fromBridge {
fn(this, args)
}
module.addItem(
name = name,
isMutable = false,
value = callable,
visibility = existing?.visibility ?: Visibility.Public,
writeVisibility = existing?.writeVisibility,
recordType = ObjRecord.Type.Fun,
callSignature = existing?.callSignature,
typeDecl = existing?.typeDecl
)
}
override fun bindGlobalFun(
name: String,
fn: suspend GlobalArgReader.() -> Obj
) {
bindGlobalFunRaw(name) { scope, args ->
val reader = GlobalArgReaderImpl(scope, args)
fn(reader)
}
}
override fun bindGlobalVarRaw(
name: String,
get: suspend (scope: ScopeFacade) -> Obj,
set: (suspend (scope: ScopeFacade, value: Obj) -> Unit)?
) {
val existing = module[name]
if (existing != null) {
if (existing.isMutable && set == null) {
throw net.sergeych.lyng.ScriptError(Pos.builtIn, "extern var $name requires a setter")
}
if (!existing.isMutable && set != null) {
throw net.sergeych.lyng.ScriptError(Pos.builtIn, "extern val $name does not allow a setter")
}
}
val mutable = existing?.isMutable ?: (set != null)
val getter = ObjExternCallable.fromBridge {
get(this)
}
val setter = set?.let { setterImpl ->
ObjExternCallable.fromBridge {
setterImpl(this, requiredArg(0))
ObjVoid
}
}
module.addItem(
name = name,
isMutable = mutable,
value = ObjProperty(name, getter, setter),
visibility = existing?.visibility ?: Visibility.Public,
writeVisibility = existing?.writeVisibility,
recordType = ObjRecord.Type.Property,
callSignature = existing?.callSignature,
typeDecl = existing?.typeDecl
)
}
}
private class GlobalArgReaderImpl(
override val scope: ScopeFacade,
override val args: Arguments
) : GlobalArgReader {
override val size: Int
get() = args.list.size
override fun requireExactCount(count: Int) {
if (size != count) scope.raiseIllegalArgument("Expected exactly $count arguments, got $size")
}
override fun obj(index: Int): Obj =
objOrNull(index) ?: scope.raiseIllegalArgument("Missing required argument at index $index")
override fun objOrNull(index: Int): Obj? =
args.list.getOrNull(index)
override fun int(index: Int): Int = long(index).toInt()
override fun long(index: Int): Long = obj(index).toLong()
override fun double(index: Int): Double = obj(index).toDouble()
override fun bool(index: Int): Boolean = obj(index).toBool()
override fun string(index: Int): String {
val value = obj(index)
return (value as? ObjString)?.value
?: scope.raiseClassCastError("Expected String at index $index, got ${value.objClass.className}")
}
}
fun ModuleScope.globalBinder(): LyngGlobalBinder = ModuleGlobalBinder(this)
inline fun <reified T> GlobalArgReader.required(index: Int): T =
coerceArg(scope, obj(index), index)
inline fun <reified T> GlobalArgReader.optional(index: Int, default: T): T {
val value = objOrNull(index) ?: return default
return coerceArg(scope, value, index)
}
inline fun <reified A1> LyngGlobalBinder.bindGlobalFun1(
name: String,
noinline fn: suspend (A1) -> Obj
) {
bindGlobalFun(name) {
requireExactCount(1)
fn(required(0))
}
}
inline fun <reified A1, reified A2> LyngGlobalBinder.bindGlobalFun2(
name: String,
noinline fn: suspend (A1, A2) -> Obj
) {
bindGlobalFun(name) {
requireExactCount(2)
fn(required(0), required(1))
}
}
inline fun <reified A1, reified A2, reified A3> LyngGlobalBinder.bindGlobalFun3(
name: String,
noinline fn: suspend (A1, A2, A3) -> Obj
) {
bindGlobalFun(name) {
requireExactCount(3)
fn(required(0), required(1), required(2))
}
}
inline fun <reified T> LyngGlobalBinder.bindGlobalVar(
name: String,
noinline get: suspend () -> T,
noinline set: (suspend (T) -> Unit)? = null
) {
bindGlobalVarRaw(
name = name,
get = { get().toObj() },
set = set?.let { setter ->
{ scope, value ->
setter(coerceArg<T>(scope = scope, value = value, index = 0))
}
}
)
}
@PublishedApi
internal inline fun <reified T> coerceArg(scope: ScopeFacade, value: Obj, index: Int): T {
if (value === ObjNull && null is T) return null as T
(value as? T)?.let { return it }
@Suppress("UNCHECKED_CAST")
return when (T::class) {
Int::class -> value.toInt() as T
Long::class -> value.toLong() as T
Double::class -> value.toDouble() as T
Float::class -> value.toDouble().toFloat() as T
Boolean::class -> value.toBool() as T
String::class -> (value as? ObjString)?.value as? T
?: scope.raiseClassCastError("Expected String at index $index, got ${value.objClass.className}")
Obj::class -> value as T
ObjInt::class -> (value as? ObjInt) as? T
?: scope.raiseClassCastError("Expected ObjInt at index $index, got ${value.objClass.className}")
ObjString::class -> (value as? ObjString) as? T
?: scope.raiseClassCastError("Expected ObjString at index $index, got ${value.objClass.className}")
ObjReal::class -> (value as? ObjReal) as? T
?: scope.raiseClassCastError("Expected ObjReal at index $index, got ${value.objClass.className}")
ObjBool::class -> (value as? ObjBool) as? T
?: scope.raiseClassCastError("Expected ObjBool at index $index, got ${value.objClass.className}")
else -> scope.raiseClassCastError("Unsupported typed argument binding for ${T::class.simpleName}")
}
}

View File

@ -0,0 +1,139 @@
/*
* 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.
*
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.bridge.bindGlobalFun1
import net.sergeych.lyng.bridge.bindGlobalFun3
import net.sergeych.lyng.bridge.bindGlobalVar
import net.sergeych.lyng.bridge.globalBinder
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjString
import kotlin.test.Test
import kotlin.test.assertTrue
class GlobalBindingTest {
@Test
fun testPackageGlobalFunAndVarBinding() = runTest {
val im = Script.defaultImportManager.copy()
var prop = "initial"
im.addPackage("bridge.globals") { module ->
module.eval(
"""
extern fun globalFun(v: Int): Int
extern fun join3(a: String, b: String, c: String): String
extern var globalProp: String
extern val answer: Int
""".trimIndent()
)
val binder = module.globalBinder()
binder.bindGlobalFun1<Int>("globalFun") { v ->
ObjInt.of((v + 10).toLong())
}
binder.bindGlobalFun3<String, String, String>("join3") { a, b, c ->
ObjString(a + b + c)
}
binder.bindGlobalVar(
name = "globalProp",
get = { prop },
set = { prop = it }
)
binder.bindGlobalVar(
name = "answer",
get = { 42 }
)
}
val scope = im.newStdScope()
scope.eval(
"""
import bridge.globals
assertEquals(15, globalFun(5))
assertEquals("abc", join3("a", "b", "c"))
assertEquals("initial", globalProp)
globalProp = "changed"
assertEquals("changed", globalProp)
assertEquals(42, answer)
""".trimIndent()
)
}
@Test
fun testPackageGlobalRawAndArgReaderBinding() = runTest {
val im = Script.defaultImportManager.copy()
im.addPackage("bridge.raw") { module ->
module.eval(
"""
extern fun sum3(a: Int, b: Int, c: Int): Int
extern fun echoRaw(x: Int): Int
""".trimIndent()
)
val binder = module.globalBinder()
binder.bindGlobalFun("sum3") {
requireExactCount(3)
ObjInt.of((int(0) + int(1) + int(2)).toLong())
}
binder.bindGlobalFunRaw("echoRaw") { _, args ->
args.firstAndOnly()
}
}
val scope = im.newStdScope()
scope.eval(
"""
import bridge.raw
assertEquals(6, sum3(1, 2, 3))
assertEquals(77, echoRaw(77))
""".trimIndent()
)
}
@Test
fun testGlobalVarExternCompatibilityChecks() = runTest {
val im = Script.defaultImportManager.copy()
im.addPackage("bridge.compat") { module ->
module.eval(
"""
extern var needsSetter: String
extern val noSetterAllowed: String
""".trimIndent()
)
val binder = module.globalBinder()
val missingSetter = try {
binder.bindGlobalVar(
name = "needsSetter",
get = { "x" }
)
false
} catch (_: ScriptError) {
true
}
val readonlySetter = try {
binder.bindGlobalVar(
name = "noSetterAllowed",
get = { "x" },
set = { _ -> }
)
false
} catch (_: ScriptError) {
true
}
assertTrue(missingSetter)
assertTrue(readonlySetter)
}
}
}