+ @Tranient for serialization

This commit is contained in:
Sergey Chernov 2026-01-16 07:47:27 +03:00
parent 05e15e8e42
commit b7dfda2f5d
14 changed files with 450 additions and 54 deletions

View File

@ -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`). 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 { ... }`. - **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). - **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). - **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). - **Equality**: `==` (equals), `!=` (not equals), `===` (ref identity), `!==` (ref not identity).
- **Comparison**: `<`, `>`, `<=`, `>=`, `<=>` (shuttle/spaceship, returns -1, 0, 1). - **Comparison**: `<`, `>`, `<=`, `>=`, `<=>` (shuttle/spaceship, returns -1, 0, 1).

View File

@ -857,6 +857,19 @@ Private fields are visible only _inside the class instance_:
void void
>>> 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
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). 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).

View File

@ -20,7 +20,18 @@ Simple classes serialization is supported:
assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() ) assertEquals( "{\"foo\":1,\"bar\":2}", Point(1,2).toJsonString() )
>>> void >>> 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 import lyng.serialization

View File

@ -20,20 +20,37 @@ It is as simple as:
assert( text.length > encodedBits.toBuffer().size ) assert( text.length > encodedBits.toBuffer().size )
>>> void >>> 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 ) init {
assertEquals( 6, p.y ) // cachedValue can be recomputed here upon deserialization
>>> void 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: 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:

View File

@ -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). 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 ## Tooling and Infrastructure
### CLI: Formatting Command ### CLI: Formatting Command

View File

@ -71,7 +71,8 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
value.byValueCopy(), value.byValueCopy(),
a.visibility ?: defaultVisibility, a.visibility ?: defaultVisibility,
recordType = ObjRecord.Type.Argument, recordType = ObjRecord.Type.Argument,
declaringClass = declaringClass) declaringClass = declaringClass,
isTransient = a.isTransient)
} }
return return
} }
@ -82,7 +83,8 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
value.byValueCopy(), value.byValueCopy(),
a.visibility ?: defaultVisibility, a.visibility ?: defaultVisibility,
recordType = ObjRecord.Type.Argument, recordType = ObjRecord.Type.Argument,
declaringClass = declaringClass) declaringClass = declaringClass,
isTransient = a.isTransient)
} }
// Prepare positional args and parameter count, handle tail-block binding // Prepare positional args and parameter count, handle tail-block binding
@ -239,5 +241,6 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
val defaultValue: Statement? = null, val defaultValue: Statement? = null,
val accessType: AccessType? = null, val accessType: AccessType? = null,
val visibility: Visibility? = null, val visibility: Visibility? = null,
val isTransient: Boolean = false,
) )
} }

View File

