diff --git a/LYNG_AI_SPEC.md b/LYNG_AI_SPEC.md index 91528a3..5d69fff 100644 --- a/LYNG_AI_SPEC.md +++ b/LYNG_AI_SPEC.md @@ -14,6 +14,7 @@ High-density specification for LLMs. Reference this for all Lyng code generation 4. `void` (if loop body never executed and no `else`). - **Implicit Coroutines**: All functions are coroutines. No `async/await`. Use `launch { ... }` (returns `Deferred`) or `flow { ... }`. - **Variables**: `val` (read-only), `var` (mutable). Supports late-init `val` in classes (must be assigned in `init` or body). +- **Serialization**: Use `@Transient` attribute before `val`/`var` or constructor parameters to exclude them from Lynon/JSON serialization. Transient fields are also ignored during `==` structural equality checks. - **Null Safety**: `?` (nullable type), `?.` (safe access), `?( )` (safe invoke), `?{ }` (safe block invoke), `?[ ]` (safe index), `?:` or `??` (elvis), `?=` (assign-if-null). - **Equality**: `==` (equals), `!=` (not equals), `===` (ref identity), `!==` (ref not identity). - **Comparison**: `<`, `>`, `<=`, `>=`, `<=>` (shuttle/spaceship, returns -1, 0, 1). diff --git a/docs/OOP.md b/docs/OOP.md index 76cc8d8..4057f84 100644 --- a/docs/OOP.md +++ b/docs/OOP.md @@ -857,6 +857,19 @@ Private fields are visible only _inside the class instance_: void >>> void +### Transient fields + +You can mark a field or a constructor parameter as transient using the `@Transient` attribute. Transient members are ignored during serialization (Lynon and JSON) and are also excluded from structural equality (`==`) checks. + +```lyng +class Session(@Transient val token, val userId) { + @Transient var lastAccess = time.now() + var data = Map() +} +``` + +For more details on how transient fields behave during restoration, see the [Serialization Guide](serialization.md). + ### Protected members Protected members are available to the declaring class and all of its transitive subclasses (including via MI). Additionally, an ancestor class can access a `protected` member of its descendant if the ancestor also defines or inherits a member with the same name (i.e., it is an override of something the ancestor knows about). diff --git a/docs/json_and_kotlin_serialization.md b/docs/json_and_kotlin_serialization.md index 18ba131..a34b3eb 100644 --- a/docs/json_and_kotlin_serialization.md +++ b/docs/json_and_kotlin_serialization.md @@ -20,7 +20,18 @@ Simple classes serialization is supported: assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() ) >>> void -Note that mutable members are serialized: +Note that mutable members are serialized by default. You can exclude any member (including constructor parameters) from JSON serialization using the `@Transient` attribute: + + import lyng.serialization + + class Point2(@Transient val foo, val bar) { + @Transient var reason = 42 + var visible = 100 + } + assertEquals( "{\"bar\":2,\"visible\":100}", Point2(1,2).toJsonString() ) + >>> void + +Note that if you override json serialization: import lyng.serialization diff --git a/docs/serialization.md b/docs/serialization.md index dd192ca..3182bcd 100644 --- a/docs/serialization.md +++ b/docs/serialization.md @@ -20,20 +20,37 @@ It is as simple as: assert( text.length > encodedBits.toBuffer().size ) >>> void -Any class you create is serializable by default; lynon serializes first constructor fields, then any `var` member fields: +Any class you create is serializable by default; lynon serializes first constructor fields, then any `var` member fields. - import lyng.serialization +## Transient Fields - class Point(x,y) +Sometimes you have fields that should not be serialized, for example, temporary caches, secret data, or derived values that are recomputed in `init` blocks. You can mark such fields with the `@Transient` attribute: - val p = Lynon.decode( Lynon.encode( Point(5,6) ) ) +```lyng +class MyData(@Transient val tempSecret, val publicData) { + @Transient var cachedValue = 0 + var persistentValue = 42 - assertEquals( 5, p.x ) - assertEquals( 6, p.y ) - >>> void + init { + // cachedValue can be recomputed here upon deserialization + cachedValue = computeCache(publicData) + } +} +``` +Transient fields: +- Are **omitted** from Lynon binary streams. +- Are **omitted** from JSON output (via `toJson`). +- Are **ignored** during structural equality checks (`==`). +- If a transient constructor parameter has a **default value**, it will be restored to that default value during deserialization. Otherwise, it will be `null`. +- Class body fields marked as `@Transient` will keep their initial values (or values assigned in `init`) after deserialization. -just as expected. +## Serialization of Objects and Classes + +- **Singleton Objects**: `object` declarations are serializable by name. Their state (mutable fields) is also serialized and restored, respecting `@Transient`. +- **Classes**: Class objects themselves can be serialized. They are serialized by their full qualified name. When converted to JSON, a class object includes its public static fields (excluding those marked `@Transient`). + +## Custom Serialization Important is to understand that normally `Lynon.decode` wants [BitBuffer], as `Lynon.encode` produces. If you have the regular [Buffer], be sure to convert it: diff --git a/docs/whats_new.md b/docs/whats_new.md index 37bf7b8..e8ecf24 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -171,6 +171,21 @@ settings["theme"] ?= "dark" The operator returns the final value of the receiver (the original value if it was not `null`, or the new value if the assignment occurred). +### Transient Attribute (`@Transient`) +The `@Transient` attribute can now be applied to class fields, constructor parameters, and static fields to exclude them from serialization. + +```lyng +class MyData(@Transient val tempSecret, val publicData) { + @Transient var cachedValue = 0 + var persistentValue = 42 +} +``` + +Key features: +- **Serialization**: Transient members are omitted from both Lynon binary streams and JSON output. +- **Structural Equality**: Transient fields are automatically ignored during `==` equality checks. +- **Deserialization**: Transient constructor parameters with default values are correctly restored to those defaults upon restoration. + ## Tooling and Infrastructure ### CLI: Formatting Command diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt index c001ff7..e4b035c 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/ArgsDeclaration.kt @@ -71,7 +71,8 @@ data class ArgsDeclaration(val params: List, val endTokenType: Token.Type) value.byValueCopy(), a.visibility ?: defaultVisibility, recordType = ObjRecord.Type.Argument, - declaringClass = declaringClass) + declaringClass = declaringClass, + isTransient = a.isTransient) } return } @@ -82,7 +83,8 @@ data class ArgsDeclaration(val params: List, val endTokenType: Token.Type) value.byValueCopy(), a.visibility ?: defaultVisibility, recordType = ObjRecord.Type.Argument, - declaringClass = declaringClass) + declaringClass = declaringClass, + isTransient = a.isTransient) } // Prepare positional args and parameter count, handle tail-block binding @@ -239,5 +241,6 @@ data class ArgsDeclaration(val params: List, val endTokenType: Token.Type) val defaultValue: Statement? = null, val accessType: AccessType? = null, val visibility: Visibility? = null, + val isTransient: Boolean = false, ) } \ No newline at end of file diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index ad0bcc5..91c71af 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -283,11 +283,13 @@ class Compiler( } private var lastAnnotation: (suspend (Scope, ObjString, Statement) -> Statement)? = null + private var isTransientFlag: Boolean = false private var lastLabel: String? = null private suspend fun parseStatement(braceMeansLambda: Boolean = false): Statement? { lastAnnotation = null lastLabel = null + isTransientFlag = false while (true) { val t = cc.next() return when (t.type) { @@ -306,6 +308,10 @@ class Compiler( Token.Type.ATLABEL -> { val label = t.value + if (label == "Transient") { + isTransientFlag = true + continue + } if (cc.peekNextNonWhitespace().type == Token.Type.LBRACE) { lastLabel = label } @@ -887,7 +893,15 @@ class Compiler( Token.Type.NEWLINE -> {} Token.Type.MULTILINE_COMMENT, Token.Type.SINGLE_LINE_COMMENT -> {} - Token.Type.ID -> { + Token.Type.ID, Token.Type.ATLABEL -> { + var isTransient = false + if (t.type == Token.Type.ATLABEL) { + if (t.value == "Transient") { + isTransient = true + t = cc.next() + } else throw ScriptError(t.pos, "Unexpected label in argument list") + } + // visibility val visibility = if (isClassDeclaration && t.value == "private") { t = cc.next() @@ -931,7 +945,8 @@ class Compiler( isEllipsis, defaultValue, access, - visibility + visibility, + isTransient ) // important: valid argument list continues with ',' and ends with '->' or ')' @@ -2015,6 +2030,7 @@ class Compiler( val newClass = ObjInstanceClass(className, *parentClasses.toTypedArray()) newClass.isAnonymous = nameToken == null + newClass.constructorMeta = ArgsDeclaration(emptyList(), Token.Type.RPAREN) for (i in parentClasses.indices) { val argsList = baseSpecs[i].args // In object, we evaluate parent args once at creation time @@ -2194,6 +2210,7 @@ class Compiler( // but we should pass Pos.builtIn to skip validation for now if needed, // or p.pos to allow it. pos = Pos.builtIn, + isTransient = p.isTransient, type = ObjRecord.Type.ConstructorField ) } @@ -2663,7 +2680,9 @@ class Compiler( isOverride: Boolean = false, isExtern: Boolean = false, isStatic: Boolean = false, + isTransient: Boolean = isTransientFlag ): Statement { + isTransientFlag = false val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true var t = cc.next() val start = t.pos @@ -2833,16 +2852,16 @@ class Compiler( val th = context.thisObj if (isStatic) { - (th as ObjClass).createClassField(name, ObjUnset, false, visibility, null, start, type = ObjRecord.Type.Delegated).apply { + (th as ObjClass).createClassField(name, ObjUnset, false, visibility, null, start, isTransient = isTransient, type = ObjRecord.Type.Delegated).apply { delegate = finalDelegate } - context.addItem(name, false, ObjUnset, visibility, recordType = ObjRecord.Type.Delegated).apply { + context.addItem(name, false, ObjUnset, visibility, recordType = ObjRecord.Type.Delegated, isTransient = isTransient).apply { delegate = finalDelegate } } else if (th is ObjClass) { val cls: ObjClass = th val storageName = "${cls.className}::$name" - cls.createField(name, ObjUnset, false, visibility, null, start, declaringClass = cls, isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, type = ObjRecord.Type.Delegated) + cls.createField(name, ObjUnset, false, visibility, null, start, declaringClass = cls, isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, isTransient = isTransient, type = ObjRecord.Type.Delegated) cls.instanceInitializers += statement(start) { scp -> val accessType2 = scp.resolveQualifiedIdentifier("DelegateAccess.Callable") val initValue2 = delegateExpression.execute(scp) @@ -2851,13 +2870,13 @@ class Compiler( } catch (e: Exception) { initValue2 } - scp.addItem(storageName, false, ObjUnset, visibility, null, recordType = ObjRecord.Type.Delegated, isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride).apply { + scp.addItem(storageName, false, ObjUnset, visibility, null, recordType = ObjRecord.Type.Delegated, isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, isTransient = isTransient).apply { delegate = finalDelegate2 } ObjVoid } } else { - context.addItem(name, false, ObjUnset, visibility, recordType = ObjRecord.Type.Delegated).apply { + context.addItem(name, false, ObjUnset, visibility, recordType = ObjRecord.Type.Delegated, isTransient = isTransient).apply { delegate = finalDelegate } } @@ -2986,8 +3005,10 @@ class Compiler( isClosed: Boolean = false, isOverride: Boolean = false, isStatic: Boolean = false, - isExtern: Boolean = false + isExtern: Boolean = false, + isTransient: Boolean = isTransientFlag ): Statement { + isTransientFlag = false val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true val nextToken = cc.next() val start = nextToken.pos @@ -3031,7 +3052,7 @@ class Compiler( return statement(start) { context -> val value = initialExpression.execute(context) for (name in names) { - context.addItem(name, true, ObjVoid, visibility) + context.addItem(name, true, ObjVoid, visibility, isTransient = isTransient) } pattern.setAt(start, context, value) if (!isMutable) { @@ -3233,17 +3254,18 @@ class Compiler( visibility, null, start, + isTransient = isTransient, type = ObjRecord.Type.Delegated ).apply { delegate = finalDelegate } // Also expose in current init scope - addItem(name, isMutable, ObjUnset, visibility, null, ObjRecord.Type.Delegated).apply { + addItem(name, isMutable, ObjUnset, visibility, null, ObjRecord.Type.Delegated, isTransient = isTransient).apply { delegate = finalDelegate } } else { - (thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, start) - addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field) + (thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, start, isTransient = isTransient) + addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field, isTransient = isTransient) } ObjVoid } @@ -3429,6 +3451,7 @@ class Compiler( visibility, setterVisibility, start, + isTransient = isTransient, type = ObjRecord.Type.Delegated, isAbstract = isAbstract, isClosed = isClosed, @@ -3448,7 +3471,8 @@ class Compiler( recordType = ObjRecord.Type.Delegated, isAbstract = isAbstract, isClosed = isClosed, - isOverride = isOverride + isOverride = isOverride, + isTransient = isTransient ).apply { delegate = finalDelegate } @@ -3469,7 +3493,8 @@ class Compiler( recordType = ObjRecord.Type.Delegated, isAbstract = isAbstract, isClosed = isClosed, - isOverride = isOverride + isOverride = isOverride, + isTransient = isTransient ) rec.delegate = finalDelegate return@statement finalDelegate @@ -3488,7 +3513,8 @@ class Compiler( recordType = ObjRecord.Type.Delegated, isAbstract = isAbstract, isClosed = isClosed, - isOverride = isOverride + isOverride = isOverride, + isTransient = isTransient ) rec.delegate = finalDelegate return@statement finalDelegate @@ -3524,6 +3550,7 @@ class Compiler( isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, + isTransient = isTransient, type = ObjRecord.Type.Field ) } @@ -3553,7 +3580,8 @@ class Compiler( recordType = ObjRecord.Type.Property, isAbstract = isAbstract, isClosed = isClosed, - isOverride = isOverride + isOverride = isOverride, + isTransient = isTransient ) prop } @@ -3576,6 +3604,7 @@ class Compiler( isClosed = isClosed, isOverride = isOverride, pos = start, + isTransient = isTransient, type = ObjRecord.Type.Field ) @@ -3591,7 +3620,8 @@ class Compiler( recordType = ObjRecord.Type.Field, isAbstract = isAbstract, isClosed = isClosed, - isOverride = isOverride + isOverride = isOverride, + isTransient = isTransient ) ObjVoid } @@ -3608,14 +3638,15 @@ class Compiler( recordType = ObjRecord.Type.Field, isAbstract = isAbstract, isClosed = isClosed, - isOverride = isOverride + isOverride = isOverride, + isTransient = isTransient ) initValue } } else { // Not in class body: regular local/var declaration val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull - context.addItem(name, isMutable, initValue, visibility, recordType = ObjRecord.Type.Other) + context.addItem(name, isMutable, initValue, visibility, recordType = ObjRecord.Type.Other, isTransient = isTransient) initValue } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index cfc180f..4a1b0cc 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -516,7 +516,8 @@ open class Scope( declaringClass: net.sergeych.lyng.obj.ObjClass? = currentClassCtx, isAbstract: Boolean = false, isClosed: Boolean = false, - isOverride: Boolean = false + isOverride: Boolean = false, + isTransient: Boolean = false ): ObjRecord { val rec = ObjRecord( value, isMutable, visibility, writeVisibility, @@ -524,7 +525,8 @@ open class Scope( type = recordType, isAbstract = isAbstract, isClosed = isClosed, - isOverride = isOverride + isOverride = isOverride, + isTransient = isTransient ) objects[name] = rec // Index this binding within the current frame to help resolve locals across suspension diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt index 9ee710b..d00ebb4 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt @@ -17,16 +17,24 @@ package net.sergeych.lyng.obj +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject import net.sergeych.lyng.* import net.sergeych.lyng.miniast.* import net.sergeych.lynon.LynonDecoder +import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonType // Simple id generator for class identities (not thread-safe; fine for scripts) private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ } val ObjClassType by lazy { - ObjClass("Class").apply { + object : ObjClass("Class") { + override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { + val name = decoder.decodeObject(scope, ObjString.type, null) as ObjString + return scope.resolveQualifiedIdentifier(name.value) + } + }.apply { addPropertyDoc( name = "className", doc = "Full name of this class including package if available.", @@ -451,6 +459,7 @@ open class ObjClass( isAbstract: Boolean = false, isClosed: Boolean = false, isOverride: Boolean = false, + isTransient: Boolean = false, type: ObjRecord.Type = ObjRecord.Type.Field, ): ObjRecord { // Validation of override rules: only for non-system declarations @@ -494,6 +503,7 @@ open class ObjClass( isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, + isTransient = isTransient, type = type ) members[name] = rec @@ -514,13 +524,14 @@ open class ObjClass( visibility: Visibility = Visibility.Public, writeVisibility: Visibility? = null, pos: Pos = Pos.builtIn, + isTransient: Boolean = false, type: ObjRecord.Type = ObjRecord.Type.Field ): ObjRecord { initClassScope() val existing = classScope!!.objects[name] if (existing != null) throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes") - val rec = classScope!!.addItem(name, isMutable, initialValue, visibility, writeVisibility, recordType = type) + val rec = classScope!!.addItem(name, isMutable, initialValue, visibility, writeVisibility, recordType = type, isTransient = isTransient) // Structural change: bump layout version for PIC invalidation layoutVersion += 1 return rec @@ -707,6 +718,22 @@ open class ObjClass( return super.invokeInstanceMethod(scope, name, args, onNotFoundResult) } + override suspend fun serialize(scope: Scope, encoder: LynonEncoder, lynonType: LynonType?) { + if (isAnonymous) scope.raiseError("Cannot serialize anonymous class") + encoder.encodeObject(scope, classNameObj, ObjString.type.lynonType()) + } + + override suspend fun toJson(scope: Scope): JsonElement { + val result = mutableMapOf() + result["__class_name"] = classNameObj.toJson(scope) + classScope?.objects?.forEach { (name, rec) -> + if (rec.type.serializable && rec.visibility.isPublic && !rec.isTransient) { + result[name] = rec.value.toJson(scope) + } + } + return JsonObject(result) + } + open suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj = scope.raiseNotImplemented() 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 fe10726..52bf542 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt @@ -338,7 +338,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { // values, so we save size of the construction: // using objlist allow for some optimizations: - val params = meta.params.map { readField(scope, it.name).value } + val params = meta.params.filter { !it.isTransient }.map { readField(scope, it.name).value } encoder.encodeAnyList(scope, params) val vars = serializingVars.values.map { it.value } if (vars.isNotEmpty()) { @@ -357,8 +357,10 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { val result = mutableMapOf() val meta = objClass.constructorMeta ?: scope.raiseError("can't serialize non-serializable object (no constructor meta)") - for (entry in meta.params) - result[entry.name] = readField(scope, entry.name).value.toJson(scope) + for (entry in meta.params) { + if (!entry.isTransient) + result[entry.name] = readField(scope, entry.name).value.toJson(scope) + } for (i in serializingVars) { // remove T:: prefix from the field name for JSON val parts = i.key.split("::") @@ -377,7 +379,8 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { it.value.type.serializable && it.value.type == ObjRecord.Type.Field && it.value.isMutable && - !metaParams.contains(it.key) + !metaParams.contains(it.key) && + !it.value.isTransient } } @@ -398,7 +401,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { protected val comparableVars: Map by lazy { instanceScope.objects.filter { - it.value.type.comparable && (it.value.type != ObjRecord.Type.Field || it.value.isMutable) + it.value.type.comparable && (it.value.type != ObjRecord.Type.Field || it.value.isMutable) && !it.value.isTransient } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt index 59be2df..e0f330d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstanceClass.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. @@ -28,13 +28,26 @@ import net.sergeych.lynon.LynonType class ObjInstanceClass(val name: String, vararg parents: ObjClass) : ObjClass(name, *parents) { override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { - val args = decoder.decodeAnyList(scope) - val actualSize = constructorMeta?.params?.size ?: 0 - if (args.size > actualSize) - scope.raiseIllegalArgument("constructor $name has only $actualSize but serialized version has ${args.size}") - val newScope = scope.createChildScope(args = Arguments(args)) - val instance = createInstance(newScope) - initializeInstance(instance, newScope.args, runConstructors = false) + val serializedArgs = decoder.decodeAnyList(scope) + val meta = constructorMeta ?: scope.raiseError("no constructor meta for $name") + val nonTransientCount = meta.params.count { !it.isTransient } + if (serializedArgs.size != nonTransientCount) + scope.raiseIllegalArgument("constructor $name expects $nonTransientCount non-transient arguments, but serialized version has ${serializedArgs.size}") + + var sIdx = 0 + val namedArgs = mutableMapOf() + for (p in meta.params) { + if (!p.isTransient) { + namedArgs[p.name] = serializedArgs[sIdx++] + } else if (p.defaultValue == null) { + // If transient parameter has no default value, we use ObjNull to avoid "too few arguments" error + namedArgs[p.name] = ObjNull + } + } + // Using named arguments allows the constructor to apply default values for transient parameters + val newArgs = Arguments(list = emptyList(), named = namedArgs) + val instance = createInstance(scope.createChildScope(args = newArgs)) + initializeInstance(instance, newArgs, runConstructors = false) return instance.apply { deserializeStateVars(scope, decoder) invokeInstanceMethod(scope, "onDeserialized") { ObjVoid } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonDecoder.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonDecoder.kt index 0be066e..b1cf683 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonDecoder.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lynon/LynonDecoder.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. @@ -20,6 +20,7 @@ package net.sergeych.lynon import net.sergeych.lyng.Scope import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.ObjClass +import net.sergeych.lyng.obj.ObjInstance import net.sergeych.lyng.obj.ObjString open class LynonDecoder(val bin: BitInput, val settings: LynonSettings = LynonSettings.default) { @@ -79,15 +80,16 @@ open class LynonDecoder(val bin: BitInput, val settings: LynonSettings = LynonSe private suspend fun decodeClassObj(scope: Scope): ObjClass { val className = decodeObject(scope, ObjString.type, null) as ObjString return scope.get(className.value)?.value?.let { - if (it !is ObjClass) - scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}") - it + if (it is ObjClass) return it + if (it is ObjInstance && it.objClass.className == className.value) return it.objClass + scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}") } ?: run { // Use Scope API that mirrors compiler-emitted ObjRef chain for qualified identifiers val evaluated = scope.resolveQualifiedIdentifier(className.value) - if (evaluated !is ObjClass) - scope.raiseClassCastError("Expected obj class but got ${evaluated::class.simpleName}") - evaluated + if (evaluated is ObjClass) return evaluated + if (evaluated is ObjInstance && evaluated.objClass.className == className.value) return evaluated.objClass + scope.raiseClassCastError("Expected obj class but got ${evaluated::class.simpleName}") + evaluated as ObjClass // unreachable but for compiler } } diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 4ac7fea..47fa44c 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -3209,6 +3209,23 @@ class ScriptTest { ) } + @Test + fun testInstantComponents() = runTest { + // This is a proposal + """ + val t1 = Instant.fromRFC3339("1970-05-06T07:11:56Z") + // components use default system calendar or modern + assertEquals(t1.year, 1970) + assertEquals(t1.month, 5) + assertEquals(t1.dayOfMonth, 6) + assertEquals(t1.hour, 7) + assertEquals(t1.minute, 11) + assertEquals(t1.second, 56) + assertEquals("1970-05-06T07:11:56Z", t1.toRFC3339()) + assertEquals("1970-05-06T07:11:56Z", t1.toSortableString()) + """.trimIndent() + } + @Test fun testDoubleImports() = runTest { val s = Scope.new() diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/TransientTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/TransientTest.kt new file mode 100644 index 0000000..d9458fa --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/TransientTest.kt @@ -0,0 +1,241 @@ +/* + * 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.ObjInstance +import net.sergeych.lyng.obj.ObjInt +import net.sergeych.lyng.obj.ObjNull +import net.sergeych.lyng.obj.toBool +import net.sergeych.lynon.lynonDecodeAny +import net.sergeych.lynon.lynonEncodeAny +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull + +class TransientTest { + + @Test + fun testTransient() = runTest { + val script = """ + class TestTransient(@Transient val a, val b) { + @Transient var c = 10 + var d = 20 + + fun check() { + a == 1 && b == 2 && c == 10 && d == 20 + } + } + + val t = TestTransient(1, 2) + t.c = 30 + t.d = 40 + t + """.trimIndent() + + val scope = Scope() + val t = scope.eval(script) as ObjInstance + + // Check initial state + assertEquals(1, (t.readField(scope, "a").value as ObjInt).value) + assertEquals(2, (t.readField(scope, "b").value as ObjInt).value) + assertEquals(30, (t.readField(scope, "c").value as ObjInt).value) + assertEquals(40, (t.readField(scope, "d").value as ObjInt).value) + + // Serialize + val serialized = lynonEncodeAny(scope, t) + println("[DEBUG_LOG] Serialized size: ${serialized.size}") + + // Deserialized + val t2 = lynonDecodeAny(scope, serialized) as ObjInstance + + // b and d should be preserved + assertEquals(2, (t2.readField(scope, "b").value as ObjInt).value) + assertEquals(40, (t2.readField(scope, "d").value as ObjInt).value) + + // a and c should be transient (lost or default/null) + // For constructor args, we currently set ObjNull if transient + assertEquals(ObjNull, t2.readField(scope, "a").value) + // For class fields, if it's transient it's not serialized, so it gets its initial value during construction + assertEquals(10, (t2.readField(scope, "c").value as ObjInt).value) + + // Check JSON + val json = t.toJson(scope).toString() + println("[DEBUG_LOG] JSON: $json") + assertFalse(json.contains("\"a\":")) + assertFalse(json.contains("\"c\":")) + assertNotNull(json.contains("\"b\":2")) + assertNotNull(json.contains("\"d\":40")) + } + + @Test + fun testTransientDefaultAndEquality() = runTest { + val script = """ + class TestExt(@Transient val a = 100, val b) { + @Transient var c = 200 + var d = 300 + } + + val t1 = TestExt(b: 2) + t1.c = 300 + t1.d = 400 + + val t2 = TestExt(a: 50, b: 2) + t2.c = 500 + t2.d = 400 + + // Equality should ignore transient fields a and c + val equal = (t1 == t2) + + [t1, t2, equal] + """.trimIndent() + + val scope = Scope() + val result = (scope.eval(script) as net.sergeych.lyng.obj.ObjList).list + val t1 = result[0] as ObjInstance + val t2 = result[1] as ObjInstance + val equal = result[2].toBool() + + assertEquals(true, equal, "Objects should be equal despite different transient fields") + + // Serialize t1 + val serialized = lynonEncodeAny(scope, t1) + val t1d = lynonDecodeAny(scope, serialized) as ObjInstance + + // a should have its default value 100, not null or 10 + assertEquals(100, (t1d.readField(scope, "a").value as ObjInt).value) + // c should have its initial value 200 + assertEquals(200, (t1d.readField(scope, "c").value as ObjInt).value) + // b and d should be preserved + assertEquals(2, (t1d.readField(scope, "b").value as ObjInt).value) + assertEquals(400, (t1d.readField(scope, "d").value as ObjInt).value) + } + + @Test + fun testStaticTransient() = runTest { + val script = """ + class TestStatic { + @Transient static var x = 10 + static var y = 20 + } + TestStatic.x = 30 + TestStatic.y = 40 + TestStatic + """.trimIndent() + val scope = Scope() + scope.eval(script) + // Static fields aren't serialized yet, but we ensure the parser accepts it + } + + @Test + fun testTransientSize() = runTest { + val script = """ + class Data1(val a, val b) { + var c = 30 + } + class Data2(val a, val b, @Transient val x) { + var c = 30 + @Transient var y = 40 + } + + val d1 = Data1(10, 20) + val d2 = Data2(10, 20, 100) + d2.y = 200 + + [d1, d2] + """.trimIndent() + + val scope = Scope() + val result = (scope.eval(script) as net.sergeych.lyng.obj.ObjList).list + val d1 = result[0] as ObjInstance + val d2 = result[1] as ObjInstance + + val s1 = lynonEncodeAny(scope, d1) + val s2 = lynonEncodeAny(scope, d2) + + println("[DEBUG_LOG] Data1 size: ${s1.size}") + println("[DEBUG_LOG] Data2 size: ${s2.size}") + + assertEquals(s1.size, s2.size, "Serialized sizes should match because transient fields are not serialized") + + val j1 = d1.toJson(scope).toString() + val j2 = d2.toJson(scope).toString() + + println("[DEBUG_LOG] Data1 JSON: $j1") + println("[DEBUG_LOG] Data2 JSON: $j2") + + assertEquals(j1.length, j2.length, "JSON lengths should match") + } + + @Test + fun testObjectTransient() = runTest { + val script = """ + object MyObject { + @Transient var temp = 10 + var persistent = 20 + } + MyObject.temp = 30 + MyObject.persistent = 40 + MyObject + """.trimIndent() + + val scope = Scope() + val obj = scope.eval(script) as ObjInstance + + val serialized = lynonEncodeAny(scope, obj) + val deserialized = lynonDecodeAny(scope, serialized) as ObjInstance + + // persistent should be 40 + assertEquals(40, (deserialized.readField(scope, "persistent").value as ObjInt).value) + // temp should be restored to 10 + assertEquals(10, (deserialized.readField(scope, "temp").value as ObjInt).value) + } + + @Test + fun testStaticTransientToJson() = runTest { + val script = """ + class TestStatic { + @Transient static var s1 = 10 + static var s2 = 20 + private static var s3 = 30 + } + TestStatic + """.trimIndent() + + val scope = Scope() + val cls = scope.eval(script) as net.sergeych.lyng.obj.ObjClass + + val json = cls.toJson(scope).toString() + println("[DEBUG_LOG] Class JSON: $json") + + // s2 should be in JSON + assertNotNull(json.contains("\"s2\":20")) + // s1 should NOT be in JSON (transient) + assertFalse(json.contains("\"s1\":")) + // s3 should NOT be in JSON (private) + assertFalse(json.contains("\"s3\":")) + // __class_name should be there + assertNotNull(json.contains("\"__class_name\":\"TestStatic\"")) + + // Test serialization/deserialization of the class itself + val serialized = lynonEncodeAny(scope, cls) + val deserialized = lynonDecodeAny(scope, serialized) as net.sergeych.lyng.obj.ObjClass + assertEquals(cls, deserialized) + } +}