diff --git a/README.md b/README.md index 95f1e69..c0b11e9 100644 --- a/README.md +++ b/README.md @@ -76,7 +76,7 @@ Now you can import lyng and use it: ### Execute script: ```kotlin -import net.sergeyh.lyng.* +import net.sergeych.lyng.* // we need a coroutine to start, as Lyng // is a coroutine based language, async topdown @@ -92,9 +92,7 @@ Script is executed over some `Scope`. Create instance, add your specific vars and functions to it, and call: ```kotlin - -import com.sun.source.tree.Scope -import new.sergeych.lyng.* +import net.sergeych.lyng.* // simple function val scope = Script.newScope().apply { diff --git a/docs/embedding.md b/docs/embedding.md index 6fc6af0..5869017 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -286,6 +286,45 @@ val isolated = net.sergeych.lyng.Scope.new() - When registering packages, names must be unique. Register before you compile/evaluate scripts that import them. - To debug scope content, `scope.toString()` and `scope.trace()` can help during development. +### 12) Handling and serializing exceptions + +When Lyng code throws an exception, it is caught in Kotlin as an `ExecutionError`. This error wraps the actual Lyng `Obj` that was thrown (which could be a built-in `ObjException` or a user-defined `ObjInstance`). + +To simplify handling these objects from Kotlin, several extension methods are provided on the `Obj` class. These methods work uniformly regardless of whether the exception is built-in or user-defined. + +#### Uniform Exception API + +| Method | Description | +| :--- | :--- | +| `obj.isLyngException()` | Returns `true` if the object is an instance of `Exception`. | +| `obj.isInstanceOf("ClassName")` | Returns `true` if the object is an instance of the named Lyng class or its ancestors. | +| `obj.getLyngExceptionMessage(scope)` | Returns the exception message as a Kotlin `String`. | +| `obj.getLyngExceptionString(scope)` | Returns a formatted string including the class name, message, and primary throw site. | +| `obj.getLyngExceptionStackTrace(scope)` | Returns the stack trace as an `ObjList` of `StackTraceEntry`. | +| `obj.getLyngExceptionExtraData(scope)` | Returns the extra data associated with the exception. | +| `obj.raiseAsExecutionError(scope)` | Rethrows the object as a Kotlin `ExecutionError`. | + +#### Example: Serialization and Rethrowing + +You can serialize Lyng exception objects using `Lynon` to transmit them across boundaries and then rethrow them. + +```kotlin +try { + scope.eval("throw MyUserException(404, \"Not Found\")") +} catch (e: ExecutionError) { + // 1. Serialize the Lyng exception object + val encoded: UByteArray = lynonEncodeAny(scope, e.errorObject) + + // ... (transmit 'encoded' byte array) ... + + // 2. Deserialize it back to an Obj in a different context + val decoded: Obj = lynonDecodeAny(scope, encoded) + + // 3. Properly rethrow it on the Kotlin side using the uniform API + decoded.raiseAsExecutionError(scope) +} +``` + --- That’s it. You now have Lyng embedded in your Kotlin app: you can expose your app’s API, evaluate user scripts, and organize your own packages to import from Lyng code. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 27fc70b..37809f0 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -20,6 +20,7 @@ package net.sergeych.lyng import net.sergeych.lyng.Compiler.Companion.compile import net.sergeych.lyng.miniast.* import net.sergeych.lyng.obj.* +import net.sergeych.lyng.pacman.ImportManager import net.sergeych.lyng.pacman.ImportProvider /** @@ -3840,4 +3841,6 @@ class Compiler( } suspend fun eval(code: String) = compile(code).execute() +suspend fun evalNamed(name: String, code: String, importManager: ImportManager = Script.defaultImportManager) = + compile(Source(name,code), importManager).execute() diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt index a227188..1ca5205 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt @@ -67,6 +67,10 @@ open class Obj { someClass == rootObjectType || (someClass is ObjClass && objClass.allImplementingNames.contains(someClass.className)) + fun isInstanceOf(className: String) = + objClass.mro.any { it.className == className } || + objClass.allImplementingNames.contains(className) + suspend fun invokeInstanceMethod(scope: Scope, name: String, vararg args: Obj): Obj = invokeInstanceMethod(scope, name, Arguments(args.toList())) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt index b8e9af7..b633a50 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt @@ -159,20 +159,17 @@ open class ObjException( } val Root = ExceptionClass("Exception").apply { - instanceConstructor = statement { - val msg = args.getOrNull(0) ?: ObjString("Exception") - if (thisObj is ObjInstance) { - (thisObj as ObjInstance).instanceScope.addItem("Exception::message", false, msg) - } - ObjVoid - } instanceInitializers.add(statement { if (thisObj is ObjInstance) { + val msg = get("message")?.value ?: ObjString("Exception") + (thisObj as ObjInstance).instanceScope.addItem("Exception::message", false, msg) + val stack = captureStackTrace(this) (thisObj as ObjInstance).instanceScope.addItem("Exception::stackTrace", false, stack) } ObjVoid }) + instanceConstructor = statement { ObjVoid } addConstDoc( name = "message", value = statement { @@ -331,3 +328,43 @@ class ObjUnsetException(scope: Scope, message: String = "property is unset (not class ObjNotImplementedException(scope: Scope, message: String = "not implemented") : ObjException("NotImplementedException", scope, message) + +/** + * Check if the object is an instance of Lyng Exception class. + */ +fun Obj.isLyngException(): Boolean = isInstanceOf("Exception") + +/** + * Get the exception message. + */ +suspend fun Obj.getLyngExceptionMessage(scope: Scope): String = + invokeInstanceMethod(scope, "message").toString(scope).value + +/** + * Get the exception stack trace. + */ +suspend fun Obj.getLyngExceptionStackTrace(scope: Scope): ObjList = + invokeInstanceMethod(scope, "stackTrace").cast(scope) + +/** + * Get the exception extra data. + */ +suspend fun Obj.getLyngExceptionExtraData(scope: Scope): Obj = + invokeInstanceMethod(scope, "extraData") + +/** + * Get the exception as a formatted string with the primary throw site. + */ +suspend fun Obj.getLyngExceptionString(scope: Scope): String = + invokeInstanceMethod(scope, "toString").toString(scope).value + +/** + * Rethrow this object as a Kotlin [ExecutionError] if it's an exception. + */ +suspend fun Obj.raiseAsExecutionError(scope: Scope?=null): Nothing { + if (this is ObjException) raise() + val sc = scope ?: Script.newScope() + val msg = getLyngExceptionMessage(sc) + val pos = (this as? ObjInstance)?.instanceScope?.pos ?: Pos.builtIn + throw ExecutionError(this, pos, msg) +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt index 4e29ff0..19fbd27 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt @@ -294,10 +294,12 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { // } val serializingVars: Map by lazy { + val metaParams = objClass.constructorMeta?.params?.map { it.name }?.toSet() ?: emptySet() instanceScope.objects.filter { it.value.type.serializable && it.value.type == ObjRecord.Type.Field && - it.value.isMutable + it.value.isMutable && + !metaParams.contains(it.key) } } diff --git a/lynglib/src/commonTest/kotlin/EmbeddingExceptionTest.kt b/lynglib/src/commonTest/kotlin/EmbeddingExceptionTest.kt new file mode 100644 index 0000000..dd90cdc --- /dev/null +++ b/lynglib/src/commonTest/kotlin/EmbeddingExceptionTest.kt @@ -0,0 +1,121 @@ +/* + * 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.obj.* +import net.sergeych.lynon.lynonDecodeAny +import net.sergeych.lynon.lynonEncodeAny +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class EmbeddingExceptionTest { + + @Test + fun testExceptionMessageSerialization() = runTest { + val scope = Script.newScope() + val ex = scope.eval("Exception(\"test message\")") + val encoded = lynonEncodeAny(scope, ex) + val decoded = lynonDecodeAny(scope, encoded) + assertEquals("test message", decoded.getLyngExceptionMessage(scope)) + } + + @Test + fun testIsInstanceOfString() = runTest { + val scope = Script.newScope() + scope.eval("class T") + val t = scope.eval("T()") + assertTrue(t.isInstanceOf("T")) + assertTrue(t.isInstanceOf("Obj")) + } + + @Test + fun testExceptionSerializationAndRethrow() = runTest { + val scope = Script.newScope() + + // 1. Define, throw and catch the exception in Lyng to get the object + val errorObj = scope.eval(""" + class MyException(val code, m) : Exception(m) + try { + throw MyException(123, "something failed") + } catch { + it + } + """.trimIndent()) + + assertTrue(errorObj.isLyngException(), "Should be a Lyng exception") + assertTrue(errorObj.isInstanceOf("MyException"), "Should be a MyException") + + // 2. Serialize it + val encoded = lynonEncodeAny(scope, errorObj) + + // 3. Deserialize it + val decodedObj = lynonDecodeAny(scope, encoded) + + // 4. Rethrow it using the new uniform extension + val rethrown = try { + decodedObj.raiseAsExecutionError(scope) + } catch (e: ExecutionError) { + e + } + + // 5. Verify the rethrown exception preserves its identity and data + val caughtObj = rethrown.errorObject + assertTrue(caughtObj.isLyngException()) + assertTrue(caughtObj.isInstanceOf("MyException")) + assertIs(caughtObj) + assertEquals("MyException", caughtObj.objClass.className) + + // Verify we can still access the custom fields + // 'code' is a field, so we use readField + val code = caughtObj.readField(scope, "code").value.toKotlin(scope) + assertEquals(123L, code) + + val message = caughtObj.getLyngExceptionMessage(scope) + assertEquals("something failed", message) + + // Verify stack trace is available + val stack = caughtObj.getLyngExceptionStackTrace(scope) + assertTrue(stack.list.isNotEmpty(), "Stack trace should not be empty") + + val errorString = caughtObj.getLyngExceptionString(scope) + assertTrue(errorString.contains("MyException: something failed"), "Error string should contain message") + } + + @Test + fun testBuiltInExceptionSerialization() = runTest { + val scope = Script.newScope() + + val encoded = try { + scope.eval("throw IllegalArgumentException(\"bad arg\")") + null + } catch (e: ExecutionError) { + lynonEncodeAny(scope, e.errorObject) + }!! + + val decodedObj = lynonDecodeAny(scope, encoded) + assertTrue(decodedObj.isLyngException()) + assertTrue(decodedObj.isInstanceOf("IllegalArgumentException")) + assertIs(decodedObj) + + val message = decodedObj.getLyngExceptionMessage(scope) + assertEquals("bad arg", message) + } +} diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 17d1881..5be7ae8 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -4558,6 +4558,7 @@ class ScriptTest { val x = try { throw IllegalAccessException("test1") } catch { it } assertEquals("test1", x.message) assert( x is IllegalAccessException) + assert( x is Exception ) assertThrows(IllegalAccessException) { throw IllegalAccessException("test2") } class X : Exception("test3") @@ -4565,6 +4566,7 @@ class ScriptTest { println(y) assertEquals("test3", y.message) assert( y is X) + assert( y is Exception ) """.trimIndent()) } @@ -4601,6 +4603,17 @@ class ScriptTest { assert( x is UserException) val y = try { throw IllegalStateException() } catch { it } assert( y is IllegalStateException) + + // creating exceptions as usual objects: + val z = IllegalArgumentException() + assert( z is Exception ) + assert( z is IllegalArgumentException ) + + class X : Exception + val t = X() + assert( t is X ) + assert( t is Exception ) + """.trimIndent()) } @@ -4635,4 +4648,28 @@ class ScriptTest { assert(caught.message.contains("Expected DerivedEx, got MyEx")) """.trimIndent()) } + + @Test + fun testRaiseAsError() = runTest { + var x = evalNamed( "tc1",""" + IllegalArgumentException("test3") + """.trimIndent()) + var x1 = try { x.raiseAsExecutionError() } catch(e: ExecutionError) { e } + println(x1.message) + assertTrue { "tc1:1" in x1.message!! } + assertTrue { "test3" in x1.message!! } + + // With user exception classes it should be the same at top level: + x = evalNamed("tc2",""" + class E: Exception("test4") + E() + """.trimIndent()) + x1 = try { x.raiseAsExecutionError() } catch(e: ExecutionError) { e } + println(x1.message) + assertContains(x1.message!!, "test4") + // the reported error message should include proper trace, which must include + // source name, in our case, is is "tc2": + assertContains(x1.message!!, "tc2") + } } + diff --git a/site/src/jsMain/kotlin/HomePage.kt b/site/src/jsMain/kotlin/HomePage.kt index 9042878..43756ab 100644 --- a/site/src/jsMain/kotlin/HomePage.kt +++ b/site/src/jsMain/kotlin/HomePage.kt @@ -1,5 +1,5 @@ /* - * Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com + * 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. @@ -100,6 +100,14 @@ val worker = object : Runnable { override fun run() = println("Working...") } worker.run() + +// User-defined exceptions: real classes with custom fields +class MyError(val code, m) : Exception(m) +try { + throw MyError(500, "Something failed") +} catch (e: MyError) { + println("Error " + e.code + ": " + e.message) +} >>> void """.trimIndent() val mapHtml = "
" + htmlEscape(code) + "
"