Compare commits

..

2 Commits

Author SHA1 Message Date
71a37a2906 Revert "Improve decodeClassObj class resolution in LynonDecoder, add fallback lookup mechanisms, and refine related tests"
This reverts commit dd1a1544c6d49641783d221b15e23c7150010161.
2025-12-13 23:14:12 +01:00
ab05f83e77 Revert "Add documentation for Lynon class-name resolution behavior and future plans for fully-qualified name support"
This reverts commit a2d26fc7775508c4fc9c5bb62496ad4b88d74662.
2025-12-13 23:13:56 +01:00
4 changed files with 11 additions and 218 deletions

View File

@ -1,20 +1,3 @@
<!--
~ Copyright 2025 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.
~
-->
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="lyng:site [jsBrowserDevelopmentRun]" type="GradleRunConfiguration" factoryName="Gradle">
<ExternalSystemSettings>
@ -34,11 +17,8 @@
</ExternalSystemSettings>
<ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
<ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
<ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
<DebugAllEnabled>false</DebugAllEnabled>
<RunAsTest>false</RunAsTest>
<GradleProfilingDisabled>true</GradleProfilingDisabled>
<GradleCoverageDisabled>true</GradleCoverageDisabled>
<method v="2" />
</configuration>
</component>

View File

@ -1,66 +0,0 @@
### Lynon class-name resolution: current behavior and roadmap for fully-qualified names
#### Current behavior (as of Dec 2025)
- The Lynon encoder writes the class name for user-defined objects as a simple name taken from `obj.objClass.classNameObj` (e.g., `"Vault"`). It does not encode the fully-qualified path (e.g., `"ns.sub.Vault"`).
- During decoding, `LynonDecoder.decodeClassObj` must resolve this simple name to an `ObjClass` using the provided `Scope`.
- Historically, `decodeClassObj` relied only on `scope.get(name)`. In some scopes this missed symbols that were import-visible at compile time, while `scope.eval(name)` could still find them. This mismatch caused occasional failures such as:
- `scope.eval("Vault")` ← returns `ObjClass`
- `scope.get("Vault")` ← returns `null`
To address this, the decoder now uses a robust, import-aware resolution strategy:
1) Try `scope.get(name)`
2) Try `scope.chainLookupWithMembers(name)`
3) Find the nearest `ModuleScope` and check its locals and its parent/root locals/members
4) Check `scope.currentImportProvider.rootScope` globals (e.g., stdlib)
5) As a last resort, try `scope.eval(name)` and validate the result is an `ObjClass`
This resolves the previously observed mismatch, including simple names like `"Vault"` defined in modules or visible via imports.
Notes:
- Tests confirm that the simple-name path is stable after this change.
- Tests for fully qualified names were avoided because the encoder does not currently emit qualified names, and runtime namespaces (like a value-bound `ns`) are not guaranteed to exist for `eval("ns.sub.Vault")` in all scopes.
#### Limitations
- Decoder receives only simple names. If two different classes with the same simple name exist in different packages/modules, decoding could be ambiguous unless the surrounding scope makes the intended one discoverable.
- Using `eval` as last fallback implies compilation at decode-time in rare cases. While this is robust, a purely structural resolution is preferable for determinism and performance.
#### Roadmap: support fully-qualified class names
If/when we decide to encode fully-qualified names, we should implement both encoder and decoder changes in lockstep.
Proposed plan:
1) Encoder changes
- When serializing a user-defined object, emit the fully-qualified class path (e.g., `"ns.sub.Vault"`) alongside or instead of the simple class name.
- Consider a feature flag or versioning in Lynon type records to maintain backward compatibility with existing streams that carry only the simple name.
2) Decoder changes
- If the encoded name contains dots, treat it as a dotted path. Implement a path resolver that:
- Splits the name by `.` into segments: `[ns, sub, Vault]`.
- Resolves the first segment with the same multi-scope strategy used today for simple names (steps 1–4 above), without relying on `eval`.
- For remaining segments, traverse members/namespace tables:
- If the current object is an `ObjNamespace` or similar container, look up the next segment in its members map.
- If the current object is an `ObjClass`, look up the next segment with `getInstanceMemberOrNull` (for nested classes or class-level members), if applicable.
- Continue until the final segment is reached; verify the target is an `ObjClass`.
- If this structural path resolution fails, optionally fall back to `scope.eval(fullyQualifiedName)` in development modes, but prefer to avoid it in production for determinism.
3) Runtime namespace objects (optional but recommended)
- Introduce or formalize an `ObjNamespace` (or reuse existing) that represents packages/modules at runtime and can be placed in scope bindings, enabling deterministic traversal of `ns.sub` without compiling code.
- Ensure imports or `package` declarations materialize such namespace objects in accessible scopes when required, so dotted paths can be resolved purely structurally by the decoder.
4) Backward compatibility and migration
- Maintain decoding support for simple names for older data.
- Prefer fully-qualified names when writing new data once the feature is available.
- Provide configuration to control which form to emit.
5) Testing strategy
- Add tests that:
- Encode/decode with simple names (legacy) and confirm existing behavior.
- Encode/decode with fully-qualified names (new) and confirm dotted-path traversal works without `eval`.
- Cover ambiguity resolution when same simple name exists in multiple namespaces.
#### Summary
Today, Lynon encodes and decodes class names using simple names only. The decoder’s name resolution was strengthened to be import-aware and robust, fixing cases where `eval` sees a class that `get` does not. To support fully-qualified names in the future, we plan to emit dotted class paths and implement a structural dotted-path resolver in the decoder (with optional runtime namespace objects) to avoid relying on `eval` and to keep decoding deterministic and unambiguous.

