Support extensions on singleton objects

This commit is contained in:
Sergey Chernov 2026-04-06 19:24:28 +03:00
parent 671583638b
commit 35628c8453
5 changed files with 56 additions and 9 deletions

View File

@ -9,6 +9,7 @@ For a programmer-focused migration summary across 1.5.x, see `docs/whats_new_1_5
- `1.5.4` is the stabilization release for the 1.5 feature set. - `1.5.4` is the stabilization release for the 1.5 feature set.
- The 1.5 line now brings together richer ranges and loops, interpolation, math modules, immutable and observable collections, richer `lyngio`, and much better CLI/IDE support. - The 1.5 line now brings together richer ranges and loops, interpolation, math modules, immutable and observable collections, richer `lyngio`, and much better CLI/IDE support.
- `1.5.4` specifically fixes user-visible issues around decimal arithmetic, mixed numeric flows, list behavior, and observable list hooks. - `1.5.4` specifically fixes user-visible issues around decimal arithmetic, mixed numeric flows, list behavior, and observable list hooks.
- `1.5.4` also fixes extension-member registration for named singleton `object` declarations, so `fun X.foo()` and `val X.bar` now work as expected.
- The docs, homepage samples, and release metadata now point at the current stable version. - The docs, homepage samples, and release metadata now point at the current stable version.
## User Highlights Across 1.5.x ## User Highlights Across 1.5.x
@ -303,6 +304,23 @@ object Config {
Config.show() Config.show()
``` ```
Named singleton objects can also be used as extension receivers:
```lyng
object X {
fun base() = "base"
}
fun X.decorate(value): String {
this.base() + ":" + value.toString()
}
val X.tag get() = this.base() + ":tag"
assertEquals("base:42", X.decorate(42))
assertEquals("base:tag", X.tag)
```
### Nested Declarations and Lifted Enums ### Nested Declarations and Lifted Enums
You can now declare classes, objects, enums, and type aliases inside another class. These nested declarations live in the class namespace (no outer instance capture) and are accessed with a qualifier. You can now declare classes, objects, enums, and type aliases inside another class. These nested declarations live in the class namespace (no outer instance capture) and are accessed with a qualifier.

View File

@ -85,8 +85,7 @@ internal suspend fun executeFunctionDecl(
} }
if (spec.extTypeName != null) { if (spec.extTypeName != null) {
val type = scope[spec.extTypeName]?.value ?: scope.raiseSymbolNotFound("class ${spec.extTypeName} not found") val type = scope.resolveExtensionReceiverClass(spec.extTypeName)
if (type !is ObjClass) scope.raiseClassCastError("${spec.extTypeName} is not the class instance")
scope.addExtension( scope.addExtension(
type, type,
spec.name, spec.name,
@ -167,8 +166,7 @@ internal suspend fun executeFunctionDecl(
val compiledFnBody = annotatedFnBody val compiledFnBody = annotatedFnBody
spec.extTypeName?.let { typeName -> spec.extTypeName?.let { typeName ->
val type = scope[typeName]?.value ?: scope.raiseSymbolNotFound("class $typeName not found") val type = scope.resolveExtensionReceiverClass(typeName)
if (type !is ObjClass) scope.raiseClassCastError("$typeName is not the class instance")
if (spec.isStatic) { if (spec.isStatic) {
type.createClassField( type.createClassField(
spec.name, spec.name,

View File

@ -1004,4 +1004,13 @@ open class Scope(
return rec.value as? net.sergeych.lyng.obj.ObjClass return rec.value as? net.sergeych.lyng.obj.ObjClass
?: raiseClassCastError("Expected class $name, got ${rec.value.objClass.className}") ?: raiseClassCastError("Expected class $name, got ${rec.value.objClass.className}")
} }
internal fun resolveExtensionReceiverClass(name: String): net.sergeych.lyng.obj.ObjClass {
val value = get(name)?.value ?: raiseSymbolNotFound("class $name not found")
return when (value) {
is net.sergeych.lyng.obj.ObjClass -> value
is net.sergeych.lyng.obj.ObjInstance -> value.objClass
else -> raiseClassCastError("$name is not the class instance")
}
}
} }

View File

@ -3094,11 +3094,7 @@ class CmdDeclExtProperty(internal val constId: Int, internal val slot: Int) : Cm
override suspend fun perform(frame: CmdFrame) { override suspend fun perform(frame: CmdFrame) {
val decl = frame.fn.constants[constId] as? BytecodeConst.ExtensionPropertyDecl val decl = frame.fn.constants[constId] as? BytecodeConst.ExtensionPropertyDecl
?: error("DECL_EXT_PROPERTY expects ExtensionPropertyDecl at $constId") ?: error("DECL_EXT_PROPERTY expects ExtensionPropertyDecl at $constId")
val type = frame.ensureScope()[decl.extTypeName]?.value val type = frame.ensureScope().resolveExtensionReceiverClass(decl.extTypeName)
?: frame.ensureScope().raiseSymbolNotFound("class ${decl.extTypeName} not found")
if (type !is ObjClass) {
frame.ensureScope().raiseClassCastError("${decl.extTypeName} is not the class instance")
}
frame.ensureScope().addExtension( frame.ensureScope().addExtension(
type, type,
decl.property.name, decl.property.name,

View File

@ -384,6 +384,32 @@ class OOTest {
} }
} }
@Test
fun testObjectSingletonSupportsExtensions() = runTest {
val scope = Script.newScope()
scope.eval(
"""
object X {
fun base() = "base"
}
fun X.decorate(value): String {
this.base() + ":" + value.toString()
}
val X.tag get() = this.base() + ":tag"
assertEquals("base", X.base())
assertEquals("base:42", X.decorate(42))
assertEquals("base:ok", X.decorate("ok"))
assertEquals("base:tag", X.tag)
// Wrapper names should be generated for singleton-object receivers too.
assertEquals("base:17", __ext__X__decorate(X, 17))
assertEquals("base:tag", __ext_get__X__tag(X))
""".trimIndent()
)
}
@Test @Test
fun testExtensionsAreScopeIsolated() = runTest { fun testExtensionsAreScopeIsolated() = runTest {
val scope1 = Script.newScope() val scope1 = Script.newScope()