+ @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`).
- **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).

View File

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

View File

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

View File

@ -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:

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).
### 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

View File

@ -71,7 +71,8 @@ data class ArgsDeclaration(val params: List<Item>, 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<Item>, 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<Item>, val endTokenType: Token.Type)
val defaultValue: Statement? = null,
val accessType: AccessType? = 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 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
}
}

View File

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

View File

@ -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<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 =
scope.raiseNotImplemented()

View File

@ -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<String, JsonElement>()
val meta = objClass.constructorMeta
?: 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)
}
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<String, ObjRecord> 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
}
}

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");
* 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<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 {
deserializeStateVars(scope, decoder)
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");
* 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)
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}")
it
} ?: run {
// Use Scope API that mirrors compiler-emitted ObjRef chain for qualified identifiers
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}")
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
fun testDoubleImports() = runTest {
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)
}
}