Support object extension indexers

This commit is contained in:
Sergey Chernov 2026-04-07 00:49:51 +03:00
parent 8386337c42
commit 583067780f
8 changed files with 252 additions and 35 deletions

View File

@ -10,6 +10,7 @@ For a programmer-focused migration summary across 1.5.x, see `docs/whats_new_1_5
- 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` also fixes extension-member registration for named singleton `object` declarations, so `fun X.foo()` and `val X.bar` now work as expected.
- `1.5.4` also lets named singleton `object` declarations use scoped indexer extensions with bracket syntax, so patterns like `Storage["name"]` can be implemented with `override fun Storage.getAt(...)` / `putAt(...)`.
- The docs, homepage samples, and release metadata now point at the current stable version.
## User Highlights Across 1.5.x

View File

@ -2771,7 +2771,14 @@ class Compiler(
val next = cc.next()
if (next.type == Token.Type.ID) {
// could be () call or obj.method {} call
val nt = cc.current()
var nt = cc.current()
var memberCallTypeArgs: List<TypeDecl>? = null
if (nt.type == Token.Type.LT) {
memberCallTypeArgs = tryParseCallTypeArgsAfterLt()
if (memberCallTypeArgs != null) {
nt = cc.current()
}
}
when (nt.type) {
Token.Type.LPAREN -> {
cc.next()
@ -2784,9 +2791,9 @@ class Compiler(
val nestedClass = receiverClass?.let { resolveClassByName("${it.className}.${next.value}") }
if (nestedClass != null) {
val field = FieldRef(left, next.value, isOptional)
operand = CallRef(field, args, tailBlock, isOptional)
operand = CallRef(field, args, tailBlock, isOptional, memberCallTypeArgs)
} else {
operand = MethodCallRef(left, next.value, args, tailBlock, isOptional)
operand = MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs)
}
} else {
// instance method call
@ -2808,9 +2815,13 @@ class Compiler(
operand = when (left) {
is LocalVarRef -> if (left.name == "this") {
resolutionSink?.referenceMember(next.value, next.pos)
val implicitType = currentImplicitThisTypeName()
val ids = resolveMemberIds(next.value, next.pos, implicitType)
ThisMethodSlotCallRef(next.value, ids.methodId, args, tailBlock, isOptional)
if (memberCallTypeArgs != null) {
MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs)
} else {
val implicitType = currentImplicitThisTypeName()
val ids = resolveMemberIds(next.value, next.pos, implicitType)
ThisMethodSlotCallRef(next.value, ids.methodId, args, tailBlock, isOptional)
}
} else if (left.name == "scope") {
if (next.value == "get" || next.value == "set") {
val first = args.firstOrNull()?.value
@ -2820,26 +2831,32 @@ class Compiler(
resolutionSink?.referenceReflection(name.value, next.pos)
}
}
MethodCallRef(left, next.value, args, tailBlock, isOptional)
MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs)
} else {
val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, tailBlock)
if (unionCall != null) {
unionCall
} else {
enforceReceiverTypeForMember(left, next.value, next.pos)
MethodCallRef(left, next.value, args, tailBlock, isOptional)
MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs)
}
}
is QualifiedThisRef ->
QualifiedThisMethodSlotCallRef(
left.typeName,
next.value,
resolveMemberIds(next.value, next.pos, left.typeName).methodId,
args,
tailBlock,
isOptional
).also {
resolutionSink?.referenceMember(next.value, next.pos, left.typeName)
if (memberCallTypeArgs != null) {
MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs).also {
resolutionSink?.referenceMember(next.value, next.pos, left.typeName)
}
} else {
QualifiedThisMethodSlotCallRef(
left.typeName,
next.value,
resolveMemberIds(next.value, next.pos, left.typeName).methodId,
args,
tailBlock,
isOptional
).also {
resolutionSink?.referenceMember(next.value, next.pos, left.typeName)
}
}
else -> {
val unionCall = buildUnionMethodCall(left, next.value, next.pos, isOptional, args, tailBlock)
@ -2847,7 +2864,7 @@ class Compiler(
unionCall
} else {
enforceReceiverTypeForMember(left, next.value, next.pos)
MethodCallRef(left, next.value, args, tailBlock, isOptional)
MethodCallRef(left, next.value, args, tailBlock, isOptional, memberCallTypeArgs)
}
}
}
@ -3253,12 +3270,13 @@ class Compiler(
is LocalVarRef -> ref.name
is FastLocalVarRef -> ref.name
is LocalSlotRef -> ref.name
is FieldRef -> ref.name
else -> null
}
if (name != null) {
if (lookupGenericFunctionDecl(name) != null) return true
if (name.firstOrNull()?.isUpperCase() == true) return true
return false
return ref is FieldRef
}
return ref is ConstRef && ref.constValue is ObjClass
}
@ -6443,6 +6461,23 @@ class Compiler(
}
}
val implicitThisTypeName = currentImplicitThisTypeName()
if (left is FieldRef && !explicitTypeArgs.isNullOrEmpty()) {
val nestedClass = if (shouldTreatAsClassScopeCall(left.target, left.name)) {
resolveReceiverClassForMember(left.target)?.let { resolveClassByName("${it.className}.${left.name}") }
} else {
null
}
if (nestedClass == null) {
return MethodCallRef(
left.target,
left.name,
args,
detectedBlockArgument,
left.isOptional || isOptional,
explicitTypeArgs
)
}
}
val result = when (left) {
is ImplicitThisMemberRef ->
if (left.methodId == null && left.fieldId != null) {

View File

@ -4712,7 +4712,7 @@ class BytecodeCompiler(
val receiver = compileRefWithFallback(ref.receiver, null, refPosOrCurrent(ref.receiver)) ?: return null
val dst = allocSlot()
fun emitDynamicCall(): CompiledValue? {
val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null
val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
val nameId = builder.addConst(BytecodeConst.StringVal(ref.name))
if (!ref.isOptional) {
@ -4759,7 +4759,7 @@ class BytecodeCompiler(
if (methodId != null && resolvedMember?.declaringClass?.className != "Obj") {
val encodedMethodId = encodeMemberId(receiverClass, methodId) ?: methodId
if (!ref.isOptional) {
val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null
val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
setPos(callPos)
builder.emit(Opcode.CALL_MEMBER_SLOT, receiver.slot, encodedMethodId, args.base, encodedCount, dst)
@ -4778,7 +4778,7 @@ class BytecodeCompiler(
Opcode.JMP_IF_TRUE,
listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel))
)
val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null
val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
setPos(callPos)
builder.emit(Opcode.CALL_MEMBER_SLOT, receiver.slot, encodedMethodId, args.base, encodedCount, dst)
@ -4796,7 +4796,7 @@ class BytecodeCompiler(
builder.emit(Opcode.GET_MEMBER_SLOT, receiver.slot, encodedFieldId, -1, calleeSlot)
}
if (!ref.isOptional) {
val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null
val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
setPos(callPos)
builder.emit(Opcode.CALL_SLOT, calleeSlot, args.base, encodedCount, dst)
@ -4812,7 +4812,7 @@ class BytecodeCompiler(
listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel))
)
builder.emit(Opcode.GET_MEMBER_SLOT, receiver.slot, encodedFieldId, -1, calleeSlot)
val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null
val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
setPos(callPos)
builder.emit(Opcode.CALL_SLOT, calleeSlot, args.base, encodedCount, dst)
@ -4845,7 +4845,7 @@ class BytecodeCompiler(
builder.emit(Opcode.CONST_NULL, memberSlot)
builder.mark(endLabel)
}
val args = compileCallArgs(ref.args, ref.tailBlock) ?: return null
val args = compileCallArgs(ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
setPos(callPos)
builder.emit(Opcode.CALL_SLOT, memberSlot, args.base, encodedCount, dst)
@ -4858,7 +4858,7 @@ class BytecodeCompiler(
)
val callee = ensureObjSlot(extSlot)
if (!ref.isOptional) {
val args = compileCallArgsWithReceiver(receiver, ref.args, ref.tailBlock) ?: return null
val args = compileCallArgsWithReceiver(receiver, ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
setPos(callPos)
builder.emit(Opcode.CALL_SLOT, callee.slot, args.base, encodedCount, dst)
@ -4874,7 +4874,7 @@ class BytecodeCompiler(
Opcode.JMP_IF_TRUE,
listOf(CmdBuilder.Operand.IntVal(cmpSlot), CmdBuilder.Operand.LabelRef(nullLabel))
)
val args = compileCallArgsWithReceiver(receiver, ref.args, ref.tailBlock) ?: return null
val args = compileCallArgsWithReceiver(receiver, ref.args, ref.tailBlock, ref.explicitTypeArgs) ?: return null
val encodedCount = encodeCallArgCount(args) ?: return null
setPos(callPos)
builder.emit(Opcode.CALL_SLOT, callee.slot, args.base, encodedCount, dst)
@ -5067,13 +5067,14 @@ class BytecodeCompiler(
private fun compileCallArgsWithReceiver(
receiver: CompiledValue,
args: List<ParsedArgument>,
tailBlock: Boolean
tailBlock: Boolean,
explicitTypeArgs: List<TypeDecl>? = null
): CallArgs? {
val argSlots = IntArray(args.size + 1) { allocSlot() }
val receiverObj = ensureObjSlot(receiver)
builder.emit(Opcode.MOVE_OBJ, receiverObj.slot, argSlots[0])
updateSlotType(argSlots[0], SlotType.OBJ)
val needPlan = tailBlock || args.any { it.isSplat || it.name != null }
val needPlan = tailBlock || args.any { it.isSplat || it.name != null } || !explicitTypeArgs.isNullOrEmpty()
val specs = if (needPlan) ArrayList<BytecodeConst.CallArgSpec>(args.size + 1) else null
specs?.add(BytecodeConst.CallArgSpec(null, false))
for ((index, arg) in args.withIndex()) {
@ -5086,7 +5087,13 @@ class BytecodeCompiler(
specs?.add(BytecodeConst.CallArgSpec(arg.name, arg.isSplat))
}
val planId = if (needPlan) {
builder.addConst(BytecodeConst.CallArgsPlan(tailBlock, specs ?: emptyList()))
builder.addConst(
BytecodeConst.CallArgsPlan(
tailBlock = tailBlock,
specs = specs ?: emptyList(),
explicitTypeArgs = explicitTypeArgs ?: emptyList()
)
)
} else {
null
}

View File

@ -3301,7 +3301,7 @@ private suspend fun resolveDynamicFieldValue(scope: Scope, receiver: Obj, name:
}
if (rec.type == ObjRecord.Type.Fun && !rec.isAbstract) {
val recv = rec.receiver ?: receiver
return rec.value.invoke(scope, recv, Arguments.EMPTY, rec.declaringClass)
return invokeForFieldReadOrReturnCallable(scope, recv, rec, rec.declaringClass)
}
return rec.value
}

View File

@ -518,7 +518,7 @@ open class Obj {
if (rec.visibility == Visibility.Private && !rec.isAbstract) {
val resolved = resolveRecord(scope, rec, name, caller)
if (resolved.type == ObjRecord.Type.Fun)
return resolved.copy(value = resolved.value.invoke(scope, this, Arguments.EMPTY, caller))
return resolved.copy(value = invokeForFieldReadOrReturnCallable(scope, this, resolved, caller))
return resolved
}
}
@ -532,7 +532,7 @@ open class Obj {
val decl = rec.declaringClass ?: cls
val resolved = resolveRecord(scope, rec, name, decl)
if (resolved.type == ObjRecord.Type.Fun)
return resolved.copy(value = resolved.value.invoke(scope, this, Arguments.EMPTY, decl))
return resolved.copy(value = invokeForFieldReadOrReturnCallable(scope, this, resolved, decl))
return resolved
}
}
@ -547,7 +547,7 @@ open class Obj {
scope.raiseError(ObjIllegalAccessException(scope, "can't access field ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})"))
val resolved = resolveRecord(scope, rec, name, decl)
if (resolved.type == ObjRecord.Type.Fun)
return resolved.copy(value = resolved.value.invoke(scope, this, Arguments.EMPTY, decl))
return resolved.copy(value = invokeForFieldReadOrReturnCallable(scope, this, resolved, decl))
return resolved
}
}
@ -557,7 +557,7 @@ open class Obj {
val prop = ext.value as ObjProperty
ObjRecord(prop.callGetter(scope, this, ext.declaringClass), isMutable = false)
} else {
ext.copy(value = ext.value.invoke(scope, this, Arguments.EMPTY, ext.declaringClass))
ext.copy(value = invokeForFieldReadOrReturnCallable(scope, this, ext, ext.declaringClass))
}
}
@ -660,6 +660,14 @@ open class Obj {
if (hasNonRootIndexerMember("getAt")) {
return invokeInstanceMethod(scope, "getAt", Arguments(index))
}
// Extension indexers are checked only after concrete class/indexer overrides.
// If this path becomes hot in benchmarks, add a small receiver-shape/member cache here
// rather than moving extension lookup ahead of the non-root member fast path.
scope.findExtension(objClass, "getAt")?.let { ext ->
if (ext.type != ObjRecord.Type.Delegated) {
return ext.value.invoke(scope, this, Arguments(index), ext.declaringClass)
}
}
if (index is ObjString) {
return readField(scope, index.value).value
}
@ -679,6 +687,20 @@ open class Obj {
return
}
}
// Same optimization note as in getAt(): extension indexers work here, but they are
// intentionally behind concrete overrides to keep normal class-defined indexers fast.
scope.findExtension(objClass, "putAt")?.let { ext ->
if (ext.type != ObjRecord.Type.Delegated) {
ext.value.invoke(scope, this, Arguments(index, newValue), ext.declaringClass)
return
}
}
scope.findExtension(objClass, "setAt")?.let { ext ->
if (ext.type != ObjRecord.Type.Delegated) {
ext.value.invoke(scope, this, Arguments(index, newValue), ext.declaringClass)
return
}
}
if (index is ObjString) {
writeField(scope, index.value, newValue)
return
@ -1130,3 +1152,24 @@ suspend fun <T>Obj.decodeSerializableWith(strategy: DeserializationStrategy<T>,
*/
suspend inline fun <reified T>Obj.decodeSerializable(scope: Scope= Scope()) =
decodeSerializableWith<T>(serializer<T>(), scope)
internal suspend fun invokeForFieldReadOrReturnCallable(
scope: Scope,
receiver: Obj,
record: ObjRecord,
decl: ObjClass?
): Obj {
return try {
record.value.invoke(scope, receiver, Arguments.EMPTY, decl)
} catch (e: ExecutionError) {
if (e.message.isMissingArgsForAutoFieldInvoke()) record.value else throw e
} catch (e: ScriptError) {
if (e.message.isMissingArgsForAutoFieldInvoke()) record.value else throw e
}
}
private fun String?.isMissingArgsForAutoFieldInvoke(): Boolean {
val lower = this?.lowercase() ?: return false
if (!lower.contains("got 0")) return false
return lower.contains("expected") || lower.contains("missing required argument")
}

View File

@ -483,6 +483,7 @@ class MethodCallRef(
internal val args: List<ParsedArgument>,
internal val tailBlock: Boolean,
internal val isOptional: Boolean,
internal val explicitTypeArgs: List<TypeDecl>? = null,
) : ObjRef {
// 4-entry PIC for method invocations (guarded by PerfFlags.METHOD_PIC)
private var mKey1: Long = 0L; private var mVer1: Int = -1; private var mInvoker1: (suspend (Obj, Scope, Arguments) -> Obj)? = null

View File

@ -16,10 +16,20 @@
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.EvalSession
import net.sergeych.lyng.ModuleScope
import net.sergeych.lyng.Script
import net.sergeych.lyng.bridge.bindObject
import net.sergeych.lyng.bridge.data
import net.sergeych.lyng.eval
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjBuffer
import net.sergeych.lyng.obj.ObjInt
import net.sergeych.lyng.obj.ObjInstance
import net.sergeych.lyng.obj.ObjList
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.toSource
import kotlin.test.Test
import kotlin.test.assertEquals
@ -990,4 +1000,106 @@ class OOTest {
""")
}
@Test
fun testExtendingObjectWithExternals() = runTest {
val s = EvalSession()
s.eval("""
extern object Storage {
extern val spaceUsed: Int
extern val spaceAvailable: Int
/*
Return packed binary data or null
*/
extern fun getPacked(key: String): Buffer?
/*
Upsert packed binary data
*/
extern fun putPacked(key: String,value: Buffer)
/*
Delete data.
@return true if data were actually deleted, false means
there were no data for the key.
*/
extern fun delete(key: String): Bool
}
""".trimIndent()
)
val scope = s.getScope() as ModuleScope
scope.bindObject("Storage") {
init { _ ->
data = mutableMapOf<String, ObjBuffer>()
}
addVal("spaceUsed") {
val storage = (thisObj as ObjInstance).data as MutableMap<String, ObjBuffer>
ObjInt(storage.values.sumOf { it.size }.toLong())
}
addVal("spaceAvailable") {
val storage = (thisObj as ObjInstance).data as MutableMap<String, ObjBuffer>
val capacity = 1_024
ObjInt((capacity - storage.values.sumOf { it.size }).toLong())
}
addFun("getPacked") {
val storage = (thisObj as ObjInstance).data as MutableMap<String, ObjBuffer>
val key = (args.list[0] as ObjString).value
storage[key] ?: ObjNull
}
addFun("putPacked") {
val storage = (thisObj as ObjInstance).data as MutableMap<String, ObjBuffer>
val key = (args.list[0] as ObjString).value
val value = args.list[1] as ObjBuffer
storage[key] = value
ObjVoid
}
addFun("delete") {
val storage = (thisObj as ObjInstance).data as MutableMap<String, ObjBuffer>
val key = (args.list[0] as ObjString).value
ObjBool(storage.remove(key) != null)
}
}
s.eval("""
import lyng.serialization
// Use names that do not collide with Obj built-ins so extension dispatch is exercised.
override fun Storage.getAt(key: String): Object? {
Storage.getPacked(key)?.let {
Lynon.decode(it.toBitInput())
}
}
override fun Storage.putAt(key: String, value: Object) {
Storage.putPacked(key, Lynon.encode(value).toBuffer())
}
assertEquals(0, Storage.spaceUsed)
assertEquals(1024, Storage.spaceAvailable)
val missing: String? = Storage["missing"]
assertEquals(null, missing)
Storage["name"] = "alice"
Storage["count"] = 42
val name: String? = Storage["name"]
val count: Int? = Storage["count"]
assertEquals("alice", name)
assertEquals(42, count)
assert(Storage.spaceUsed > 0)
assert(Storage.spaceAvailable < 1024)
val wrappedName: String? = __ext__Storage__getAt(Storage, "name")
assertEquals("alice", wrappedName)
__ext__Storage__putAt(Storage, "flag", true)
val flag: Bool? = Storage["flag"]
assertEquals(true, flag)
assert(Storage.delete("name"))
val deletedName: String? = Storage["name"]
assertEquals(null, deletedName)
assert(!Storage.delete("name"))
""".trimIndent())
}
}

View File

@ -0,0 +1,18 @@
# Object indexer extension follow-up
Context
- `Obj.getAt` / `Obj.putAt` now support scoped extension indexers for named singleton `object` declarations, so patterns like `Storage["name"]` can route through `override fun Storage.getAt(...)` and `override fun Storage.putAt(...)`.
- Dispatch order is intentionally:
1. concrete non-root class members
2. scoped extensions
3. built-in string-field fallback
Why this order
- Normal class-defined indexers are the common fast path and should not pay extension lookup cost first.
- Checking scoped extensions earlier caused avoidable overhead for regular overridden indexers.
- Routing extension indexers through the generic root `Obj.getAt` member path caused recursion; direct extension dispatch avoids that.
Possible future optimization
- If extension-based indexers show up in hot profiles, add a tiny receiver-shape/member cache at the extension-dispatch points in `Obj.getAt` / `Obj.putAt`.
- The cache should stay behind the concrete non-root member fast path.
- A good shape key is likely `(receiver class id/layout version, member name)` with the cached extension record/wrapper.