@ -283,11 +283,13 @@ class Compiler(
} }
private var lastAnnotation: (suspend (Scope, ObjString, Statement) -> Statement)? = null private var lastAnnotation: (suspend (Scope, ObjString, Statement) -> Statement)? = null
private var isTransientFlag: Boolean = false
private var lastLabel: String? = null private var lastLabel: String? = null
private suspend fun parseStatement(braceMeansLambda: Boolean = false): Statement? { private suspend fun parseStatement(braceMeansLambda: Boolean = false): Statement? {
lastAnnotation = null lastAnnotation = null
lastLabel = null lastLabel = null
isTransientFlag = false
while (true) { while (true) {
val t = cc.next() val t = cc.next()
return when (t.type) { return when (t.type) {
@ -306,6 +308,10 @@ class Compiler(
Token.Type.ATLABEL -> { Token.Type.ATLABEL -> {
val label = t.value val label = t.value
if (label == "Transient") {
isTransientFlag = true
continue
}
if (cc.peekNextNonWhitespace().type == Token.Type.LBRACE) { if (cc.peekNextNonWhitespace().type == Token.Type.LBRACE) {
lastLabel = label lastLabel = label
} }
@ -887,7 +893,15 @@ class Compiler(
Token.Type.NEWLINE -> {} Token.Type.NEWLINE -> {}
Token.Type.MULTILINE_COMMENT, Token.Type.SINGLE_LINE_COMMENT -> {} 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 // visibility
val visibility = if (isClassDeclaration && t.value == "private") { val visibility = if (isClassDeclaration && t.value == "private") {
t = cc.next() t = cc.next()
@ -931,7 +945,8 @@ class Compiler(
isEllipsis, isEllipsis,
defaultValue, defaultValue,
access, access,
visibility visibility,
isTransient
) )
// important: valid argument list continues with ',' and ends with '->' or ')' // important: valid argument list continues with ',' and ends with '->' or ')'
@ -2015,6 +2030,7 @@ class Compiler(
val newClass = ObjInstanceClass(className, *parentClasses.toTypedArray()) val newClass = ObjInstanceClass(className, *parentClasses.toTypedArray())
newClass.isAnonymous = nameToken == null newClass.isAnonymous = nameToken == null
newClass.constructorMeta = ArgsDeclaration(emptyList(), Token.Type.RPAREN)
for (i in parentClasses.indices) { for (i in parentClasses.indices) {
val argsList = baseSpecs[i].args val argsList = baseSpecs[i].args
// In object, we evaluate parent args once at creation time // 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, // but we should pass Pos.builtIn to skip validation for now if needed,
// or p.pos to allow it. // or p.pos to allow it.
pos = Pos.builtIn, pos = Pos.builtIn,
isTransient = p.isTransient,
type = ObjRecord.Type.ConstructorField type = ObjRecord.Type.ConstructorField
) )
} }
@ -2663,7 +2680,9 @@ class Compiler(
isOverride: Boolean = false, isOverride: Boolean = false,
isExtern: Boolean = false, isExtern: Boolean = false,
isStatic: Boolean = false, isStatic: Boolean = false,
isTransient: Boolean = isTransientFlag
): Statement { ): Statement {
isTransientFlag = false
val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true
var t = cc.next() var t = cc.next()
val start = t.pos val start = t.pos
@ -2833,16 +2852,16 @@ class Compiler(
val th = context.thisObj val th = context.thisObj
if (isStatic) { 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 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 delegate = finalDelegate
} }
} else if (th is ObjClass) { } else if (th is ObjClass) {
val cls: ObjClass = th val cls: ObjClass = th
val storageName = "${cls.className}::$name" 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 -> cls.instanceInitializers += statement(start) { scp ->
val accessType2 = scp.resolveQualifiedIdentifier("DelegateAccess.Callable") val accessType2 = scp.resolveQualifiedIdentifier("DelegateAccess.Callable")
val initValue2 = delegateExpression.execute(scp) val initValue2 = delegateExpression.execute(scp)
@ -2851,13 +2870,13 @@ class Compiler(
} catch (e: Exception) { } catch (e: Exception) {
initValue2 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 delegate = finalDelegate2
} }
ObjVoid ObjVoid
} }
} else { } 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 delegate = finalDelegate
} }
} }
@ -2986,8 +3005,10 @@ class Compiler(
isClosed: Boolean = false, isClosed: Boolean = false,
isOverride: Boolean = false, isOverride: Boolean = false,
isStatic: Boolean = false, isStatic: Boolean = false,
isExtern: Boolean = false isExtern: Boolean = false,
isTransient: Boolean = isTransientFlag
): Statement { ): Statement {
isTransientFlag = false
val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true val actualExtern = isExtern || (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.isExtern == true
val nextToken = cc.next() val nextToken = cc.next()
val start = nextToken.pos val start = nextToken.pos
@ -3031,7 +3052,7 @@ class Compiler(
return statement(start) { context -> return statement(start) { context ->
val value = initialExpression.execute(context) val value = initialExpression.execute(context)
for (name in names) { for (name in names) {
context.addItem(name, true, ObjVoid, visibility) context.addItem(name, true, ObjVoid, visibility, isTransient = isTransient)
} }
pattern.setAt(start, context, value) pattern.setAt(start, context, value)
if (!isMutable) { if (!isMutable) {
@ -3233,17 +3254,18 @@ class Compiler(
visibility, visibility,
null, null,
start, start,
isTransient = isTransient,
type = ObjRecord.Type.Delegated type = ObjRecord.Type.Delegated
).apply { ).apply {
delegate = finalDelegate delegate = finalDelegate
} }
// Also expose in current init scope // 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 delegate = finalDelegate
} }
} else { } else {
(thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, start) (thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, start, isTransient = isTransient)
addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field) addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field, isTransient = isTransient)
} }
ObjVoid ObjVoid
} }
@ -3429,6 +3451,7 @@ class Compiler(
visibility, visibility,
setterVisibility, setterVisibility,
start, start,
isTransient = isTransient,
type = ObjRecord.Type.Delegated, type = ObjRecord.Type.Delegated,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
@ -3448,7 +3471,8 @@ class Compiler(
recordType = ObjRecord.Type.Delegated, recordType = ObjRecord.Type.Delegated,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride,
isTransient = isTransient
).apply { ).apply {
delegate = finalDelegate delegate = finalDelegate
} }
@ -3469,7 +3493,8 @@ class Compiler(
recordType = ObjRecord.Type.Delegated, recordType = ObjRecord.Type.Delegated,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride,
isTransient = isTransient
) )
rec.delegate = finalDelegate rec.delegate = finalDelegate
return@statement finalDelegate return@statement finalDelegate
@ -3488,7 +3513,8 @@ class Compiler(
recordType = ObjRecord.Type.Delegated, recordType = ObjRecord.Type.Delegated,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride,
isTransient = isTransient
) )
rec.delegate = finalDelegate rec.delegate = finalDelegate
return@statement finalDelegate return@statement finalDelegate
@ -3524,6 +3550,7 @@ class Compiler(
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient,
type = ObjRecord.Type.Field type = ObjRecord.Type.Field
) )
} }
@ -3553,7 +3580,8 @@ class Compiler(
recordType = ObjRecord.Type.Property, recordType = ObjRecord.Type.Property,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride,
isTransient = isTransient
) )
prop prop
} }
@ -3576,6 +3604,7 @@ class Compiler(
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
pos = start, pos = start,
isTransient = isTransient,
type = ObjRecord.Type.Field type = ObjRecord.Type.Field
) )
@ -3591,7 +3620,8 @@ class Compiler(
recordType = ObjRecord.Type.Field, recordType = ObjRecord.Type.Field,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride,
isTransient = isTransient
) )
ObjVoid ObjVoid
} }
@ -3608,14 +3638,15 @@ class Compiler(
recordType = ObjRecord.Type.Field, recordType = ObjRecord.Type.Field,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride,
isTransient = isTransient
) )
initValue initValue
} }
} else { } else {
// Not in class body: regular local/var declaration // Not in class body: regular local/var declaration
val initValue = initialExpression?.execute(context)?.byValueCopy() ?: ObjNull 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 initValue
} }
} }

