diff --git a/README.md b/README.md index a84eca4..95f1e69 100644 --- a/README.md +++ b/README.md @@ -173,6 +173,7 @@ Ready features: - [x] ranges, lists, strings, interfaces: Iterable, Iterator, Collection, Array - [x] when(value), if-then-else - [x] exception handling: throw, try-catch-finally, exception classes. +- [x] user-defined exception classes - [x] multiplatform maven publication - [x] documentation for the current state - [x] maps, sets and sequences (flows?) @@ -196,6 +197,7 @@ Ready features: - [x] late-init vals in classes - [x] properties with getters and setters - [x] assign-if-null operator `?=` +- [x] user-defined exception classes All of this is documented in the [language site](https://lynglang.com) and locally [docs/language.md](docs/tutorial.md). the current nightly builds published on the site and in the private maven repository. diff --git a/docs/OOP.md b/docs/OOP.md index 9eba039..891c726 100644 --- a/docs/OOP.md +++ b/docs/OOP.md @@ -720,6 +720,23 @@ Notes and limitations (current version): - `name` and `ordinal` are read‑only properties of an entry. - `entries` is a read‑only list owned by the enum type. +## Exception Classes + +You can define your own exception classes by inheriting from the built-in `Exception` class. User-defined exceptions are regular classes and can have their own properties and methods. + +```lyng +class MyError(val code, m) : Exception(m) + +try { + throw MyError(500, "Internal Server Error") +} +catch(e: MyError) { + println("Error " + e.code + ": " + e.message) +} +``` + +For more details on error handling, see the [Exceptions Handling Guide](exceptions_handling.md). + ## fields and visibility It is possible to add non-constructor fields: diff --git a/docs/exceptions_handling.md b/docs/exceptions_handling.md index 1f08fef..58725c8 100644 --- a/docs/exceptions_handling.md +++ b/docs/exceptions_handling.md @@ -128,9 +128,9 @@ Serializable class that conveys information about the exception. Important membe | name | description | |-------------------|--------------------------------------------------------| -| message | String message | -| stackTrace | lyng stack trace, list of `StackTraceEntry`, see below | -| printStackTrace() | format and print stack trace using println() | +| message | String message | +| stackTrace() | lyng stack trace, list of `StackTraceEntry`, see below | +| printStackTrace() | format and print stack trace using println() | ## StackTraceEntry @@ -150,24 +150,103 @@ class StackTraceEntry( # Custom error classes -_this functionality is not yet released_ +You can define your own exception classes by inheriting from the built-in `Exception` class. This allows you to create specific error types for your application logic and catch them specifically. + +## Defining a custom exception + +To define a custom exception, create a class that inherits from `Exception`: + +```lyng +class MyUserException : Exception("something went wrong") +``` + +You can also pass the message dynamically: + +```lyng +class MyUserException(m) : Exception(m) + +throw MyUserException("custom error message") +``` + +If you don't provide a message to the `Exception` constructor, the class name will be used as the default message: + +```lyng +class SimpleException : Exception + +val e = SimpleException() +assertEquals("SimpleException", e.message) +``` + +## Throwing and catching custom exceptions + +Custom exceptions are thrown using the `throw` keyword and can be caught using `catch` blocks, just like standard exceptions: + +```lyng +class ValidationException(m) : Exception(m) + +try { + throw ValidationException("Invalid input") +} +catch(e: ValidationException) { + println("Caught validation error: " + e.message) +} +catch(e: Exception) { + println("Caught other exception: " + e.message) +} +``` + +Since user exceptions are real classes, inheritance works as expected: + +```lyng +class BaseError : Exception +class DerivedError : BaseError + +try { + throw DerivedError() +} +catch(e: BaseError) { + // This will catch DerivedError as well + assert(e is DerivedError) +} +``` + +## Accessing extra data + +You can add your own fields to custom exception classes to carry additional information: + +```lyng +class NetworkException(m, val statusCode) : Exception(m) + +try { + throw NetworkException("Not Found", 404) +} +catch(e: NetworkException) { + println("Error " + e.statusCode + ": " + e.message) +} +``` # Standard exception classes | class | notes | |----------------------------|-------------------------------------------------------| -| Exception | root of al throwable objects | +| Exception | root of all throwable objects | | NullReferenceException | | | AssertionFailedException | | | ClassCastException | | | IndexOutOfBoundsException | | | IllegalArgumentException | | +| IllegalStateException | | +| NoSuchElementException | | | IllegalAssignmentException | assigning to val, etc. | | SymbolNotDefinedException | | | IterationEndException | attempt to read iterator past end, `hasNext == false` | | IllegalAccessException | attempt to access private members or like | -| UnknownException | unexpected kotlin exception caught | -| | | +| UnknownException | unexpected internal exception caught | +| NotFoundException | | +| IllegalOperationException | | +| UnsetException | access to uninitialized late-init val | +| NotImplementedException | used by `TODO()` | +| SyntaxError | | ### Symbol resolution errors diff --git a/docs/whats_new.md b/docs/whats_new.md index 69f7f2d..a069007 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -139,6 +139,20 @@ var name by Observable("initial") { n, old, new -> The system features a unified interface (`getValue`, `setValue`, `invoke`) and a `bind` hook for initialization-time validation and configuration. See the [Delegation Guide](delegation.md) for more. +### User-Defined Exception Classes +You can now create custom exception types by inheriting from the built-in `Exception` class. Custom exceptions are real classes that can have their own fields and methods, and they work seamlessly with `throw` and `try-catch` blocks. + +```lyng +class ValidationException(val field, m) : Exception(m) + +try { + throw ValidationException("email", "Invalid format") +} +catch(e: ValidationException) { + println("Error in " + e.field + ": " + e.message) +} +``` + ### Assign-if-null Operator (`?=`) The new `?=` operator provides a concise way to assign a value only if the target is `null`. It is especially useful for setting default values or lazy initialization. diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index a0811a0..27fc70b 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -1719,19 +1719,25 @@ class Compiler( var errorObject = throwStatement.execute(sc) // Rebind error scope to the throw-site position so ScriptError.pos is accurate val throwScope = sc.createChildScope(pos = start) - errorObject = when (errorObject) { - is ObjString -> ObjException(throwScope, errorObject.value) - is ObjException -> ObjException( + if (errorObject is ObjString) { + errorObject = ObjException(throwScope, errorObject.value) + } + if (!errorObject.isInstanceOf(ObjException.Root)) { + throwScope.raiseError("this is not an exception object: $errorObject") + } + if (errorObject is ObjException) { + errorObject = ObjException( errorObject.exceptionClass, throwScope, errorObject.message, errorObject.extraData, errorObject.useStackTrace ) - - else -> throwScope.raiseError("this is not an exception object: $errorObject") + throwScope.raiseError(errorObject) + } else { + val msg = errorObject.invokeInstanceMethod(sc, "message").toString(sc).value + throwScope.raiseError(errorObject, start, msg) } - throwScope.raiseError(errorObject) } } @@ -1814,25 +1820,25 @@ class Compiler( throw e } catch (e: Exception) { // convert to appropriate exception - val objException = when (e) { + val caughtObj = when (e) { is ExecutionError -> e.errorObject else -> ObjUnknownException(this, e.message ?: e.toString()) } // let's see if we should catch it: var isCaught = false for (cdata in catches) { - var exceptionObject: ObjException? = null + var match: Obj? = null for (exceptionClassName in cdata.classNames) { - val exObj = ObjException.getErrorClass(exceptionClassName) - ?: raiseSymbolNotFound("error clas not exists: $exceptionClassName") - if (objException.isInstanceOf(exObj)) { - exceptionObject = objException + val exObj = this[exceptionClassName]?.value as? ObjClass + ?: raiseSymbolNotFound("error class does not exist or is not a class: $exceptionClassName") + if (caughtObj.isInstanceOf(exObj)) { + match = caughtObj break } } - if (exceptionObject != null) { + if (match != null) { val catchContext = this.createChildScope(pos = cdata.catchVar.pos) - catchContext.addItem(cdata.catchVar.value, false, objException) + catchContext.addItem(cdata.catchVar.value, false, caughtObj) result = cdata.block.execute(catchContext) isCaught = true break diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index 8bf79de..ff98401 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -277,16 +277,22 @@ open class Scope( raiseError(ObjSymbolNotDefinedException(this, "symbol is not defined: $name")) fun raiseError(message: String): Nothing { - throw ExecutionError(ObjException(this, message)) + val ex = ObjException(this, message) + throw ExecutionError(ex, pos, ex.message.value) } fun raiseError(obj: ObjException): Nothing { - throw ExecutionError(obj) + throw ExecutionError(obj, obj.scope.pos, obj.message.value) + } + + fun raiseError(obj: Obj, pos: Pos, message: String): Nothing { + throw ExecutionError(obj, pos, message) } @Suppress("unused") fun raiseNotFound(message: String = "not found"): Nothing { - throw ExecutionError(ObjNotFoundException(this, message)) + val ex = ObjNotFoundException(this, message) + throw ExecutionError(ex, ex.scope.pos, ex.message.value) } inline fun requiredArg(index: Int): T { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt index 0fe4726..97a743b 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt @@ -248,10 +248,9 @@ class Script( ) ) expectedClass?.let { - if (result !is ObjException) - raiseError("Expected $expectedClass, got non-lyng exception $result") - if (result.exceptionClass != expectedClass) { - raiseError("Expected $expectedClass, got ${result.exceptionClass}") + if (!result.isInstanceOf(it)) { + val actual = if (result is ObjException) result.exceptionClass else result.objClass + raiseError("Expected $it, got $actual") } } result diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ScriptError.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ScriptError.kt index 9c93de6..ad83801 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ScriptError.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ScriptError.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. @@ -19,7 +19,7 @@ package net.sergeych.lyng -import net.sergeych.lyng.obj.ObjException +import net.sergeych.lyng.obj.Obj open class ScriptError(val pos: Pos, val errorMessage: String, cause: Throwable? = null) : Exception( """ @@ -33,6 +33,6 @@ open class ScriptError(val pos: Pos, val errorMessage: String, cause: Throwable? class ScriptFlowIsNoMoreCollected: Exception() -class ExecutionError(val errorObject: ObjException) : ScriptError(errorObject.scope.pos, errorObject.message.value) +class ExecutionError(val errorObject: Obj, pos: Pos, message: String) : ScriptError(pos, message) class ImportException(pos: Pos, message: String) : ScriptError(pos, message) \ No newline at end of file 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 10e591a..b8e9af7 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjException.kt @@ -41,7 +41,7 @@ open class ObjException( val exceptionClass: ExceptionClass, val scope: Scope, val message: ObjString, - @Suppress("unused") val extraData: Obj = ObjNull, + val extraData: Obj = ObjNull, val useStackTrace: ObjList? = null ) : Obj() { constructor(name: String, scope: Scope, message: String) : this( @@ -54,37 +54,14 @@ open class ObjException( suspend fun getStackTrace(): ObjList { return cachedStackTrace.get { - val result = ObjList() - val maybeCls = scope.get("StackTraceEntry")?.value as? ObjClass - var s: Scope? = scope - var lastPos: Pos? = null - while (s != null) { - val pos = s.pos - if (pos != lastPos && !pos.currentLine.isEmpty()) { - if (maybeCls != null) { - result.list += maybeCls.callWithArgs( - scope, - pos.source.objSourceName, - ObjInt(pos.line.toLong()), - ObjInt(pos.column.toLong()), - ObjString(pos.currentLine) - ) - } else { - // Fallback textual entry if StackTraceEntry class is not available in this scope - result.list += ObjString("${pos.source.objSourceName}:${pos.line}:${pos.column}: ${pos.currentLine}") - } - } - s = s.parent - lastPos = pos - } - result + captureStackTrace(scope) } } constructor(scope: Scope, message: String) : this(Root, scope, ObjString(message)) fun raise(): Nothing { - throw ExecutionError(this) + throw ExecutionError(this, scope.pos, message.value) } override val objClass: ObjClass = exceptionClass @@ -116,7 +93,41 @@ open class ObjException( companion object { + suspend fun captureStackTrace(scope: Scope): ObjList { + val result = ObjList() + val maybeCls = scope.get("StackTraceEntry")?.value as? ObjClass + var s: Scope? = scope + var lastPos: Pos? = null + while (s != null) { + val pos = s.pos + if (pos != lastPos && !pos.currentLine.isEmpty()) { + if (maybeCls != null) { + result.list += maybeCls.callWithArgs( + scope, + pos.source.objSourceName, + ObjInt(pos.line.toLong()), + ObjInt(pos.column.toLong()), + ObjString(pos.currentLine) + ) + } else { + // Fallback textual entry if StackTraceEntry class is not available in this scope + result.list += ObjString("${pos.source.objSourceName}:${pos.line}:${pos.column}: ${pos.currentLine}") + } + } + s = s.parent + lastPos = pos + } + return result + } + class ExceptionClass(val name: String, vararg parents: ObjClass) : ObjClass(name, *parents) { + init { + constructorMeta = ArgsDeclaration( + listOf(ArgsDeclaration.Item("message", defaultValue = statement { ObjString(name) })), + Token.Type.RPAREN + ) + } + override suspend fun callOn(scope: Scope): Obj { val message = scope.args.getOrNull(0)?.toString(scope) ?: ObjString(name) return ObjException(this, scope, message) @@ -148,22 +159,77 @@ 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 stack = captureStackTrace(this) + (thisObj as ObjInstance).instanceScope.addItem("Exception::stackTrace", false, stack) + } + ObjVoid + }) addConstDoc( name = "message", value = statement { - (thisObj as ObjException).message.toObj() + when (val t = thisObj) { + is ObjException -> t.message + is ObjInstance -> t.instanceScope.get("Exception::message")?.value ?: ObjNull + else -> ObjNull + } }, doc = "Human‑readable error message.", type = type("lyng.String"), moduleName = "lyng.stdlib" ) + addConstDoc( + name = "extraData", + value = statement { + when (val t = thisObj) { + is ObjException -> t.extraData + else -> ObjNull + } + }, + doc = "Extra data associated with the exception.", + type = type("lyng.Any", nullable = true), + moduleName = "lyng.stdlib" + ) addFnDoc( name = "stackTrace", doc = "Stack trace captured at throw site as a list of `StackTraceEntry`.", returns = TypeGenericDoc(type("lyng.List"), listOf(type("lyng.StackTraceEntry"))), moduleName = "lyng.stdlib" ) { - (thisObj as ObjException).getStackTrace() + when (val t = thisObj) { + is ObjException -> t.getStackTrace() + is ObjInstance -> t.instanceScope.get("Exception::stackTrace")?.value as? ObjList ?: ObjList() + else -> ObjList() + } + } + addFnDoc( + name = "toString", + doc = "Human‑readable string representation of the error.", + returns = type("lyng.String"), + moduleName = "lyng.stdlib" + ) { + val msg = when (val t = thisObj) { + is ObjException -> t.message.value + is ObjInstance -> (t.instanceScope.get("Exception::message")?.value as? ObjString)?.value + ?: t.objClass.className + + else -> t.objClass.className + } + val stack = when (val t = thisObj) { + is ObjException -> t.getStackTrace() + is ObjInstance -> t.instanceScope.get("Exception::stackTrace")?.value as? ObjList ?: ObjList() + else -> ObjList() + } + val at = stack.list.firstOrNull()?.toString(this) ?: ObjString("(unknown)") + ObjString("${thisObj.objClass.className}: $msg at $at") } } diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 537460f..17d1881 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -4552,22 +4552,22 @@ class ScriptTest { """.trimIndent()) } -// @Test -// fun testUserClassExceptions() = runTest { -// eval(""" -// val x = try { throw IllegalAccessException("test1") } catch { it } -// assertEquals("test1", x.message) -// assert( x is IllegalAccessException) -// assertThrows(IllegalAccessException) { throw IllegalAccessException("test2") } -// -// class X : Exception("test3") -// val y = try { throw X() } catch { it } -// println(y) -// assertEquals("test3", y.message) -// assert( y is X) -// -// """.trimIndent()) -// } + @Test + fun testUserClassExceptions() = runTest { + eval(""" + val x = try { throw IllegalAccessException("test1") } catch { it } + assertEquals("test1", x.message) + assert( x is IllegalAccessException) + assertThrows(IllegalAccessException) { throw IllegalAccessException("test2") } + + class X : Exception("test3") + val y = try { throw X() } catch { it } + println(y) + assertEquals("test3", y.message) + assert( y is X) + + """.trimIndent()) + } @Test fun testTodo() = runTest { @@ -4592,4 +4592,47 @@ class ScriptTest { """.trimIndent()) } + @Test + fun testUserExceptionClass() = runTest { + eval(""" + class UserException : Exception("user exception") + val x = try { throw UserException() } catch { it } + assertEquals("user exception", x.message) + assert( x is UserException) + val y = try { throw IllegalStateException() } catch { it } + assert( y is IllegalStateException) + """.trimIndent()) + } + + @Test + fun testExceptionToString() = runTest { + eval(""" + class MyEx(m) : Exception(m) + val e = MyEx("custom error") + val s = e.toString() + assert( s.startsWith("MyEx: custom error at ") ) + + val e2 = try { throw e } catch { it } + assert( e2 === e ) + assertEquals("custom error", e2.message) + """.trimIndent()) + } + @Test + fun testAssertThrowsUserException() = runTest { + eval(""" + class MyEx : Exception + class DerivedEx : MyEx + + assertThrows(MyEx) { throw MyEx() } + assertThrows(Exception) { throw MyEx() } + assertThrows(MyEx) { throw DerivedEx() } + + val caught = try { + assertThrows(DerivedEx) { throw MyEx() } + null + } catch { it } + assert(caught != null) + assert(caught.message.contains("Expected DerivedEx, got MyEx")) + """.trimIndent()) + } }