Extensions methods and properties now are correctly isolated, respect visibility rules and allow adding class properties and class vals.

This commit is contained in:
Sergey Chernov 2025-12-24 00:29:10 +01:00
parent 3b6504d3b1
commit cd2b1a9cb7
26 changed files with 606 additions and 163 deletions

View File

@ -98,7 +98,17 @@ esac
die() { echo "ERROR: $*" 1>&2 ; exit 1; }
function refreshTextmateZip() {
echo "Refreshing distributables/lyng-textmate.zip from editors/..."
mkdir -p distributables
# We use -r for recursive and -q for quiet (optional)
# -j can be used if we want to junk paths, but the request says "contents of editors/"
# usually we want to preserve the structure inside editors/
(cd editors && zip -rq ../distributables/lyng-textmate.zip .)
}
# Update the IDEA plugin download link in docs (temporarily), then build, then restore the doc
refreshTextmateZip
updateIdeaPluginDownloadLink || echo "WARN: proceeding without updating IDEA plugin download link"
./gradlew site:jsBrowserDistribution

View File

@ -491,9 +491,11 @@ As usual, private statics are not accessible from the outside:
# Extending classes
It sometimes happen that the class is missing some particular functionality that can be _added to it_ without rewriting its inner logic and using its private state. In this case _extension methods_ could be used, for example. we want to create an extension method
that would test if some object of unknown type contains something that can be interpreted
as an integer. In this case we _extend_ class `Object`, as it is the parent class for any instance of any type:
It sometimes happen that the class is missing some particular functionality that can be _added to it_ without rewriting its inner logic and using its private state. In this case _extension members_ could be used.
## Extension methods
For example, we want to create an extension method that would test if some object of unknown type contains something that can be interpreted as an integer. In this case we _extend_ class `Object`, as it is the parent class for any instance of any type:
fun Object.isInteger() {
when(this) {
@ -518,10 +520,67 @@ as an integer. In this case we _extend_ class `Object`, as it is the parent clas
assert( ! "5.2".isInteger() )
>>> void
__Important note__ as for version 0.6.9, extensions are in __global scope__. It means, that once applied to a global type (Int in our sample), they will be available for _all_ contexts, even new created,
as they are modifying the type, not the context.
## Extension properties
Beware of it. We might need to reconsider it later.
Just like methods, you can extend existing classes with properties. These can be defined using simple initialization (for `val` only) or with custom accessors.
### Simple val extension
A read-only extension can be defined by assigning an expression:
```kotlin
val String.isLong = length > 10
val s = "Hello, world!"
assert(s.isLong)
```
### Properties with accessors
For more complex logic, use `get()` and `set()` blocks:
```kotlin
class Box(var value: Int)
var Box.doubledValue
get() = value * 2
set(v) = value = v / 2
val b = Box(10)
assertEquals(20, b.doubledValue)
b.doubledValue = 30
assertEquals(15, b.value)
```
Extension members are strictly barred from accessing private members of the class they extend, maintaining encapsulation.
### Extension Scoping and Isolation
Extensions in Lyng are **scope-isolated**. This means an extension is only visible within the scope where it is defined and its child scopes. This reduces the "attack surface" and prevents extensions from polluting the global space or other modules.
#### Scope Isolation Example
You can define different extensions with the same name in different scopes:
```kotlin
fun scopeA() {
val Int.description = "Number: " + toString()
assertEquals("Number: 42", 42.description)
}
fun scopeB() {
val Int.description = "Value: " + toString()
assertEquals("Value: 42", 42.description)
}
scopeA()
scopeB()
// Outside those scopes, Int.description is not defined
assertThrows { 42.description }
```
This isolation ensures that libraries can use extensions internally without worrying about name collisions with other libraries or the user's code. When a module is imported using `use`, its top-level extensions become available in the importing scope.
## dynamic symbols

View File

@ -1608,4 +1608,28 @@ Notes:
- `private` is visible only inside the declaring class; `protected` is visible from the declaring class and any of its transitive subclasses. Qualialsofication (`this@Type`) or casts do not bypass visibility.
- Safe‑call `?.` works with `as?` for optional dispatch.
To get details on OOP in Lyng, see [OOP notes](oop.md).
## Extension members
You can add new methods and properties to existing classes without modifying them.
### Extension functions
fun String.shout() = this.upper() + "!!!"
"hello".shout()
>>> "HELLO!!!"
### Extension properties
val Int.isEven = this % 2 == 0
4.isEven
>>> true
Example with custom accessors:
val String.firstChar get() = this[0]
"abc".firstChar
>>> 'a'
Extension members are **scope-isolated**: they are visible only in the scope where they are defined and its children. This prevents name collisions and improves security.
To get details on OOP in Lyng, see [OOP notes](OOP.md).

View File

@ -76,7 +76,7 @@
},
"labels": { "patterns": [ { "name": "entity.name.label.lyng", "match": "[\\p{L}_][\\p{L}\\p{N}_]*:" } ] },
"directives": { "patterns": [ { "name": "meta.directive.lyng", "match": "^\\s*#[_A-Za-z][_A-Za-z0-9]*" } ] },
"declarations": { "patterns": [ { "name": "meta.function.declaration.lyng", "match": "\\b(?:fun|fn)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.function.lyng" } } }, { "name": "meta.type.declaration.lyng", "match": "\\b(?:class|enum)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.type.lyng" } } }, { "name": "meta.variable.declaration.lyng", "match": "\\b(?:val|var)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "variable.other.declaration.lyng" } } } ] },
"declarations": { "patterns": [ { "name": "meta.function.declaration.lyng", "match": "\\b(fun|fn)\\s+(?:([\\p{L}_][\\p{L}\\p{N}_]*)\\.)?([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "keyword.declaration.lyng" }, "2": { "name": "entity.name.type.lyng" }, "3": { "name": "entity.name.function.lyng" } } }, { "name": "meta.type.declaration.lyng", "match": "\\b(?:class|enum)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.type.lyng" } } }, { "name": "meta.variable.declaration.lyng", "match": "\\b(val|var)\\s+(?:([\\p{L}_][\\p{L}\\p{N}_]*)\\.)?([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "keyword.declaration.lyng" }, "2": { "name": "entity.name.type.lyng" }, "3": { "name": "variable.other.declaration.lyng" } } } ] },
"keywords": { "patterns": [ { "name": "keyword.control.lyng", "match": "\\b(?:if|else|when|while|do|for|try|catch|finally|throw|return|break|continue)\\b" }, { "name": "keyword.declaration.lyng", "match": "\\b(?:fun|fn|class|enum|val|var|import|package|constructor|property|open|extern|private|protected|static|get|set)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\bnot\\s+(?:in|is)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\b(?:and|or|not|in|is|as|as\\?)\\b" } ] },
"constants": { "patterns": [ { "name": "constant.language.lyng", "match": "(?:\\b(?:true|false|null|this)\\b|π)" } ] },
"types": { "patterns": [ { "name": "storage.type.lyng", "match": "\\b(?:Int|Real|String|Bool|Char|Regex)\\b" }, { "name": "entity.name.type.lyng", "match": "\\b[A-Z][A-Za-z0-9_]*\\b(?!\\s*\\()" } ] },

View File

@ -157,8 +157,12 @@ class LyngExternalAnnotator : ExternalAnnotator<LyngExternalAnnotator.Input, Lyn
is MiniFunDecl -> {
addTypeSegments(d.returnType)
d.params.forEach { addTypeSegments(it.type) }
addTypeSegments(d.receiver)
}
is MiniValDecl -> {
addTypeSegments(d.type)
addTypeSegments(d.receiver)
}
is MiniValDecl -> addTypeSegments(d.type)
is MiniClassDecl -> {
d.ctorFields.forEach { addTypeSegments(it.type) }
d.classFields.forEach { addTypeSegments(it.type) }

View File

@ -184,7 +184,7 @@ class LyngCompletionContributor : CompletionContributor() {
?: guessReturnClassAcrossKnownCallees(text, memberDotPos, imported)
?: guessReceiverClass(text, memberDotPos, imported)
if (!inferredClass.isNullOrBlank()) {
val ext = BuiltinDocRegistry.extensionMethodNamesFor(inferredClass)
val ext = BuiltinDocRegistry.extensionMemberNamesFor(inferredClass)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Post-engine extension check for $inferredClass: ${'$'}{ext}")
for (name in ext) {
if (existing.contains(name)) continue
@ -494,10 +494,10 @@ class LyngCompletionContributor : CompletionContributor() {
}
}
// Supplement with stdlib extension-like methods defined in root.lyng (e.g., fun String.trim(...))
// Supplement with stdlib extension members defined in root.lyng (e.g., fun String.trim(...))
run {
val already = (directMap.keys + inheritedMap.keys).toMutableSet()
val ext = BuiltinDocRegistry.extensionMethodNamesFor(className)
val ext = BuiltinDocRegistry.extensionMemberNamesFor(className)
if (DEBUG_COMPLETION) log.info("[LYNG_DEBUG] Extensions for $className: count=${ext.size} -> ${ext}")
for (name in ext) {
if (already.contains(name)) continue

View File

@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych"
version = "1.1.0-beta1"
version = "1.1.0-SNAPSHOT"
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below

View File

@ -49,12 +49,14 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
arguments: Arguments = scope.args,
defaultAccessType: AccessType = AccessType.Var,
defaultVisibility: Visibility = Visibility.Public,
declaringClass: net.sergeych.lyng.obj.ObjClass? = scope.currentClassCtx
) {
fun assign(a: Item, value: Obj) {
scope.addItem(a.name, (a.accessType ?: defaultAccessType).isMutable,
value.byValueCopy(),
a.visibility ?: defaultVisibility,
recordType = ObjRecord.Type.Argument)
recordType = ObjRecord.Type.Argument,
declaringClass = declaringClass)
}
// Prepare positional args and parameter count, handle tail-block binding

View File

@ -58,17 +58,25 @@ class ClosureScope(val callScope: Scope, val closureScope: Scope) :
?.instanceScope
?.objects
?.get(name)
?.let { return it }
closureScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it }
?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) return rec
}
findExtension(closureScope.thisObj.objClass, name)?.let { return it }
closureScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) return rec
}
// 3) Closure scope chain (locals/parents + members), ignore ClosureScope overrides to prevent recursion
closureScope.chainLookupWithMembers(name)?.let { return it }
closureScope.chainLookupWithMembers(name, currentClassCtx)?.let { return it }
// 4) Caller `this` members
callScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it }
findExtension(callScope.thisObj.objClass, name)?.let { return it }
callScope.thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) return rec
}
// 5) Caller chain (locals/parents + members)
callScope.chainLookupWithMembers(name)?.let { return it }
callScope.chainLookupWithMembers(name, currentClassCtx)?.let { return it }
// 6) Module pseudo-symbols (e.g., __PACKAGE__) — walk caller ancestry and query ModuleScope directly
if (name.startsWith("__")) {

View File

@ -2366,6 +2366,7 @@ class Compiler(
throw ScriptError(t.pos, "Expected identifier after 'fun'")
else t.value
var nameStartPos: Pos = t.pos
var receiverMini: MiniTypeRef? = null
val annotation = lastAnnotation
val parentContext = codeContexts.last()
@ -2374,6 +2375,12 @@ class Compiler(
// Is extension?
if (t.type == Token.Type.DOT) {
extTypeName = name
val receiverEnd = Pos(start.source, start.line, start.column + name.length)
receiverMini = MiniTypeName(
range = MiniRange(start, receiverEnd),
segments = listOf(MiniTypeName.Segment(name, MiniRange(start, receiverEnd))),
nullable = false
)
t = cc.next()
if (t.type != Token.Type.ID)
throw ScriptError(t.pos, "illegal extension format: expected function name")
@ -2417,7 +2424,8 @@ class Compiler(
returnType = returnTypeMini,
body = null,
doc = declDocLocal,
nameStart = nameStartPos
nameStart = nameStartPos,
receiver = receiverMini
)
miniSink?.onFunDecl(node)
pendingDeclDoc = null
@ -2486,7 +2494,7 @@ class Compiler(
// class extension method
val type = context[typeName]?.value ?: context.raiseSymbolNotFound("class $typeName not found")
if (type !is ObjClass) context.raiseClassCastError("$typeName is not the class instance")
type.addFn(name, isOpen = true, visibility = visibility) {
val stmt = statement {
// ObjInstance has a fixed instance scope, so we need to build a closure
(thisObj as? ObjInstance)?.let { i ->
annotatedFnBody.execute(ClosureScope(this, i.instanceScope))
@ -2494,6 +2502,7 @@ class Compiler(
// other classes can create one-time scope for this rare case:
?: annotatedFnBody.execute(thisObj.autoInstanceScope(this))
}
context.addExtension(type, name, ObjRecord(stmt, isMutable = false, visibility = visibility, declaringClass = null))
}
// regular function/method
?: run {
@ -2640,7 +2649,27 @@ class Compiler(
if (nextToken.type != Token.Type.ID)
throw ScriptError(nextToken.pos, "Expected identifier or [ here")
val name = nextToken.value
var name = nextToken.value
var extTypeName: String? = null
var nameStartPos: Pos = nextToken.pos
var receiverMini: MiniTypeRef? = null
if (cc.peekNextNonWhitespace().type == Token.Type.DOT) {
cc.skipWsTokens()
cc.next() // consume dot
extTypeName = name
val receiverEnd = Pos(nextToken.pos.source, nextToken.pos.line, nextToken.pos.column + name.length)
receiverMini = MiniTypeName(
range = MiniRange(nextToken.pos, receiverEnd),
segments = listOf(MiniTypeName.Segment(name, MiniRange(nextToken.pos, receiverEnd))),
nullable = false
)
val nameToken = cc.next()
if (nameToken.type != Token.Type.ID)
throw ScriptError(nameToken.pos, "Expected identifier after dot in extension declaration")
name = nameToken.value
nameStartPos = nameToken.pos
}
// Optional explicit type annotation
val varTypeMini: MiniTypeRef? = if (cc.current().type == Token.Type.COLON) {
@ -2648,19 +2677,22 @@ class Compiler(
} else null
val markBeforeEq = cc.savePos()
cc.skipWsTokens()
val eqToken = cc.next()
var setNull = false
var isProperty = false
val declaringClassNameCaptured = (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.name
if (declaringClassNameCaptured != null) {
if (declaringClassNameCaptured != null || extTypeName != null) {
val mark = cc.savePos()
cc.restorePos(markBeforeEq)
cc.skipWsTokens()
val next = cc.peekNextNonWhitespace()
if (next.isId("get") || next.isId("set")) {
isProperty = true
cc.restorePos(markBeforeEq)
cc.skipWsTokens()
} else {
cc.restorePos(mark)
}
@ -2673,10 +2705,11 @@ class Compiler(
true
} else {
if (!isProperty && eqToken.type != Token.Type.ASSIGN) {
if (!isMutable && (declaringClassNameCaptured == null))
if (!isMutable && (declaringClassNameCaptured == null) && (extTypeName == null))
throw ScriptError(start, "val must be initialized")
else {
cc.restorePos(markBeforeEq)
cc.skipWsTokens()
setNull = true
}
}
@ -2698,7 +2731,8 @@ class Compiler(
type = varTypeMini,
initRange = initR,
doc = pendingDeclDoc,
nameStart = start
nameStart = nameStartPos,
receiver = receiverMini
)
miniSink?.onValDecl(node)
pendingDeclDoc = null
@ -2722,7 +2756,7 @@ class Compiler(
// Check for accessors if it is a class member
var getter: Statement? = null
var setter: Statement? = null
if (declaringClassNameCaptured != null) {
if (declaringClassNameCaptured != null || extTypeName != null) {
while (true) {
val t = cc.peekNextNonWhitespace()
if (t.isId("get")) {
@ -2785,6 +2819,22 @@ class Compiler(
}
return statement(start) { context ->
if (extTypeName != null) {
val prop = if (getter != null || setter != null) {
ObjProperty(name, getter, setter)
} else {
// Simple val extension with initializer
val initExpr = initialExpression ?: throw ScriptError(start, "Extension val must be initialized")
ObjProperty(name, statement(initExpr.pos) { scp -> initExpr.execute(scp) }, null)
}
val type = context[extTypeName]?.value ?: context.raiseSymbolNotFound("class $extTypeName not found")
if (type !is ObjClass) context.raiseClassCastError("$extTypeName is not the class instance")
context.addExtension(type, name, ObjRecord(prop, isMutable = false, visibility = visibility, declaringClass = null, type = ObjRecord.Type.Property))
return@statement prop
}
// In true class bodies (not inside a function), store fields under a class-qualified key to support MI collisions
// Do NOT infer declaring class from runtime thisObj here; only the compile-time captured
// ClassBody qualifies for class-field storage. Otherwise, this is a plain local.

View File

@ -45,19 +45,36 @@ class ModuleScope(
if (record.visibility.isPublic) {
val newName = symbols?.let { ss: Map<String, String> ->
ss[symbol]
?.also { symbolsToImport!!.remove(it) }
?: scope.raiseError("internal error: symbol $symbol not found though the module is cached")
} ?: symbol
val existing = scope.objects[newName]
if (existing != null ) {
if (existing.importedFrom != record.importedFrom)
scope.raiseError("symbol ${existing.importedFrom?.packageName}.$newName already exists, redefinition on import is not allowed")
// already imported
?.also { symbolsToImport!!.remove(symbol) }
?: return@let null
} ?: if (symbols == null) symbol else null
if (newName != null) {
val existing = scope.objects[newName]
if (existing != null) {
if (existing.importedFrom != record.importedFrom)
scope.raiseError("symbol ${existing.importedFrom?.packageName}.$newName already exists, redefinition on import is not allowed")
// already imported
} else {
// when importing records, we keep track of its package (not otherwise needed)
if (record.importedFrom == null) record.importedFrom = this
scope.objects[newName] = record
}
}
else {
// when importing records, we keep track of its package (not otherwise needed)
if (record.importedFrom == null) record.importedFrom = this
scope.objects[newName] = record
}
}
for ((cls, map) in this.extensions) {
for ((symbol, record) in map) {
if (record.visibility.isPublic) {
val newName = symbols?.let { ss: Map<String, String> ->
ss[symbol]
?.also { symbolsToImport!!.remove(symbol) }
?: return@let null
} ?: if (symbols == null) symbol else null
if (newName != null) {
scope.addExtension(cls, newName, record)
}
}
}
}

View File

@ -62,6 +62,30 @@ open class Scope(
*/
internal val localBindings: MutableMap<String, ObjRecord> = mutableMapOf()
internal val extensions: MutableMap<ObjClass, MutableMap<String, ObjRecord>> = mutableMapOf()
fun addExtension(cls: ObjClass, name: String, record: ObjRecord) {
extensions.getOrPut(cls) { mutableMapOf() }[name] = record
}
internal fun findExtension(receiverClass: ObjClass, name: String): ObjRecord? {
var s: Scope? = this
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) break
// Proximity rule: check all extensions in the current scope before going to parent.
// Priority within scope: more specific class in MRO wins.
for (cls in receiverClass.mro) {
s.extensions[cls]?.get(name)?.let { return it }
}
if (s is ClosureScope) {
s.closureScope.findExtension(receiverClass, name)?.let { return it }
}
s = s.parent
}
return null
}
/** Debug helper: ensure assigning [candidateParent] does not create a structural cycle. */
private fun ensureNoCycle(candidateParent: Scope?) {
if (candidateParent == null) return
@ -82,10 +106,17 @@ open class Scope(
* intertwined closure frames. They traverse the plain parent chain and consult only locals
* and bindings of each frame. Instance/class member fallback must be decided by the caller.
*/
private fun tryGetLocalRecord(s: Scope, name: String): ObjRecord? {
s.objects[name]?.let { return it }
s.localBindings[name]?.let { return it }
s.getSlotIndexOf(name)?.let { return s.getSlotRecord(it) }
private fun tryGetLocalRecord(s: Scope, name: String, caller: net.sergeych.lyng.obj.ObjClass?): ObjRecord? {
s.objects[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
}
s.localBindings[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
}
s.getSlotIndexOf(name)?.let { idx ->
val rec = s.getSlotRecord(idx)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
}
return null
}
@ -95,7 +126,7 @@ open class Scope(
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it }
tryGetLocalRecord(s, name, currentClassCtx)?.let { return it }
s = s.parent
}
return null
@ -110,17 +141,22 @@ open class Scope(
*/
internal fun baseGetIgnoreClosure(name: String): ObjRecord? {
// 1) locals/bindings in this frame
tryGetLocalRecord(this, name)?.let { return it }
tryGetLocalRecord(this, name, currentClassCtx)?.let { return it }
// 2) walk parents for plain locals/bindings only
var s = parent
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it }
tryGetLocalRecord(s, name, currentClassCtx)?.let { return it }
s = s.parent
}
// 3) fallback to instance/class members of this frame's thisObj
return thisObj.objClass.getInstanceMemberOrNull(name)
for (cls in thisObj.objClass.mro) {
this.extensions[cls]?.get(name)?.let { return it }
}
return thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
}
}
/**
@ -130,13 +166,18 @@ open class Scope(
* This completely avoids invoking overridden `get` implementations, preventing
* ping-pong recursion between `ClosureScope` frames.
*/
internal fun chainLookupWithMembers(name: String): ObjRecord? {
internal fun chainLookupWithMembers(name: String, caller: net.sergeych.lyng.obj.ObjClass? = currentClassCtx): ObjRecord? {
var s: Scope? = this
val visited = HashSet<Long>(4)
while (s != null) {
if (!visited.add(s.frameId)) return null
tryGetLocalRecord(s, name)?.let { return it }
s.thisObj.objClass.getInstanceMemberOrNull(name)?.let { return it }
tryGetLocalRecord(s, name, caller)?.let { return it }
for (cls in s.thisObj.objClass.mro) {
s.extensions[cls]?.get(name)?.let { return it }
}
s.thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, caller)) return rec
}
s = s.parent
}
return null
@ -152,6 +193,10 @@ open class Scope(
// copy locals and bindings
snap.objects.putAll(this.objects)
snap.localBindings.putAll(this.localBindings)
// copy extensions
for ((cls, map) in extensions) {
snap.extensions[cls] = map.toMutableMap()
}
// copy slots map preserving indices
if (this.slotCount() > 0) {
var i = 0
@ -273,13 +318,19 @@ open class Scope(
if (name == "this") thisObj.asReadonly
else {
// Prefer direct locals/bindings declared in this frame
(objects[name]
(objects[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
}
// Then, check known local bindings in this frame (helps after suspension)
?: localBindings[name]
?: localBindings[name]?.let { rec ->
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
}
// Walk up ancestry
?: parent?.get(name)
// Finally, fallback to class members on thisObj
?: thisObj.objClass.getInstanceMemberOrNull(name)
?: thisObj.objClass.getInstanceMemberOrNull(name)?.let { rec ->
if (canAccessMember(rec.visibility, rec.declaringClass, currentClassCtx)) rec else null
}
)
}
@ -317,6 +368,7 @@ open class Scope(
slots.clear()
nameToSlot.clear()
localBindings.clear()
extensions.clear()
// Now safe to validate and re-parent
ensureNoCycle(parent)
this.parent = parent
@ -400,9 +452,10 @@ open class Scope(
isMutable: Boolean,
value: Obj,
visibility: Visibility = Visibility.Public,
recordType: ObjRecord.Type = ObjRecord.Type.Other
recordType: ObjRecord.Type = ObjRecord.Type.Other,
declaringClass: net.sergeych.lyng.obj.ObjClass? = currentClassCtx
): ObjRecord {
val rec = ObjRecord(value, isMutable, visibility, declaringClass = currentClassCtx, type = recordType)
val rec = ObjRecord(value, isMutable, visibility, declaringClass = declaringClass, type = recordType)
objects[name] = rec
// Index this binding within the current frame to help resolve locals across suspension
localBindings[name] = rec

View File

@ -26,7 +26,7 @@ enum class Visibility {
/** MI-aware visibility check: whether [caller] can access a member declared in [decl] with [visibility]. */
fun canAccessMember(visibility: Visibility, decl: net.sergeych.lyng.obj.ObjClass?, caller: net.sergeych.lyng.obj.ObjClass?): Boolean {
return when (visibility) {
val res = when (visibility) {
Visibility.Public -> true
Visibility.Private -> (decl != null && caller === decl)
Visibility.Protected -> when {
@ -36,4 +36,5 @@ fun canAccessMember(visibility: Visibility, decl: net.sergeych.lyng.obj.ObjClass
else -> (caller.allParentsSet.contains(decl))
}
}
return res
}

View File

@ -105,20 +105,24 @@ object BuiltinDocRegistry : BuiltinDocSource {
}
/**
* List names of extension-like methods defined for [className] in the stdlib text (`root.lyng`).
* We do a lightweight regex scan like: `fun ClassName.methodName(` and collect distinct names.
* List names of extension members defined for [className] in the stdlib text (`root.lyng`).
* We do a lightweight regex scan like: `fun ClassName.methodName(` or `val ClassName.propName`
* and collect distinct names.
*/
fun extensionMethodNamesFor(className: String): List<String> {
fun extensionMemberNamesFor(className: String): List<String> {
val src = try { rootLyng } catch (_: Throwable) { null } ?: return emptyList()
val out = LinkedHashSet<String>()
// Match lines like: fun String.trim(...)
val re = Regex("(?m)^\\s*fun\\s+${className}\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\(")
// Match lines like: fun String.trim(...) or val Int.isEven = ...
val re = Regex("(?m)^\\s*(?:fun|val|var)\\s+${className}\\.([A-Za-z_][A-Za-z0-9_]*)\\b")
re.findAll(src).forEach { m ->
val name = m.groupValues.getOrNull(1)?.trim()
if (!name.isNullOrEmpty()) out.add(name)
}
return out.toList()
}
@Deprecated("Use extensionMemberNamesFor", ReplaceWith("extensionMemberNamesFor(className)"))
fun extensionMethodNamesFor(className: String): List<String> = extensionMemberNamesFor(className)
}
// ---------------- Builders ----------------
@ -367,8 +371,8 @@ private object StdlibInlineDocIndex {
else -> {
// Non-comment, non-blank: try to match a declaration just after comments
if (buf.isNotEmpty()) {
// fun Class.name( ... )
val mExt = Regex("^fun\\s+([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_]*)\\s*\\(").find(line)
// fun/val/var Class.name( ... )
val mExt = Regex("^(?:fun|val|var)\\s+([A-Za-z_][A-Za-z0-9_]*)\\.([A-Za-z_][A-Za-z0-9_]*)\\b").find(line)
if (mExt != null) {
val (cls, name) = mExt.destructured
flushTo(Key.Method(cls, name))

View File

@ -208,10 +208,10 @@ object CompletionEngineLight {
emitGroup(directMap)
emitGroup(inheritedMap)
// Supplement with stdlib extension-like methods defined in root.lyng (e.g., fun String.re(...))
// Supplement with stdlib extension members defined in root.lyng (e.g., fun String.re(...))
run {
val already = (directMap.keys + inheritedMap.keys).toMutableSet()
val ext = BuiltinDocRegistry.extensionMethodNamesFor(className)
val ext = BuiltinDocRegistry.extensionMemberNamesFor(className)
for (name in ext) {
if (already.contains(name)) continue
val resolved = DocLookupUtils.resolveMemberWithInheritance(imported, className, name)

View File

@ -100,7 +100,7 @@ fun ObjClass.addFnDoc(
code: suspend Scope.() -> Obj
) {
// Register runtime method
addFn(name, isOpen, visibility, code)
addFn(name, isOpen, visibility, code = code)
// Register docs for the member under this class
BuiltinDocRegistry.module(moduleName ?: ownerModuleNameFromClassOrUnknown()) {
classDoc(this@addFnDoc.className, doc = "") {

View File

@ -109,7 +109,8 @@ data class MiniFunDecl(
val returnType: MiniTypeRef?,
val body: MiniBlock?,
override val doc: MiniDoc?,
override val nameStart: Pos
override val nameStart: Pos,
val receiver: MiniTypeRef? = null
) : MiniDecl
data class MiniValDecl(
@ -119,7 +120,8 @@ data class MiniValDecl(
val type: MiniTypeRef?,
val initRange: MiniRange?,
override val doc: MiniDoc?,
override val nameStart: Pos
override val nameStart: Pos,
val receiver: MiniTypeRef? = null
) : MiniDecl
data class MiniClassDecl(

View File

@ -85,21 +85,50 @@ open class Obj {
args: Arguments = Arguments.EMPTY,
onNotFoundResult: (suspend () -> Obj?)? = null
): Obj {
val rec = objClass.getInstanceMemberOrNull(name)
if (rec != null) {
val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name)
val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(ObjAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})"))
// Propagate declaring class as current class context during method execution
val saved = scope.currentClassCtx
scope.currentClassCtx = decl
try {
return rec.value.invoke(scope, this, args)
} finally {
scope.currentClassCtx = saved
// 1. Hierarchy members (excluding root fallback)
for (cls in objClass.mro) {
if (cls.className == "Obj") break
val rec = cls.members[name] ?: cls.classScope?.objects?.get(name)
if (rec != null) {
val decl = rec.declaringClass ?: cls
val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(ObjAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})"))
val saved = scope.currentClassCtx
scope.currentClassCtx = decl
try {
return rec.value.invoke(scope, this, args)
} finally {
scope.currentClassCtx = saved
}
}
}
// 2. Extensions in scope
val extension = scope.findExtension(objClass, name)
if (extension != null) {
return extension.value.invoke(scope, this, args)
}
// 3. Root object fallback
for (cls in objClass.mro) {
if (cls.className == "Obj") {
cls.members[name]?.let { rec ->
val decl = rec.declaringClass ?: cls
val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(ObjAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})"))
val saved = scope.currentClassCtx
scope.currentClassCtx = decl
try {
return rec.value.invoke(scope, this, args)
} finally {
scope.currentClassCtx = saved
}
}
}
}
return onNotFoundResult?.invoke()
?: scope.raiseError(
"no such member: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}. " +
@ -313,34 +342,83 @@ open class Obj {
// suspend fun <T> sync(block: () -> T): T = monitor.withLock { block() }
open suspend fun readField(scope: Scope, name: String): ObjRecord {
// could be property or class field:
val obj = objClass.getInstanceMemberOrNull(name) ?: scope.raiseError(
// 1. Hierarchy members (excluding root fallback)
for (cls in objClass.mro) {
if (cls.className == "Obj") break
cls.members[name]?.let { return resolveRecord(scope, it, name, it.declaringClass) }
cls.classScope?.objects?.get(name)?.let { return resolveRecord(scope, it, name, it.declaringClass) }
}
// 2. Extensions
val extension = scope.findExtension(objClass, name)
if (extension != null) {
return resolveRecord(scope, extension, name, extension.declaringClass)
}
// 3. Root fallback
for (cls in objClass.mro) {
if (cls.className == "Obj") {
cls.members[name]?.let {
val decl = it.declaringClass ?: cls
return resolveRecord(scope, it, name, decl)
}
}
}
scope.raiseError(
"no such field: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}"
)
val decl = obj.declaringClass ?: objClass.findDeclaringClassOf(name)
}
protected suspend fun resolveRecord(scope: Scope, obj: ObjRecord, name: String, decl: ObjClass?): ObjRecord {
val value = obj.value
if (value is ObjProperty) {
return ObjRecord(value.callGetter(scope, this, decl), obj.isMutable)
}
if (value is Statement && decl != null) {
return ObjRecord(value.execute(scope.createChildScope(scope.pos, newThisObj = this)), obj.isMutable)
}
val caller = scope.currentClassCtx
// Check visibility for non-property members here if they weren't checked before
if (!canAccessMember(obj.visibility, decl, caller))
scope.raiseError(ObjAccessException(scope, "can't access field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})"))
return when (val value = obj.value) {
is Statement -> {
ObjRecord(value.execute(scope.createChildScope(scope.pos, newThisObj = this)), obj.isMutable)
}
// could be writable property naturally
// null -> ObjNull.asReadonly
else -> obj
}
return obj
}
open suspend fun writeField(scope: Scope, name: String, newValue: Obj) {
willMutate(scope)
val field = objClass.getInstanceMemberOrNull(name) ?: scope.raiseError(
var field: ObjRecord? = null
// 1. Hierarchy members (excluding root fallback)
for (cls in objClass.mro) {
if (cls.className == "Obj") break
field = cls.members[name] ?: cls.classScope?.objects?.get(name)
if (field != null) break
}
// 2. Extensions
if (field == null) {
field = scope.findExtension(objClass, name)
}
// 3. Root fallback
if (field == null) {
for (cls in objClass.mro) {
if (cls.className == "Obj") {
field = cls.members[name]
if (field != null) break
}
}
}
if (field == null) scope.raiseError(
"no such field: $name on ${objClass.className}. Considered order: ${objClass.renderLinearization(true)}"
)
val decl = field.declaringClass ?: objClass.findDeclaringClassOf(name)
val decl = field.declaringClass
val caller = scope.currentClassCtx
if (!canAccessMember(field.visibility, decl, caller))
scope.raiseError(ObjAccessException(scope, "can't assign field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})"))
if (field.isMutable) field.value = newValue else scope.raiseError("can't assign to read-only field: $name")
if (field.value is ObjProperty) {
(field.value as ObjProperty).callSetter(scope, this, newValue, decl)
} else if (field.isMutable) field.value = newValue else scope.raiseError("can't assign to read-only field: $name")
}
open suspend fun getAt(scope: Scope, index: Obj): Obj {

View File

@ -53,6 +53,12 @@ class ObjChar(val value: Char): Obj() {
returns = type("lyng.Int"),
moduleName = "lyng.stdlib"
) { ObjInt(thisAs<ObjChar>().value.code.toLong()) }
addFn("isDigit") {
thisAs<ObjChar>().value.isDigit().toObj()
}
addFn("isSpace") {
thisAs<ObjChar>().value.isWhitespace().toObj()
}
}
}
}

View File

@ -175,7 +175,11 @@ open class ObjClass(
}
/** Full C3 MRO including this class at index 0. */
val mro: List<ObjClass> by lazy { c3Linearize(this, mutableMapOf()) }
val mro: List<ObjClass> by lazy {
val base = c3Linearize(this, mutableMapOf())
if (this.className == "Obj" || base.any { it.className == "Obj" }) base
else base + rootObjectType
}
/** Parents in C3 order (no self). */
val mroParents: List<ObjClass> by lazy { mro.drop(1) }
@ -216,6 +220,7 @@ open class ObjClass(
// remains stable even when call frames are pooled and reused.
val stableParent = classScope ?: scope.parent
instance.instanceScope = Scope(stableParent, scope.args, scope.pos, instance)
instance.instanceScope.currentClassCtx = null
// Expose instance methods (and other callable members) directly in the instance scope for fast lookup
// This mirrors Obj.autoInstanceScope behavior for ad-hoc scopes and makes fb.method() resolution robust
// 1) members-defined methods
@ -268,7 +273,7 @@ open class ObjClass(
c.constructorMeta?.let { meta ->
val argsHere = argsForThis ?: Arguments.EMPTY
// Assign constructor params into instance scope (unmangled)
meta.assignToContext(instance.instanceScope, argsHere)
meta.assignToContext(instance.instanceScope, argsHere, declaringClass = c)
// Also expose them under MI-mangled storage keys `${Class}::name` so qualified views can access them
// and so that base-class casts like `(obj as Base).field` work.
for (p in meta.params) {
@ -297,7 +302,7 @@ open class ObjClass(
// parameters even if they were shadowed/overwritten by parent class initialization.
c.constructorMeta?.let { meta ->
val argsHere = argsForThis ?: Arguments.EMPTY
meta.assignToContext(instance.instanceScope, argsHere)
meta.assignToContext(instance.instanceScope, argsHere, declaringClass = c)
// Re-sync mangled names to point to the fresh records to keep them consistent
for (p in meta.params) {
val rec = instance.instanceScope.objects[p.name]
@ -340,14 +345,15 @@ open class ObjClass(
initialValue: Obj,
isMutable: Boolean = false,
visibility: Visibility = Visibility.Public,
pos: Pos = Pos.builtIn
pos: Pos = Pos.builtIn,
declaringClass: ObjClass? = this
) {
// Allow overriding ancestors: only prevent redefinition if THIS class already defines an immutable member
val existingInSelf = members[name]
if (existingInSelf != null && existingInSelf.isMutable == false)
throw ScriptError(pos, "$name is already defined in $objClass")
// Install/override in this class
members[name] = ObjRecord(initialValue, isMutable, visibility, declaringClass = this)
members[name] = ObjRecord(initialValue, isMutable, visibility, declaringClass = declaringClass)
// Structural change: bump layout version for PIC invalidation
layoutVersion += 1
}
@ -377,10 +383,11 @@ open class ObjClass(
name: String,
isOpen: Boolean = false,
visibility: Visibility = Visibility.Public,
declaringClass: ObjClass? = this,
code: suspend Scope.() -> Obj
) {
val stmt = statement { code() }
createField(name, stmt, isOpen, visibility)
createField(name, stmt, isOpen, visibility, Pos.builtIn, declaringClass)
}
fun addConst(name: String, value: Obj) = createField(name, value, isMutable = false)

View File

@ -33,9 +33,10 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
override suspend fun readField(scope: Scope, name: String): ObjRecord {
// Direct (unmangled) lookup first
instanceScope[name]?.let { rec ->
val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name)
val decl = rec.declaringClass
// Allow unconditional access when accessing through `this` of the same instance
if (scope.thisObj !== this) {
// BUT only if we are in the class context (not extension)
if (scope.thisObj !== this || scope.currentClassCtx == null) {
val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(
@ -47,7 +48,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
}
if (rec.type == ObjRecord.Type.Property) {
val prop = rec.value as ObjProperty
return rec.copy(value = prop.callGetter(scope, this))
return rec.copy(value = prop.callGetter(scope, this, decl))
}
return rec
}
@ -70,7 +71,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
instanceScope.objects.containsKey("${cls.className}::$name") -> cls
else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") }
}
if (scope.thisObj !== this) {
if (scope.thisObj !== this || scope.currentClassCtx == null) {
val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, declaring, caller))
scope.raiseError(
@ -82,7 +83,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
}
if (rec.type == ObjRecord.Type.Property) {
val prop = rec.value as ObjProperty
return rec.copy(value = prop.callGetter(scope, this))
return rec.copy(value = prop.callGetter(scope, this, declaring))
}
return rec
}
@ -93,8 +94,8 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
override suspend fun writeField(scope: Scope, name: String, newValue: Obj) {
// Direct (unmangled) first
instanceScope[name]?.let { f ->
val decl = f.declaringClass ?: objClass.findDeclaringClassOf(name)
if (scope.thisObj !== this) {
val decl = f.declaringClass
if (scope.thisObj !== this || scope.currentClassCtx == null) {
val caller = scope.currentClassCtx
if (!canAccessMember(f.visibility, decl, caller))
ObjIllegalAssignmentException(
@ -104,7 +105,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
}
if (f.type == ObjRecord.Type.Property) {
val prop = f.value as ObjProperty
prop.callSetter(scope, this, newValue)
prop.callSetter(scope, this, newValue, decl)
return
}
if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise()
@ -128,7 +129,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
instanceScope.objects.containsKey("${cls.className}::$name") -> cls
else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") }
}
if (scope.thisObj !== this) {
if (scope.thisObj !== this || scope.currentClassCtx == null) {
val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, declaring, caller))
ObjIllegalAssignmentException(
@ -138,7 +139,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
}
if (rec.type == ObjRecord.Type.Property) {
val prop = rec.value as ObjProperty
prop.callSetter(scope, this, newValue)
prop.callSetter(scope, this, newValue, declaring)
return
}
if (!rec.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise()
@ -154,7 +155,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
onNotFoundResult: (suspend () -> Obj?)?
): Obj =
instanceScope[name]?.let { rec ->
val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name)
val decl = rec.declaringClass
val caller = scope.currentClassCtx ?: if (scope.thisObj === this) objClass else null
if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(
@ -163,23 +164,16 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
"can't invoke method $name (declared in ${decl?.className ?: "?"})"
)
)
// execute with lexical class context propagated to declaring class
val saved = instanceScope.currentClassCtx
instanceScope.currentClassCtx = decl
try {
rec.value.invoke(
instanceScope,
this,
args
)
} finally {
instanceScope.currentClassCtx = saved
}
rec.value.invoke(
instanceScope,
this,
args
)
}
?: run {
// fallback: class-scope function (registered during class body execution)
objClass.classScope?.objects?.get(name)?.let { rec ->
val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name)
val decl = rec.declaringClass
val caller = scope.currentClassCtx ?: if (scope.thisObj === this) objClass else null
if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(
@ -188,13 +182,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
"can't invoke method $name (declared in ${decl?.className ?: "?"})"
)
)
val saved = instanceScope.currentClassCtx
instanceScope.currentClassCtx = decl
try {
rec.value.invoke(instanceScope, this, args)
} finally {
instanceScope.currentClassCtx = saved
}
rec.value.invoke(instanceScope, this, args)
}
}
?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult)

View File

@ -1,6 +1,24 @@
/*
* 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.
*
*/
package net.sergeych.lyng.obj
import net.sergeych.lyng.Arguments
import net.sergeych.lyng.ClosureScope
import net.sergeych.lyng.Scope
import net.sergeych.lyng.Statement
@ -14,16 +32,24 @@ class ObjProperty(
val setter: Statement?
) : Obj() {
suspend fun callGetter(scope: Scope, instance: ObjInstance): Obj {
suspend fun callGetter(scope: Scope, instance: Obj, declaringClass: ObjClass? = null): Obj {
val g = getter ?: scope.raiseError("property $name has no getter")
// Execute getter in a child scope of the instance with 'this' properly set
return g.execute(instance.instanceScope.createChildScope(newThisObj = instance))
// Use ClosureScope to match extension function behavior (access to instance scope + call scope)
val instanceScope = (instance as? ObjInstance)?.instanceScope ?: instance.autoInstanceScope(scope)
val execScope = ClosureScope(scope, instanceScope).createChildScope(newThisObj = instance)
execScope.currentClassCtx = declaringClass
return g.execute(execScope)
}
suspend fun callSetter(scope: Scope, instance: ObjInstance, value: Obj) {
suspend fun callSetter(scope: Scope, instance: Obj, value: Obj, declaringClass: ObjClass? = null) {
val s = setter ?: scope.raiseError("property $name has no setter")
// Execute setter in a child scope of the instance with 'this' properly set and the value as an argument
s.execute(instance.instanceScope.createChildScope(args = Arguments(value), newThisObj = instance))
// Use ClosureScope to match extension function behavior
val instanceScope = (instance as? ObjInstance)?.instanceScope ?: instance.autoInstanceScope(scope)
val execScope = ClosureScope(scope, instanceScope).createChildScope(args = Arguments(value), newThisObj = instance)
execScope.currentClassCtx = declaringClass
s.execute(execScope)
}
override fun toString(): String = "Property($name)"

View File

@ -1178,18 +1178,25 @@ class MethodCallRef(
when (base) {
is ObjInstance -> {
// Prefer resolved class member to avoid per-call lookup on hit
val member = base.objClass.getInstanceMemberOrNull(name)
if (member != null) {
val visibility = member.visibility
val callable = member.value
// BUT only if it's NOT a root object member (which can be shadowed by extensions)
var hierarchyMember: ObjRecord? = null
for (cls in base.objClass.mro) {
if (cls.className == "Obj") break
hierarchyMember = cls.members[name] ?: cls.classScope?.objects?.get(name)
if (hierarchyMember != null) break
}
if (hierarchyMember != null) {
val visibility = hierarchyMember.visibility
val callable = hierarchyMember.value
mKey1 = key; mVer1 = ver; mInvoker1 = { obj, sc, a ->
val inst = obj as ObjInstance
if (!visibility.isPublic)
if (!visibility.isPublic && !canAccessMember(visibility, hierarchyMember.declaringClass ?: inst.objClass, sc.currentClassCtx))
sc.raiseError(ObjAccessException(sc, "can't invoke non-public method $name"))
callable.invoke(inst.instanceScope, inst, a)
}
} else {
// Fallback to name-based lookup per call (uncommon)
// Fallback to name-based lookup per call (handles extensions and root members)
mKey1 = key; mVer1 = ver; mInvoker1 = { obj, sc, a -> obj.invokeInstanceMethod(sc, name, a) }
}
}
@ -1281,10 +1288,15 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
val slot = if (hit) cachedSlot else resolveSlot(scope)
if (slot >= 0) {
if (PerfFlags.PIC_DEBUG_COUNTERS) {
if (hit) PerfStats.localVarPicHit++ else PerfStats.localVarPicMiss++
val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
// Not visible via slot, fallback to other lookups
} else {
if (PerfFlags.PIC_DEBUG_COUNTERS) {
if (hit) PerfStats.localVarPicHit++ else PerfStats.localVarPicMiss++
}
return rec
}
return scope.getSlotRecord(slot)
}
if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicMiss++
// 2) Fallback name in scope or field on `this`
@ -1336,7 +1348,11 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
}
val hit = (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount())
val slot = if (hit) cachedSlot else resolveSlot(scope)
if (slot >= 0) return scope.getSlotRecord(slot).value
if (slot >= 0) {
val rec = scope.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
return rec.value
}
// Fallback name in scope or field on `this`
scope[name]?.let { return it.value }
run {
@ -1400,9 +1416,11 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
val slot = if (cachedFrameId == scope.frameId && cachedSlot >= 0 && cachedSlot < scope.slotCount()) cachedSlot else resolveSlot(scope)
if (slot >= 0) {
val rec = scope.getSlotRecord(slot)
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value")
rec.value = newValue
return
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value")
rec.value = newValue
return
}
}
scope[name]?.let { stored ->
if (stored.isMutable) stored.value = newValue
@ -1444,17 +1462,25 @@ class BoundLocalVarRef(
) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord {
scope.pos = atPos
return scope.getSlotRecord(slot)
val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
scope.raiseError(ObjAccessException(scope, "private field access"))
return rec
}
override suspend fun evalValue(scope: Scope): Obj {
scope.pos = atPos
return scope.getSlotRecord(slot).value
val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
scope.raiseError(ObjAccessException(scope, "private field access"))
return rec.value
}
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
scope.pos = atPos
val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
scope.raiseError(ObjAccessException(scope, "private field access"))
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value")
rec.value = newValue
}
@ -1518,10 +1544,13 @@ class FastLocalVarRef(
val slot = if (ownerValid && cachedSlot >= 0) cachedSlot else resolveSlotInAncestry(scope)
val actualOwner = cachedOwnerScope
if (slot >= 0 && actualOwner != null) {
if (PerfFlags.PIC_DEBUG_COUNTERS) {
if (ownerValid) PerfStats.fastLocalHit++ else PerfStats.fastLocalMiss++
val rec = actualOwner.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
if (PerfFlags.PIC_DEBUG_COUNTERS) {
if (ownerValid) PerfStats.fastLocalHit++ else PerfStats.fastLocalMiss++
}
return rec
}
return actualOwner.getSlotRecord(slot)
}
// Try per-frame local binding maps in the ancestry first (locals declared in frames)
run {
@ -1558,7 +1587,11 @@ class FastLocalVarRef(
val ownerValid = isOwnerValidFor(scope)
val slot = if (ownerValid && cachedSlot >= 0) cachedSlot else resolveSlotInAncestry(scope)
val actualOwner = cachedOwnerScope
if (slot >= 0 && actualOwner != null) return actualOwner.getSlotRecord(slot).value
if (slot >= 0 && actualOwner != null) {
val rec = actualOwner.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
return rec.value
}
// Try per-frame local binding maps in the ancestry first
run {
var s: Scope? = scope
@ -1595,9 +1628,11 @@ class FastLocalVarRef(
val actualOwner = cachedOwnerScope
if (slot >= 0 && actualOwner != null) {
val rec = actualOwner.getSlotRecord(slot)
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value")
rec.value = newValue
return
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value")
rec.value = newValue
return
}
}
// Try per-frame local binding maps in the ancestry first
run {

View File

@ -122,6 +122,12 @@ class ObjRingBuffer(val capacity: Int) : Obj() {
returns = type("lyng.Void"),
moduleName = "lyng.stdlib"
) { thisAs<ObjRingBuffer>().apply { buffer.add(requireOnlyArg<Obj>()) } }
addFnDoc(
name = "first",
doc = "Return the oldest element in the buffer.",
returns = type("lyng.Any"),
moduleName = "lyng.stdlib"
) { thisAs<ObjRingBuffer>().buffer.first() }
}
}
}

View File

@ -22,6 +22,7 @@ import net.sergeych.lyng.obj.ObjInstance
import net.sergeych.lyng.obj.ObjList
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFails
class OOTest {
@Test
@ -343,4 +344,56 @@ class OOTest {
val b2 = list.list[1] as ObjInstance
assertEquals(0, b1.compareTo(scope, b2))
}
@Test
fun testPropAsExtension() = runTest {
val scope = Script.newScope()
scope.eval("""
class A(x) {
private val privateVal = 100
val p1 get() = x + 1
}
assertEquals(2, A(1).p1)
fun A.f() = x + 5
assertEquals(7, A(2).f())
// The same, we should be able to add member values to a class;
// notice it should access to the class public instance members,
// somewhat like it is declared in the class body
val A.simple = x + 3
assertEquals(5, A(2).simple)
// it should also work with properties:
val A.p10 get() = x * 10
assertEquals(20, A(2).p10)
""".trimIndent())
// important is that such extensions should not be able to access private members
// and thus remove privateness:
assertFails {
scope.eval("val A.exportPrivateVal = privateVal; A(1).exportPrivateVal")
}
assertFails {
scope.eval("val A.exportPrivateValProp get() = privateVal; A(1).exportPrivateValProp")
}
}
@Test
fun testExtensionsAreScopeIsolated() = runTest {
val scope1 = Script.newScope()
scope1.eval("""
val String.totalDigits get() {
// notice using `this`:
this.characters.filter{ it.isDigit() }.size()
}
assertEquals(2, "answer is 42".totalDigits)
""")
val scope2 = Script.newScope()
scope2.eval("""
// in scope2 we didn't override `totalDigits` extension:
assertThrows { "answer is 42".totalDigits }
""".trimIndent())
}
}

View File

@ -16,7 +16,7 @@ fun cached(builder) {
}
}
/* Filter elements of this iterable using the provided predicate. */
fun Iterable.filter(predicate) {
fun Iterable.filterFlow(predicate) {
val list = this
flow {
for( item in list ) {
@ -27,10 +27,20 @@ fun Iterable.filter(predicate) {
}
}
fun Iterable.filter(predicate) {
val result = []
for( item in this ) if( predicate(item) ) result.add(item)
result
}
/*
filter out all null elements from this collection (Iterable); collection of
non-null elements is returned
*/
fun Iterable.filterFlowNotNull() {
filterFlow { it != null }
}
fun Iterable.filterNotNull() {
filter { it != null }
}
@ -42,10 +52,10 @@ fun Iterable.drop(n) {
}
/* Return the first element or throw if the iterable is empty. */
fun Iterable.first() {
val Iterable.first get() {
val i = iterator()
if( !i.hasNext() ) throw NoSuchElementException()
i.next().also { i.cancelIteration() }
i.next().also { i.cancelIteration() }
}
/*
@ -73,7 +83,7 @@ fun Iterable.findFirstOrNull(predicate) {
/* Return the last element or throw if the iterable is empty. */
fun Iterable.last() {
val Iterable.last get() {
var found = false
var element = null
for( i in this ) {
@ -81,7 +91,7 @@ fun Iterable.last() {
found = true
}
if( !found ) throw NoSuchElementException()
element
element
}
/* Emit all but the last N elements of this iterable. */
@ -247,5 +257,5 @@ fun Exception.printStackTrace() {
}
/* Compile this string into a regular expression. */
fun String.re(): Regex { Regex(this) }
val String.re get() = Regex(this)