View File

@ -516,7 +516,8 @@ open class Scope(
declaringClass: net.sergeych.lyng.obj.ObjClass? = currentClassCtx, declaringClass: net.sergeych.lyng.obj.ObjClass? = currentClassCtx,
isAbstract: Boolean = false, isAbstract: Boolean = false,
isClosed: Boolean = false, isClosed: Boolean = false,
isOverride: Boolean = false isOverride: Boolean = false,
isTransient: Boolean = false
): ObjRecord { ): ObjRecord {
val rec = ObjRecord( val rec = ObjRecord(
value, isMutable, visibility, writeVisibility, value, isMutable, visibility, writeVisibility,
@ -524,7 +525,8 @@ open class Scope(
type = recordType, type = recordType,
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride isOverride = isOverride,
isTransient = isTransient
) )
objects[name] = rec objects[name] = rec
// Index this binding within the current frame to help resolve locals across suspension // Index this binding within the current frame to help resolve locals across suspension

View File

@ -17,16 +17,24 @@
package net.sergeych.lyng.obj package net.sergeych.lyng.obj
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import net.sergeych.lyng.* import net.sergeych.lyng.*
import net.sergeych.lyng.miniast.* import net.sergeych.lyng.miniast.*
import net.sergeych.lynon.LynonDecoder import net.sergeych.lynon.LynonDecoder
import net.sergeych.lynon.LynonEncoder
import net.sergeych.lynon.LynonType import net.sergeych.lynon.LynonType
// Simple id generator for class identities (not thread-safe; fine for scripts) // Simple id generator for class identities (not thread-safe; fine for scripts)
private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ } private object ClassIdGen { var c: Long = 1L; fun nextId(): Long = c++ }
val ObjClassType by lazy { 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( addPropertyDoc(
name = "className", name = "className",
doc = "Full name of this class including package if available.", doc = "Full name of this class including package if available.",
@ -451,6 +459,7 @@ open class ObjClass(
isAbstract: Boolean = false, isAbstract: Boolean = false,
isClosed: Boolean = false, isClosed: Boolean = false,
isOverride: Boolean = false, isOverride: Boolean = false,
isTransient: Boolean = false,
type: ObjRecord.Type = ObjRecord.Type.Field, type: ObjRecord.Type = ObjRecord.Type.Field,
): ObjRecord { ): ObjRecord {
// Validation of override rules: only for non-system declarations // Validation of override rules: only for non-system declarations
@ -494,6 +503,7 @@ open class ObjClass(
isAbstract = isAbstract, isAbstract = isAbstract,
isClosed = isClosed, isClosed = isClosed,
isOverride = isOverride, isOverride = isOverride,
isTransient = isTransient,
type = type type = type
) )
members[name] = rec members[name] = rec
@ -514,13 +524,14 @@ open class ObjClass(
visibility: Visibility = Visibility.Public, visibility: Visibility = Visibility.Public,
writeVisibility: Visibility? = null, writeVisibility: Visibility? = null,
pos: Pos = Pos.builtIn, pos: Pos = Pos.builtIn,
isTransient: Boolean = false,
type: ObjRecord.Type = ObjRecord.Type.Field type: ObjRecord.Type = ObjRecord.Type.Field
): ObjRecord { ): ObjRecord {
initClassScope() initClassScope()
val existing = classScope!!.objects[name] val existing = classScope!!.objects[name]
if (existing != null) if (existing != null)
throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes") 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 // Structural change: bump layout version for PIC invalidation
layoutVersion += 1 layoutVersion += 1
return rec return rec
@ -707,6 +718,22 @@ open class ObjClass(
return super.invokeInstanceMethod(scope, name, args, onNotFoundResult) 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<String, JsonElement>()
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 = open suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj =
scope.raiseNotImplemented() scope.raiseNotImplemented()

View File

@ -338,7 +338,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
// values, so we save size of the construction: // values, so we save size of the construction:
// using objlist allow for some optimizations: // 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) encoder.encodeAnyList(scope, params)
val vars = serializingVars.values.map { it.value } val vars = serializingVars.values.map { it.value }
if (vars.isNotEmpty()) { if (vars.isNotEmpty()) {
@ -357,8 +357,10 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
val result = mutableMapOf<String, JsonElement>() val result = mutableMapOf<String, JsonElement>()
val meta = objClass.constructorMeta val meta = objClass.constructorMeta
?: scope.raiseError("can't serialize non-serializable object (no constructor meta)") ?: scope.raiseError("can't serialize non-serializable object (no constructor meta)")
for (entry in meta.params) for (entry in meta.params) {
if (!entry.isTransient)
result[entry.name] = readField(scope, entry.name).value.toJson(scope) result[entry.name] = readField(scope, entry.name).value.toJson(scope)
}
for (i in serializingVars) { for (i in serializingVars) {
// remove T:: prefix from the field name for JSON // remove T:: prefix from the field name for JSON
val parts = i.key.split("::") val parts = i.key.split("::")
@ -377,7 +379,8 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
it.value.type.serializable && it.value.type.serializable &&
it.value.type == ObjRecord.Type.Field && it.value.type == ObjRecord.Type.Field &&
it.value.isMutable && 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<String, ObjRecord> by lazy { protected val comparableVars: Map<String, ObjRecord> by lazy {
instanceScope.objects.filter { 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
} }
} }

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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) { class ObjInstanceClass(val name: String, vararg parents: ObjClass) : ObjClass(name, *parents) {
override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj { override suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj {
val args = decoder.decodeAnyList(scope) val serializedArgs = decoder.decodeAnyList(scope)
val actualSize = constructorMeta?.params?.size ?: 0 val meta = constructorMeta ?: scope.raiseError("no constructor meta for $name")
if (args.size > actualSize) val nonTransientCount = meta.params.count { !it.isTransient }
scope.raiseIllegalArgument("constructor $name has only $actualSize but serialized version has ${args.size}") if (serializedArgs.size != nonTransientCount)
val newScope = scope.createChildScope(args = Arguments(args)) scope.raiseIllegalArgument("constructor $name expects $nonTransientCount non-transient arguments, but serialized version has ${serializedArgs.size}")
val instance = createInstance(newScope)
initializeInstance(instance, newScope.args, runConstructors = false) var sIdx = 0
val namedArgs = mutableMapOf<String, Obj>()
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<Obj>(), named = namedArgs)
val instance = createInstance(scope.createChildScope(args = newArgs))
initializeInstance(instance, newArgs, runConstructors = false)
return instance.apply { return instance.apply {
deserializeStateVars(scope, decoder) deserializeStateVars(scope, decoder)
invokeInstanceMethod(scope, "onDeserialized") { ObjVoid } invokeInstanceMethod(scope, "onDeserialized") { ObjVoid }

View File

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with 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.Scope
import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.Obj
import net.sergeych.lyng.obj.ObjClass import net.sergeych.lyng.obj.ObjClass
import net.sergeych.lyng.obj.ObjInstance
import net.sergeych.lyng.obj.ObjString import net.sergeych.lyng.obj.ObjString
open class LynonDecoder(val bin: BitInput, val settings: LynonSettings = LynonSettings.default) { 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 { private suspend fun decodeClassObj(scope: Scope): ObjClass {
val className = decodeObject(scope, ObjString.type, null) as ObjString val className = decodeObject(scope, ObjString.type, null) as ObjString
return scope.get(className.value)?.value?.let { return scope.get(className.value)?.value?.let {
if (it !is ObjClass) 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}") scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
it
} ?: run { } ?: run {
// Use Scope API that mirrors compiler-emitted ObjRef chain for qualified identifiers // Use Scope API that mirrors compiler-emitted ObjRef chain for qualified identifiers
val evaluated = scope.resolveQualifiedIdentifier(className.value) val evaluated = scope.resolveQualifiedIdentifier(className.value)
if (evaluated !is ObjClass) 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}") scope.raiseClassCastError("Expected obj class but got ${evaluated::class.simpleName}")
evaluated evaluated as ObjClass // unreachable but for compiler
} }
} }

View File

@ -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 @Test
fun testDoubleImports() = runTest { fun testDoubleImports() = runTest {
val s = Scope.new() val s = Scope.new()

View File

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