View File

@ -78,72 +78,15 @@ 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
val name = className.value
// 1) Try direct lookup in this scope (locals/parents/this members)
scope.get(name)?.value?.let {
return scope.get(className.value)?.value?.let {
if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
return it
}
// 2) Try ancestry lookup including instance/class members, but without invoking overridden get
scope.chainLookupWithMembers(name)?.value?.let {
if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
return it
}
// 3) Try to find nearest ModuleScope and check its locals and its parent (root) locals
run {
var s: Scope? = scope
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
if (s is net.sergeych.lyng.ModuleScope) {
s.objects[name]?.value?.let {
if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
return it
}
s.localBindings[name]?.value?.let {
if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
return it
}
s.parent?.let { p ->
p.objects[name]?.value?.let {
if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
return it
}
p.localBindings[name]?.value?.let {
if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
return it
}
p.thisObj.objClass.getInstanceMemberOrNull(name)?.value?.let {
if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
return it
}
}
break
}
s = s.parent
}
}
// 4) Try ImportProvider root scope globals (e.g., stdlib)
runCatching { scope.currentImportProvider.rootScope.objects[name]?.value }.getOrNull()?.let {
if (it !is ObjClass)
scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
return it
}
// // 5) As a final fallback, try to evaluate the name in this scope using the compiler
// runCatching { scope.eval(name) }.getOrNull()?.let {
// if (it !is ObjClass)
// scope.raiseClassCastError("Expected obj class but got ${it::class.simpleName}")
// return it
// }
// If everything failed, raise an informative error
scope.raiseSymbolNotFound("can't deserialize: not found type ${className}")
it
} ?: scope.also {
println("Class not found: $className")
println("::: ${runCatching { scope.eval("Vault")}.getOrNull() }")
println("::2 [${className}]: ${scope.get(className.value)}")
}.raiseSymbolNotFound("can't deserialize: not found type $className")
}
suspend fun decodeAnyList(scope: Scope, fixedSize: Int? = null): MutableList<Obj> {

View File

@ -25,7 +25,10 @@ import net.sergeych.lyng.obj.*
import net.sergeych.lynon.*
import java.nio.file.Files
import java.nio.file.Path
import kotlin.test.*
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class LynonTests {
@ -41,73 +44,6 @@ class LynonTests {
assertEquals(3, sizeInTetrades(257u))
}
@Ignore("This is not yet implemented")
@Test
fun decodeClassObj_shouldResolveDottedQualifiedNames() = runTest {
// Define nested namespaces and a class with a qualified name via eval
val module = net.sergeych.lyng.Script.defaultImportManager.newModule()
module.eval(
"""
package ns.sub
class Vault() { fun toString() { "ns.sub.Vault" } }
""".trimIndent()
)
val child = module.createChildScope(module.pos)
// Sanity: eval resolves both qualified and unqualified in the same module context
val qualified = child.eval("ns.sub.Vault")
assertTrue(qualified is ObjClass)
val inst = child.eval("ns.sub.Vault()")
assertTrue(inst is ObjInstance)
// Encode and decode instance; decoder should resolve class by its encoded name
val bout = MemoryBitOutput()
val enc = LynonEncoder(bout)
enc.encodeAny(child, inst)
val bin = MemoryBitInput(bout.toBitArray())
val dec = LynonDecoder(bin)
val decoded = dec.decodeAny(child)
assertTrue(decoded is ObjInstance)
val decObj = decoded as ObjInstance
assertEquals("Vault", decObj.objClass.className)
}
@Test
fun decodeClassObj_shouldResolveWhenEvalFindsButGetMisses() = runTest {
// Build a module scope and define a class there via eval (simulating imported/user code)
val module = net.sergeych.lyng.Script.defaultImportManager.newModule()
module.eval("class Vault() { fun toString() { \"Vault\" } }")
// Build a child scope where local bindings do not include the class name explicitly
val child = module.createChildScope(module.pos)
// Sanity: eval in the child must resolve the class by name
val evalResolved = child.eval("Vault")
assertTrue(evalResolved is ObjClass)
// Create an instance so that encoder will write type Other + class name and constructor args
val inst = child.eval("Vault()")
assertTrue(inst is ObjInstance)
// Encode the instance and then decode it with our decoder that contains robust class lookup
val bout = MemoryBitOutput()
val enc = LynonEncoder(bout)
enc.encodeAny(child, inst)
val bin = MemoryBitInput(bout.toBitArray())
val dec = LynonDecoder(bin)
val decoded = dec.decodeAny(child)
assertTrue(decoded is ObjInstance)
// Class should be resolvable and preserved
val instObj = inst as ObjInstance
val decObj = decoded as ObjInstance
assertEquals(instObj.objClass.className, decObj.objClass.className)
assertEquals("Vault", decObj.objClass.className)
}
@Test
fun testSizeInBits() {
assertEquals(1, sizeInBits(0u))