Added delegation support: Delegate, lazy, "by" keyword for valr/var/fun and object {} singletons

This commit is contained in:
Sergey Chernov 2026-01-05 19:05:16 +01:00
parent 8e766490d9
commit 5f819dc87a
20 changed files with 1183 additions and 754 deletions

View File

@ -42,6 +42,35 @@ a _constructor_ that requires two parameters for fields. So when creating it wit
Form now on `Point` is a class, it's type is `Class`, and we can create instances with it as in the Form now on `Point` is a class, it's type is `Class`, and we can create instances with it as in the
example above. example above.
## Singleton Objects
Singleton objects are declared using the `object` keyword. An `object` declaration defines both a class and a single instance of that class at the same time. This is perfect for stateless utilities, global configuration, or shared delegates.
```lyng
object Config {
val version = "1.0.0"
val debug = true
fun printInfo() {
println("App version: " + version)
}
}
// Usage:
println(Config.version)
Config.printInfo()
```
Objects can also inherit from classes or interfaces:
```lyng
object DefaultLogger : Logger("Default") {
override fun log(msg) {
println("[DEFAULT] " + msg)
}
}
```
## Properties ## Properties
Properties allow you to define member accessors that look like fields but execute code when read or written. Unlike regular fields, properties in Lyng do **not** have automatic backing fields; they are pure accessors. Properties allow you to define member accessors that look like fields but execute code when read or written. Unlike regular fields, properties in Lyng do **not** have automatic backing fields; they are pure accessors.
@ -122,6 +151,39 @@ println(service.data()) // Returns "Record 42" immediately (no second fetch)
Note that `cached` returns a lambda, so you access the value by calling it like a method: `service.data()`. This is a powerful pattern for lazy-loading resources, caching results of database queries, or delaying expensive computations until they are truly needed. Note that `cached` returns a lambda, so you access the value by calling it like a method: `service.data()`. This is a powerful pattern for lazy-loading resources, caching results of database queries, or delaying expensive computations until they are truly needed.
## Delegation
Delegation allows you to hand over the logic of a property or function to another object. This is done using the `by` keyword.
### Property Delegation
Instead of providing `get()` and `set()` accessors, you can delegate them to an object that implements the `getValue` and `setValue` methods.
```lyng
class User {
var name by MyDelegate()
}
```
### Function Delegation
You can also delegate a whole function to an object. When the function is called, it will invoke the delegate's `invoke` method.
```lyng
fun remoteAction by RemoteProxy("actionName")
```
### The Unified Delegate Interface
A delegate is any object that provides the following methods (all optional depending on usage):
- `getValue(thisRef, name)`: Called when a delegated `val` or `var` is read.
- `setValue(thisRef, name, newValue)`: Called when a delegated `var` is written.
- `invoke(thisRef, name, args...)`: Called when a delegated `fun` is invoked.
- `bind(name, access, thisRef)`: Called once during initialization to configure or validate the delegate.
For more details and advanced patterns (like `lazy`, `observable`, and shared stateless delegates), see the [Delegation Guide](delegation.md).
## Instance initialization: init block ## Instance initialization: init block
In addition to the primary constructor arguments, you can provide an `init` block that runs on each instance creation. This is useful for more complex initializations, side effects, or setting up fields that depend on multiple constructor parameters. In addition to the primary constructor arguments, you can provide an `init` block that runs on each instance creation. This is useful for more complex initializations, side effects, or setting up fields that depend on multiple constructor parameters.

175
docs/delegation.md Normal file
View File

@ -0,0 +1,175 @@
# Delegation in Lyng
Delegation is a powerful pattern that allows you to outsource the logic of properties (`val`, `var`) and functions (`fun`) to another object. This enables code reuse, separation of concerns, and the implementation of common patterns like lazy initialization, observable properties, and remote procedure calls (RPC) with minimal boilerplate.
## The `by` Keyword
Delegation is triggered using the `by` keyword in a declaration. The expression following `by` is evaluated once when the member is initialized, and the resulting object becomes the **delegate**.
```lyng
val x by MyDelegate()
var y by MyDelegate()
fun f by MyDelegate()
```
## The Unified Delegate Model
A delegate object can implement any of the following methods to intercept member access. All methods receive the `thisRef` (the instance containing the member) and the `name` of the member.
```lyng
interface Delegate {
// Called when a 'val' or 'var' is read
fun getValue(thisRef, name)
// Called when a 'var' is assigned
fun setValue(thisRef, name, newValue)
// Called when a 'fun' is invoked
fun invoke(thisRef, name, args...)
// Optional: Called once during initialization to "bind" the delegate
// Can be used for validation or to return a different delegate instance
fun bind(name, access, thisRef) = this
}
```
### Delegate Access Types
The `bind` method receives an `access` parameter of type `DelegateAccess`, which can be one of:
- `DelegateAccess.Val`
- `DelegateAccess.Var`
- `DelegateAccess.Callable` (for `fun`)
## Usage Cases and Examples
### 1. Lazy Initialization
The classic `lazy` pattern ensures a value is computed only when first accessed and then cached. In Lyng, `lazy` is implemented as a class that follows this pattern. While classes typically start with an uppercase letter, `lazy` is an exception to make its usage feel like a native language feature.
```lyng
class lazy(val creator) : Delegate {
private var value = Unset
override fun bind(name, access, thisRef) {
if (access != DelegateAccess.Val) throw "lazy delegate can only be used with 'val'"
this
}
override fun getValue(thisRef, name) {
if (value == Unset) {
value = creator()
}
value
}
}
// Usage:
val expensiveData by lazy {
println("Performing expensive computation...")
42
}
println(expensiveData) // Computes and prints 42
println(expensiveData) // Returns 42 immediately
```
### 2. Observable Properties
Delegates can be used to react to property changes.
```lyng
class Observable(initialValue, val onChange) {
private var value = initialValue
fun getValue(thisRef, name) = value
fun setValue(thisRef, name, newValue) {
val oldValue = value
value = newValue
onChange(name, oldValue, newValue)
}
}
class User {
var name by Observable("Guest") { name, old, new ->
println("Property %s changed from %s to %s"(name, old, new))
}
}
val u = User()
u.name = "Alice" // Prints: Property name changed from Guest to Alice
```
### 3. Function Delegation (Proxies)
You can delegate an entire function to an object. This is particularly useful for implementing decorators or RPC clients.
```lyng
object LoggerDelegate {
fun invoke(thisRef, name, args...) {
println("Calling function: " + name + " with args: " + args)
// Logic here...
"Result of " + name
}
}
fun remoteAction by LoggerDelegate
println(remoteAction(1, 2, 3))
// Prints: Calling function: remoteAction with args: [1, 2, 3]
// Prints: Result of remoteAction
```
### 4. Stateless Delegates (Shared Singletons)
Because `getValue`, `setValue`, and `invoke` receive `thisRef`, a single object can act as a delegate for multiple properties across many instances without any per-property memory overhead.
```lyng
object Constant42 {
fun getValue(thisRef, name) = 42
}
class Foo {
val a by Constant42
val b by Constant42
}
val f = Foo()
assertEquals(42, f.a)
assertEquals(42, f.b)
```
### 5. Local Delegation
Delegation is not limited to class members; you can also use it for local variables inside functions.
```lyng
fun test() {
val x by LocalProxy(123)
println(x)
}
```
## The `bind` Hook
The `bind(name, access, thisRef)` method is called exactly once when the member is being initialized. It allows the delegate to:
1. **Validate usage**: Throw an error if the delegate is used with the wrong member type (e.g., `lazy` on a `var`).
2. **Initialize state**: Set up internal state based on the property name or the containing instance.
3. **Substitute itself**: Return a different object that will act as the actual delegate.
```lyng
class ValidatedDelegate() {
fun bind(name, access, thisRef) {
if (access == DelegateAccess.Var) {
throw "This delegate cannot be used with 'var'"
}
this
}
fun getValue(thisRef, name) = "Validated"
}
```
## Summary
Delegation in Lyng combines the elegance of Kotlin-style properties with the flexibility of dynamic function interception. By unifying `val`, `var`, and `fun` delegation into a single model, Lyng provides a consistent and powerful tool for meta-programming and code reuse.

View File

@ -87,6 +87,27 @@ Lyng supports simple enums for a fixed set of named constants. Declare with `enu
For more details (usage patterns, `when` switching, serialization), see OOP notes: [Enums in detail](OOP.md#enums). For more details (usage patterns, `when` switching, serialization), see OOP notes: [Enums in detail](OOP.md#enums).
## Singleton Objects
Singleton objects are declared using the `object` keyword. They define a class and create its single instance immediately.
object Logger {
fun log(msg) { println("[LOG] " + msg) }
}
Logger.log("Hello singleton!")
## Delegation (briefly)
You can delegate properties and functions to other objects using the `by` keyword. This is perfect for patterns like `lazy` initialization.
val expensiveData by lazy {
// computed only once on demand
"computed"
}
For more details on these features, see [Delegation in Lyng](delegation.md) and [OOP notes](OOP.md).
When putting multiple statments in the same line it is convenient and recommended to use `;`: When putting multiple statments in the same line it is convenient and recommended to use `;`:
var from; var to var from; var to

View File

@ -93,6 +93,36 @@ let d = Derived()
println((d as B).foo()) // Disambiguation via cast println((d as B).foo()) // Disambiguation via cast
``` ```
### Singleton Objects
Singleton objects are declared using the `object` keyword. They provide a convenient way to define a class and its single instance in one go.
```lyng
object Config {
val version = "1.2.3"
fun show() = println("Config version: " + version)
}
Config.show()
```
### Unified Delegation Model
A powerful new delegation system allows `val`, `var`, and `fun` members to delegate their logic to other objects using the `by` keyword.
```lyng
// Property delegation
val lazyValue by lazy { "expensive" }
// Function delegation
fun remoteAction by myProxy
// Observable properties
var name by Observable("initial") { n, old, new ->
println("Changed!")
}
```
The system features a unified interface (`getValue`, `setValue`, `invoke`) and a `bind` hook for initialization-time validation and configuration. See the [Delegation Guide](delegation.md) for more.
## Tooling and Infrastructure ## Tooling and Infrastructure
### CLI: Formatting Command ### CLI: Formatting Command

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -138,7 +138,7 @@ data class ArgsDeclaration(val params: List<Item>, val endTokenType: Token.Type)
} else { } else {
val value = if (hp < callArgs.size) callArgs[hp++] val value = if (hp < callArgs.size) callArgs[hp++]
else a.defaultValue?.execute(scope) else a.defaultValue?.execute(scope)
?: scope.raiseIllegalArgument("too few arguments for the call") ?: scope.raiseIllegalArgument("too few arguments for the call (missing ${a.name})")
assign(a, value) assign(a, value)
} }
i++ i++

View File

@ -286,7 +286,7 @@ class Compiler(
while (true) { while (true) {
val t = cc.next() val t = cc.next()
return when (t.type) { return when (t.type) {
Token.Type.ID -> { Token.Type.ID, Token.Type.OBJECT -> {
parseKeywordStatement(t) parseKeywordStatement(t)
?: run { ?: run {
cc.previous() cc.previous()
@ -337,7 +337,7 @@ class Compiler(
private suspend fun parseExpression(): Statement? { private suspend fun parseExpression(): Statement? {
val pos = cc.currentPos() val pos = cc.currentPos()
return parseExpressionLevel()?.let { a -> statement(pos) { a.get(it).value } } return parseExpressionLevel()?.let { a -> statement(pos) { a.evalValue(it) } }
} }
private suspend fun parseExpressionLevel(level: Int = 0): ObjRef? { private suspend fun parseExpressionLevel(level: Int = 0): ObjRef? {
@ -1063,7 +1063,7 @@ class Compiler(
val next = cc.peekNextNonWhitespace() val next = cc.peekNextNonWhitespace()
if (next.type == Token.Type.COMMA || next.type == Token.Type.RPAREN) { if (next.type == Token.Type.COMMA || next.type == Token.Type.RPAREN) {
val localVar = LocalVarRef(name, t1.pos) val localVar = LocalVarRef(name, t1.pos)
return ParsedArgument(statement(t1.pos) { localVar.get(it).value }, t1.pos, isSplat = false, name = name) return ParsedArgument(statement(t1.pos) { localVar.evalValue(it) }, t1.pos, isSplat = false, name = name)
} }
val rhs = parseExpression() ?: t2.raiseSyntax("expected expression after named argument '${name}:'") val rhs = parseExpression() ?: t2.raiseSyntax("expected expression after named argument '${name}:'")
return ParsedArgument(rhs, t1.pos, isSplat = false, name = name) return ParsedArgument(rhs, t1.pos, isSplat = false, name = name)
@ -1135,7 +1135,7 @@ class Compiler(
val next = cc.peekNextNonWhitespace() val next = cc.peekNextNonWhitespace()
if (next.type == Token.Type.COMMA || next.type == Token.Type.RPAREN) { if (next.type == Token.Type.COMMA || next.type == Token.Type.RPAREN) {
val localVar = LocalVarRef(name, t1.pos) val localVar = LocalVarRef(name, t1.pos)
return ParsedArgument(statement(t1.pos) { localVar.get(it).value }, t1.pos, isSplat = false, name = name) return ParsedArgument(statement(t1.pos) { localVar.evalValue(it) }, t1.pos, isSplat = false, name = name)
} }
val rhs = parseExpression() ?: t2.raiseSyntax("expected expression after named argument '${name}:'") val rhs = parseExpression() ?: t2.raiseSyntax("expected expression after named argument '${name}:'")
return ParsedArgument(rhs, t1.pos, isSplat = false, name = name) return ParsedArgument(rhs, t1.pos, isSplat = false, name = name)
@ -1356,6 +1356,12 @@ class Compiler(
parseClassDeclaration(isAbstract) parseClassDeclaration(isAbstract)
} }
"object" -> {
if (isStatic || isClosed || isOverride || isExtern || isAbstract)
throw ScriptError(currentToken.pos, "unsupported modifiers for object: ${modifiers.joinToString(" ")}")
parseObjectDeclaration()
}
"interface" -> { "interface" -> {
if (isStatic || isClosed || isOverride || isExtern || isAbstract) if (isStatic || isClosed || isOverride || isExtern || isAbstract)
throw ScriptError( throw ScriptError(
@ -1421,6 +1427,12 @@ class Compiler(
parseClassDeclaration() parseClassDeclaration()
} }
"object" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
parseObjectDeclaration()
}
"init" -> { "init" -> {
if (codeContexts.lastOrNull() is CodeContext.ClassBody && cc.peekNextNonWhitespace().type == Token.Type.LBRACE) { if (codeContexts.lastOrNull() is CodeContext.ClassBody && cc.peekNextNonWhitespace().type == Token.Type.LBRACE) {
val block = parseBlock() val block = parseBlock()
@ -1847,6 +1859,81 @@ class Compiler(
} }
} }
private suspend fun parseObjectDeclaration(): Statement {
val nameToken = cc.requireToken(Token.Type.ID)
val startPos = pendingDeclStart ?: nameToken.pos
val doc = pendingDeclDoc ?: consumePendingDoc()
pendingDeclDoc = null
pendingDeclStart = null
// Optional base list: ":" Base ("," Base)* where Base := ID ( "(" args? ")" )?
data class BaseSpec(val name: String, val args: List<ParsedArgument>?)
val baseSpecs = mutableListOf<BaseSpec>()
if (cc.skipTokenOfType(Token.Type.COLON, isOptional = true)) {
do {
val baseId = cc.requireToken(Token.Type.ID, "base class name expected")
var argsList: List<ParsedArgument>? = null
if (cc.skipTokenOfType(Token.Type.LPAREN, isOptional = true)) {
argsList = parseArgsNoTailBlock()
}
baseSpecs += BaseSpec(baseId.value, argsList)
} while (cc.skipTokenOfType(Token.Type.COMMA, isOptional = true))
}
cc.skipTokenOfType(Token.Type.NEWLINE, isOptional = true)
pushInitScope()
// Robust body detection
var classBodyRange: MiniRange? = null
val bodyInit: Statement? = run {
val saved = cc.savePos()
val next = cc.nextNonWhitespace()
if (next.type == Token.Type.LBRACE) {
val bodyStart = next.pos
val st = withLocalNames(emptySet()) {
parseScript()
}
val rbTok = cc.next()
if (rbTok.type != Token.Type.RBRACE) throw ScriptError(rbTok.pos, "unbalanced braces in object body")
classBodyRange = MiniRange(bodyStart, rbTok.pos)
st
} else {
cc.restorePos(saved)
null
}
}
val initScope = popInitScope()
val className = nameToken.value
return statement(startPos) { context ->
val parentClasses = baseSpecs.map { baseSpec ->
val rec = context[baseSpec.name] ?: throw ScriptError(nameToken.pos, "unknown base class: ${baseSpec.name}")
(rec.value as? ObjClass) ?: throw ScriptError(nameToken.pos, "${baseSpec.name} is not a class")
}
val newClass = ObjInstanceClass(className, *parentClasses.toTypedArray())
for (i in parentClasses.indices) {
val argsList = baseSpecs[i].args
// In object, we evaluate parent args once at creation time
if (argsList != null) newClass.directParentArgs[parentClasses[i]] = argsList
}
val classScope = context.createChildScope(newThisObj = newClass)
classScope.currentClassCtx = newClass
newClass.classScope = classScope
bodyInit?.execute(classScope)
// Create instance (singleton)
val instance = newClass.callOn(context.createChildScope(Arguments.EMPTY))
context.addItem(className, false, instance)
instance
}
}
private suspend fun parseClassDeclaration(isAbstract: Boolean = false): Statement { private suspend fun parseClassDeclaration(isAbstract: Boolean = false): Statement {
val nameToken = cc.requireToken(Token.Type.ID) val nameToken = cc.requireToken(Token.Type.ID)
val startPos = pendingDeclStart ?: nameToken.pos val startPos = pendingDeclStart ?: nameToken.pos
@ -2457,9 +2544,9 @@ class Compiler(
val annotation = lastAnnotation val annotation = lastAnnotation
val parentContext = codeContexts.last() val parentContext = codeContexts.last()
t = cc.next()
// Is extension? // Is extension?
if (t.type == Token.Type.DOT) { if (cc.peekNextNonWhitespace().type == Token.Type.DOT) {
cc.nextNonWhitespace() // consume DOT
extTypeName = name extTypeName = name
val receiverEnd = Pos(start.source, start.line, start.column + name.length) val receiverEnd = Pos(start.source, start.line, start.column + name.length)
receiverMini = MiniTypeName( receiverMini = MiniTypeName(
@ -2472,24 +2559,33 @@ class Compiler(
throw ScriptError(t.pos, "illegal extension format: expected function name") throw ScriptError(t.pos, "illegal extension format: expected function name")
name = t.value name = t.value
nameStartPos = t.pos nameStartPos = t.pos
t = cc.next()
} }
if (t.type != Token.Type.LPAREN) val argsDeclaration: ArgsDeclaration =
throw ScriptError(t.pos, "Bad function definition: expected '(' after 'fn ${name}'") if (cc.peekNextNonWhitespace().type == Token.Type.LPAREN) {
cc.nextNonWhitespace() // consume (
parseArgsDeclaration() ?: ArgsDeclaration(emptyList(), Token.Type.RPAREN)
} else ArgsDeclaration(emptyList(), Token.Type.RPAREN)
val argsDeclaration = parseArgsDeclaration() // Optional return type
if (argsDeclaration == null || argsDeclaration.endTokenType != Token.Type.RPAREN) val returnTypeMini: MiniTypeRef? = if (cc.peekNextNonWhitespace().type == Token.Type.COLON) {
parseTypeDeclarationWithMini().second
} else null
var isDelegated = false
var delegateExpression: Statement? = null
if (cc.peekNextNonWhitespace().type == Token.Type.BY) {
cc.nextNonWhitespace() // consume by
isDelegated = true
delegateExpression = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected delegate expression")
}
if (!isDelegated && argsDeclaration.endTokenType != Token.Type.RPAREN)
throw ScriptError( throw ScriptError(
t.pos, t.pos,
"Bad function definition: expected valid argument declaration or () after 'fn ${name}'" "Bad function definition: expected valid argument declaration or () after 'fn ${name}'"
) )
// Optional return type
val returnTypeMini: MiniTypeRef? = if (cc.current().type == Token.Type.COLON) {
parseTypeDeclarationWithMini().second
} else null
// Capture doc locally to reuse even if we need to emit later // Capture doc locally to reuse even if we need to emit later
val declDocLocal = pendingDeclDoc val declDocLocal = pendingDeclDoc
@ -2526,17 +2622,13 @@ class Compiler(
localDeclCountStack.add(0) localDeclCountStack.add(0)
val fnStatements = if (isExtern) val fnStatements = if (isExtern)
statement { raiseError("extern function not provided: $name") } statement { raiseError("extern function not provided: $name") }
else if (isAbstract) { else if (isAbstract || isDelegated) {
val next = cc.peekNextNonWhitespace()
if (next.type == Token.Type.ASSIGN || next.type == Token.Type.LBRACE)
throw ScriptError(next.pos, "abstract function $name cannot have a body")
null null
} else } else
withLocalNames(paramNames) { withLocalNames(paramNames) {
val next = cc.peekNextNonWhitespace() val next = cc.peekNextNonWhitespace()
if (next.type == Token.Type.ASSIGN) { if (next.type == Token.Type.ASSIGN) {
cc.skipWsTokens() cc.nextNonWhitespace() // consume '='
cc.next() // consume '='
val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected function body expression") val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected function body expression")
// Shorthand function returns the expression value // Shorthand function returns the expression value
statement(expr.pos) { scope -> statement(expr.pos) { scope ->
@ -2573,6 +2665,57 @@ class Compiler(
} }
// parentContext // parentContext
val fnCreateStatement = statement(start) { context -> val fnCreateStatement = statement(start) { context ->
if (isDelegated) {
val accessType = context.resolveQualifiedIdentifier("DelegateAccess.Callable")
val initValue = delegateExpression!!.execute(context)
val finalDelegate = try {
initValue.invokeInstanceMethod(context, "bind", Arguments(ObjString(name), accessType, context.thisObj))
} catch (e: Exception) {
initValue
}
if (extTypeName != 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(ObjUnset, isMutable = false, visibility = visibility, declaringClass = null, type = ObjRecord.Type.Delegated).apply {
delegate = finalDelegate
})
return@statement ObjVoid
}
val th = context.thisObj
if (isStatic) {
(th as ObjClass).createClassField(name, ObjUnset, false, visibility, null, start, type = ObjRecord.Type.Delegated).apply {
delegate = finalDelegate
}
context.addItem(name, false, ObjUnset, visibility, recordType = ObjRecord.Type.Delegated).apply {
delegate = finalDelegate
}
} else if (th is ObjClass) {
val cls: ObjClass = th
val storageName = "${cls.className}::$name"
cls.createField(name, ObjUnset, false, visibility, null, start, declaringClass = cls, isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride, type = ObjRecord.Type.Delegated)
cls.instanceInitializers += statement(start) { scp ->
val accessType2 = scp.resolveQualifiedIdentifier("DelegateAccess.Callable")
val initValue2 = delegateExpression!!.execute(scp)
val finalDelegate2 = try {
initValue2.invokeInstanceMethod(scp, "bind", Arguments(ObjString(name), accessType2, scp.thisObj))
} catch (e: Exception) {
initValue2
}
scp.addItem(storageName, false, ObjUnset, visibility, null, recordType = ObjRecord.Type.Delegated, isAbstract = isAbstract, isClosed = isClosed, isOverride = isOverride).apply {
delegate = finalDelegate2
}
ObjVoid
}
} else {
context.addItem(name, false, ObjUnset, visibility, recordType = ObjRecord.Type.Delegated).apply {
delegate = finalDelegate
}
}
return@statement ObjVoid
}
// we added fn in the context. now we must save closure // we added fn in the context. now we must save closure
// for the function, unless we're in the class scope: // for the function, unless we're in the class scope:
if (isStatic || parentContext !is CodeContext.ClassBody) if (isStatic || parentContext !is CodeContext.ClassBody)
@ -2803,14 +2946,14 @@ class Compiler(
if (!isStatic) declareLocalName(name) if (!isStatic) declareLocalName(name)
val isDelegate = if (isAbstract) { val isDelegate = if (isAbstract) {
if (!isProperty && (eqToken.type == Token.Type.ASSIGN || eqToken.isId("by"))) if (!isProperty && (eqToken.type == Token.Type.ASSIGN || eqToken.type == Token.Type.BY))
throw ScriptError(eqToken.pos, "abstract variable $name cannot have an initializer or delegate") throw ScriptError(eqToken.pos, "abstract variable $name cannot have an initializer or delegate")
// Abstract variables don't have initializers // Abstract variables don't have initializers
cc.restorePos(markBeforeEq) cc.restorePos(markBeforeEq)
cc.skipWsTokens() cc.skipWsTokens()
setNull = true setNull = true
false false
} else if (!isProperty && eqToken.isId("by")) { } else if (!isProperty && eqToken.type == Token.Type.BY) {
true true
} else { } else {
if (!isProperty && eqToken.type != Token.Type.ASSIGN) { if (!isProperty && eqToken.type != Token.Type.ASSIGN) {
@ -2858,11 +3001,35 @@ class Compiler(
// when creating instance, but we need to execute it in the class initializer which // when creating instance, but we need to execute it in the class initializer which
// is missing as for now. Add it to the compiler context? // is missing as for now. Add it to the compiler context?
// if (isDelegate) throw ScriptError(start, "static delegates are not yet implemented")
currentInitScope += statement { currentInitScope += statement {
val initValue = initialExpression?.execute(this)?.byValueCopy() ?: ObjNull val initValue = initialExpression?.execute(this)?.byValueCopy() ?: ObjNull
(thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, pos) if (isDelegate) {
val accessTypeStr = if (isMutable) "Var" else "Val"
val accessType = resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
val finalDelegate = try {
initValue.invokeInstanceMethod(this, "bind", Arguments(ObjString(name), accessType, thisObj))
} catch (e: Exception) {
initValue
}
(thisObj as ObjClass).createClassField(
name,
ObjUnset,
isMutable,
visibility,
null,
start,
type = ObjRecord.Type.Delegated
).apply {
delegate = finalDelegate
}
// Also expose in current init scope
addItem(name, isMutable, ObjUnset, visibility, null, ObjRecord.Type.Delegated).apply {
delegate = finalDelegate
}
} else {
(thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, start)
addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field) addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field)
}
ObjVoid ObjVoid
} }
return NopStatement return NopStatement
@ -3004,7 +3171,83 @@ class Compiler(
if (!isStatic) declareLocalName(name) if (!isStatic) declareLocalName(name)
if (isDelegate) { if (isDelegate) {
TODO() val declaringClassName = declaringClassNameCaptured
if (declaringClassName != null) {
val storageName = "$declaringClassName::$name"
val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance)
if (isClassScope) {
val cls = context.thisObj as ObjClass
cls.createField(
name,
ObjUnset,
isMutable,
visibility,
setterVisibility,
start,
type = ObjRecord.Type.Delegated,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
)
cls.instanceInitializers += statement(start) { scp ->
val initValue = initialExpression!!.execute(scp)
val accessTypeStr = if (isMutable) "Var" else "Val"
val accessType = scp.resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
val finalDelegate = try {
initValue.invokeInstanceMethod(scp, "bind", Arguments(ObjString(name), accessType, scp.thisObj))
} catch (e: Exception) {
initValue
}
scp.addItem(
storageName, isMutable, ObjUnset, visibility, setterVisibility,
recordType = ObjRecord.Type.Delegated,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
).apply {
delegate = finalDelegate
}
ObjVoid
}
return@statement ObjVoid
} else {
val initValue = initialExpression!!.execute(context)
val accessTypeStr = if (isMutable) "Var" else "Val"
val accessType = context.resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
val finalDelegate = try {
initValue.invokeInstanceMethod(context, "bind", Arguments(ObjString(name), accessType, context.thisObj))
} catch (e: Exception) {
initValue
}
val rec = context.addItem(
storageName, isMutable, ObjUnset, visibility, setterVisibility,
recordType = ObjRecord.Type.Delegated,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
)
rec.delegate = finalDelegate
return@statement finalDelegate
}
} else {
val initValue = initialExpression!!.execute(context)
val accessTypeStr = if (isMutable) "Var" else "Val"
val accessType = context.resolveQualifiedIdentifier("DelegateAccess.$accessTypeStr")
val finalDelegate = try {
initValue.invokeInstanceMethod(context, "bind", Arguments(ObjString(name), accessType, ObjNull))
} catch (e: Exception) {
initValue
}
val rec = context.addItem(
name, isMutable, ObjUnset, visibility, setterVisibility,
recordType = ObjRecord.Type.Delegated,
isAbstract = isAbstract,
isClosed = isClosed,
isOverride = isOverride
)
rec.delegate = finalDelegate
return@statement finalDelegate
}
} else if (getter != null || setter != null) { } else if (getter != null || setter != null) {
val declaringClassName = declaringClassNameCaptured!! val declaringClassName = declaringClassNameCaptured!!
val storageName = "$declaringClassName::$name" val storageName = "$declaringClassName::$name"

View File

@ -356,6 +356,8 @@ private class Parser(fromPos: Pos) {
when (text) { when (text) {
"in" -> Token("in", from, Token.Type.IN) "in" -> Token("in", from, Token.Type.IN)
"is" -> Token("is", from, Token.Type.IS) "is" -> Token("is", from, Token.Type.IS)
"by" -> Token("by", from, Token.Type.BY)
"object" -> Token("object", from, Token.Type.OBJECT)
"as" -> { "as" -> {
// support both `as` and tight `as?` without spaces // support both `as` and tight `as?` without spaces
if (currentChar == '?') { pos.advance(); Token("as?", from, Token.Type.ASNULL) } if (currentChar == '?') { pos.advance(); Token("as?", from, Token.Type.ASNULL) }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -621,6 +621,36 @@ open class Scope(
return ref.evalValue(this) return ref.evalValue(this)
} }
suspend fun resolve(rec: ObjRecord, name: String): Obj {
if (rec.type == ObjRecord.Type.Delegated) {
val del = rec.delegate ?: raiseError("Internal error: delegated property $name has no delegate")
val th = if (thisObj === ObjVoid) ObjNull else thisObj
return del.invokeInstanceMethod(this, "getValue", Arguments(th, ObjString(name)), onNotFoundResult = {
// If getValue not found, return a wrapper that calls invoke
object : Statement() {
override val pos: Pos = Pos.builtIn
override suspend fun execute(scope: Scope): Obj {
val th2 = if (scope.thisObj === ObjVoid) ObjNull else scope.thisObj
val allArgs = (listOf(th2, ObjString(name)) + scope.args.list).toTypedArray()
return del.invokeInstanceMethod(scope, "invoke", Arguments(*allArgs))
}
}
})!!
}
return rec.value
}
suspend fun assign(rec: ObjRecord, name: String, newValue: Obj) {
if (rec.type == ObjRecord.Type.Delegated) {
val del = rec.delegate ?: raiseError("Internal error: delegated property $name has no delegate")
val th = if (thisObj === ObjVoid) ObjNull else thisObj
del.invokeInstanceMethod(this, "setValue", Arguments(th, ObjString(name), newValue))
return
}
if (!rec.isMutable && rec.value !== ObjUnset) raiseIllegalAssignment("can't reassign val $name")
rec.value = newValue
}
companion object { companion object {
fun new(): Scope = fun new(): Scope =

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -36,7 +36,7 @@ data class Token(val value: String, val pos: Pos, val type: Type) {
PLUS, MINUS, STAR, SLASH, PERCENT, PLUS, MINUS, STAR, SLASH, PERCENT,
ASSIGN, PLUSASSIGN, MINUSASSIGN, STARASSIGN, SLASHASSIGN, PERCENTASSIGN, ASSIGN, PLUSASSIGN, MINUSASSIGN, STARASSIGN, SLASHASSIGN, PERCENTASSIGN,
PLUS2, MINUS2, PLUS2, MINUS2,
IN, NOTIN, IS, NOTIS, IN, NOTIN, IS, NOTIS, BY,
EQ, NEQ, LT, LTE, GT, GTE, REF_EQ, REF_NEQ, MATCH, NOTMATCH, EQ, NEQ, LT, LTE, GT, GTE, REF_EQ, REF_NEQ, MATCH, NOTMATCH,
SHUTTLE, SHUTTLE,
AND, BITAND, OR, BITOR, BITXOR, NOT, BITNOT, DOT, ARROW, EQARROW, QUESTION, COLONCOLON, AND, BITAND, OR, BITOR, BITXOR, NOT, BITNOT, DOT, ARROW, EQARROW, QUESTION, COLONCOLON,
@ -44,7 +44,7 @@ data class Token(val value: String, val pos: Pos, val type: Type) {
SINLGE_LINE_COMMENT, MULTILINE_COMMENT, SINLGE_LINE_COMMENT, MULTILINE_COMMENT,
LABEL, ATLABEL, // label@ at@label LABEL, ATLABEL, // label@ at@label
// type-checking/casting // type-checking/casting
AS, ASNULL, AS, ASNULL, OBJECT,
//PUBLIC, PROTECTED, INTERNAL, EXPORT, OPEN, INLINE, OVERRIDE, ABSTRACT, SEALED, EXTERNAL, VAL, VAR, CONST, TYPE, FUN, CLASS, INTERFACE, ENUM, OBJECT, TRAIT, THIS, //PUBLIC, PROTECTED, INTERNAL, EXPORT, OPEN, INLINE, OVERRIDE, ABSTRACT, SEALED, EXTERNAL, VAL, VAR, CONST, TYPE, FUN, CLASS, INTERFACE, ENUM, OBJECT, TRAIT, THIS,
ELLIPSIS, DOTDOT, DOTDOTLT, ELLIPSIS, DOTDOT, DOTDOTLT,
NEWLINE, NEWLINE,

View File

@ -74,7 +74,7 @@ private fun kindOf(type: Type, value: String): HighlightKind? = when (type) {
Type.COMMA, Type.SEMICOLON, Type.COLON -> HighlightKind.Punctuation Type.COMMA, Type.SEMICOLON, Type.COLON -> HighlightKind.Punctuation
// textual control keywords // textual control keywords
Type.IN, Type.NOTIN, Type.IS, Type.NOTIS, Type.AS, Type.ASNULL, Type.IN, Type.NOTIN, Type.IS, Type.NOTIS, Type.AS, Type.ASNULL, Type.BY, Type.OBJECT,
Type.AND, Type.OR, Type.NOT -> HighlightKind.Keyword Type.AND, Type.OR, Type.NOT -> HighlightKind.Keyword
// labels / annotations // labels / annotations

View File

@ -94,13 +94,7 @@ open class Obj {
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, decl, caller)) if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(ObjIllegalAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})")) scope.raiseError(ObjIllegalAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})"))
val saved = scope.currentClassCtx return rec.value.invoke(scope, this, args, decl)
scope.currentClassCtx = decl
try {
return rec.value.invoke(scope, this, args)
} finally {
scope.currentClassCtx = saved
}
} }
} }
@ -118,13 +112,7 @@ open class Obj {
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, decl, caller)) if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(ObjIllegalAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})")) scope.raiseError(ObjIllegalAccessException(scope, "can't invoke ${name}: not visible (declared in ${decl.className}, caller ${caller?.className ?: "?"})"))
val saved = scope.currentClassCtx return rec.value.invoke(scope, this, args, decl)
scope.currentClassCtx = decl
try {
return rec.value.invoke(scope, this, args)
} finally {
scope.currentClassCtx = saved
}
} }
} }
} }
@ -376,7 +364,14 @@ open class Obj {
) )
} }
protected suspend fun resolveRecord(scope: Scope, obj: ObjRecord, name: String, decl: ObjClass?): ObjRecord { protected open suspend fun resolveRecord(scope: Scope, obj: ObjRecord, name: String, decl: ObjClass?): ObjRecord {
if (obj.type == ObjRecord.Type.Delegated) {
val del = obj.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate")
return obj.copy(
value = del.invokeInstanceMethod(scope, "getValue", Arguments(this, ObjString(name))),
type = ObjRecord.Type.Other
)
}
val value = obj.value val value = obj.value
if (value is ObjProperty) { if (value is ObjProperty) {
return ObjRecord(value.callGetter(scope, this, decl), obj.isMutable) return ObjRecord(value.callGetter(scope, this, decl), obj.isMutable)
@ -425,7 +420,10 @@ open class Obj {
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(field.effectiveWriteVisibility, decl, caller)) if (!canAccessMember(field.effectiveWriteVisibility, decl, caller))
scope.raiseError(ObjIllegalAccessException(scope, "can't assign field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})")) scope.raiseError(ObjIllegalAccessException(scope, "can't assign field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})"))
if (field.value is ObjProperty) { if (field.type == ObjRecord.Type.Delegated) {
val del = field.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate")
del.invokeInstanceMethod(scope, "setValue", Arguments(this, ObjString(name), newValue))
} else if (field.value is ObjProperty) {
(field.value as ObjProperty).callSetter(scope, this, newValue, decl) (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") } else if (field.isMutable) field.value = newValue else scope.raiseError("can't assign to read-only field: $name")
} }
@ -444,13 +442,16 @@ open class Obj {
scope.raiseNotImplemented() scope.raiseNotImplemented()
} }
suspend fun invoke(scope: Scope, thisObj: Obj, args: Arguments): Obj = suspend fun invoke(scope: Scope, thisObj: Obj, args: Arguments, declaringClass: ObjClass? = null): Obj =
if (PerfFlags.SCOPE_POOL) if (PerfFlags.SCOPE_POOL)
scope.withChildFrame(args, newThisObj = thisObj) { child -> scope.withChildFrame(args, newThisObj = thisObj) { child ->
if (declaringClass != null) child.currentClassCtx = declaringClass
callOn(child) callOn(child)
} }
else else
callOn(scope.createChildScope(scope.pos, args = args, newThisObj = thisObj)) callOn(scope.createChildScope(scope.pos, args = args, newThisObj = thisObj).also {
if (declaringClass != null) it.currentClassCtx = declaringClass
})
suspend fun invoke(scope: Scope, thisObj: Obj, vararg args: Obj): Obj = suspend fun invoke(scope: Scope, thisObj: Obj, vararg args: Obj): Obj =
callOn( callOn(

View File

@ -197,7 +197,7 @@ open class ObjClass(
val list = mutableListOf<String>() val list = mutableListOf<String>()
if (includeSelf) list += className if (includeSelf) list += className
mroParents.forEach { list += it.className } mroParents.forEach { list += it.className }
return list.joinToString(" ") return list.joinToString(", ")
} }
override val objClass: ObjClass by lazy { ObjClassType } override val objClass: ObjClass by lazy { ObjClassType }
@ -234,13 +234,13 @@ open class ObjClass(
// This mirrors Obj.autoInstanceScope behavior for ad-hoc scopes and makes fb.method() resolution robust // This mirrors Obj.autoInstanceScope behavior for ad-hoc scopes and makes fb.method() resolution robust
// 1) members-defined methods // 1) members-defined methods
for ((k, v) in members) { for ((k, v) in members) {
if (v.value is Statement) { if (v.value is Statement || v.type == ObjRecord.Type.Delegated) {
instance.instanceScope.objects[k] = v instance.instanceScope.objects[k] = v
} }
} }
// 2) class-scope methods registered during class-body execution // 2) class-scope methods registered during class-body execution
classScope?.objects?.forEach { (k, rec) -> classScope?.objects?.forEach { (k, rec) ->
if (rec.value is Statement) { if (rec.value is Statement || rec.type == ObjRecord.Type.Delegated) {
// if not already present, copy reference for dispatch // if not already present, copy reference for dispatch
if (!instance.instanceScope.objects.containsKey(k)) { if (!instance.instanceScope.objects.containsKey(k)) {
instance.instanceScope.objects[k] = rec instance.instanceScope.objects[k] = rec
@ -361,7 +361,7 @@ open class ObjClass(
isClosed: Boolean = false, isClosed: Boolean = false,
isOverride: Boolean = false, isOverride: Boolean = false,
type: ObjRecord.Type = ObjRecord.Type.Field, type: ObjRecord.Type = ObjRecord.Type.Field,
) { ): ObjRecord {
// Validation of override rules: only for non-system declarations // Validation of override rules: only for non-system declarations
if (pos != Pos.builtIn) { if (pos != Pos.builtIn) {
val existing = getInstanceMemberOrNull(name) val existing = getInstanceMemberOrNull(name)
@ -396,7 +396,7 @@ open class ObjClass(
throw ScriptError(pos, "$name is already defined in $objClass") throw ScriptError(pos, "$name is already defined in $objClass")
// Install/override in this class // Install/override in this class
members[name] = ObjRecord( val rec = ObjRecord(
initialValue, isMutable, visibility, writeVisibility, initialValue, isMutable, visibility, writeVisibility,
declaringClass = declaringClass, declaringClass = declaringClass,
isAbstract = isAbstract, isAbstract = isAbstract,
@ -404,8 +404,10 @@ open class ObjClass(
isOverride = isOverride, isOverride = isOverride,
type = type type = type
) )
members[name] = rec
// Structural change: bump layout version for PIC invalidation // Structural change: bump layout version for PIC invalidation
layoutVersion += 1 layoutVersion += 1
return rec
} }
private fun initClassScope(): Scope { private fun initClassScope(): Scope {
@ -419,15 +421,17 @@ open class ObjClass(
isMutable: Boolean = false, isMutable: Boolean = false,
visibility: Visibility = Visibility.Public, visibility: Visibility = Visibility.Public,
writeVisibility: Visibility? = null, writeVisibility: Visibility? = null,
pos: Pos = Pos.builtIn pos: Pos = Pos.builtIn,
) { type: ObjRecord.Type = ObjRecord.Type.Field
): ObjRecord {
initClassScope() initClassScope()
val existing = classScope!!.objects[name] val existing = classScope!!.objects[name]
if (existing != null) if (existing != null)
throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes") throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes")
classScope!!.addItem(name, isMutable, initialValue, visibility, writeVisibility) val rec = classScope!!.addItem(name, isMutable, initialValue, visibility, writeVisibility, recordType = type)
// Structural change: bump layout version for PIC invalidation // Structural change: bump layout version for PIC invalidation
layoutVersion += 1 layoutVersion += 1
return rec
} }
fun addFn( fun addFn(
@ -558,15 +562,21 @@ open class ObjClass(
override suspend fun readField(scope: Scope, name: String): ObjRecord { override suspend fun readField(scope: Scope, name: String): ObjRecord {
classScope?.objects?.get(name)?.let { classScope?.objects?.get(name)?.let {
if (it.visibility.isPublic) return it if (it.visibility.isPublic) return resolveRecord(scope, it, name, this)
} }
return super.readField(scope, name) return super.readField(scope, name)
} }
override suspend fun writeField(scope: Scope, name: String, newValue: Obj) { override suspend fun writeField(scope: Scope, name: String, newValue: Obj) {
initClassScope().objects[name]?.let { initClassScope().objects[name]?.let { rec ->
if (it.isMutable) it.value = newValue if (rec.type == ObjRecord.Type.Delegated) {
val del = rec.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate")
del.invokeInstanceMethod(scope, "setValue", Arguments(this, ObjString(name), newValue))
return
}
if (rec.isMutable) rec.value = newValue
else scope.raiseIllegalAssignment("can't assign $name is not mutable") else scope.raiseIllegalAssignment("can't assign $name is not mutable")
return
} }
?: super.writeField(scope, name, newValue) ?: super.writeField(scope, name, newValue)
} }
@ -575,8 +585,16 @@ open class ObjClass(
scope: Scope, name: String, args: Arguments, scope: Scope, name: String, args: Arguments,
onNotFoundResult: (suspend () -> Obj?)? onNotFoundResult: (suspend () -> Obj?)?
): Obj { ): Obj {
return classScope?.objects?.get(name)?.value?.invoke(scope, this, args) getInstanceMemberOrNull(name)?.let { rec ->
?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult) val decl = rec.declaringClass ?: findDeclaringClassOf(name) ?: this
if (rec.type == ObjRecord.Type.Delegated) {
val del = rec.delegate ?: scope.raiseError("Internal error: delegated function $name has no delegate")
val allArgs = (listOf(this, ObjString(name)) + args.list).toTypedArray()
return del.invokeInstanceMethod(scope, "invoke", Arguments(*allArgs))
}
return rec.value.invoke(scope, this, args, decl)
}
return super.invokeInstanceMethod(scope, name, args, onNotFoundResult)
} }
open suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj = open suspend fun deserialize(scope: Scope, decoder: LynonDecoder, lynonType: LynonType?): Obj =

View File

@ -31,7 +31,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
internal lateinit var instanceScope: Scope internal lateinit var instanceScope: Scope
override suspend fun readField(scope: Scope, name: String): ObjRecord { override suspend fun readField(scope: Scope, name: String): ObjRecord {
// Direct (unmangled) lookup first // 1. Direct (unmangled) lookup first
instanceScope[name]?.let { rec -> instanceScope[name]?.let { rec ->
val decl = rec.declaringClass val decl = rec.declaringClass
// Allow unconditional access when accessing through `this` of the same instance // Allow unconditional access when accessing through `this` of the same instance
@ -46,33 +46,20 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
) )
) )
} }
if (rec.type == ObjRecord.Type.Property) { return resolveRecord(scope, rec, name, decl)
val prop = rec.value as ObjProperty
return rec.copy(value = prop.callGetter(scope, this, decl))
} }
return rec
}
// Try MI-mangled lookup along linearization (C3 MRO): ClassName::name
val cls = objClass
// self first, then parents // 2. MI-mangled instance scope lookup
fun findMangled(): ObjRecord? { val cls = objClass
// self fun findMangledInRead(): ObjRecord? {
instanceScope.objects["${cls.className}::$name"]?.let { instanceScope.objects["${cls.className}::$name"]?.let { return it }
if (name == "c") println("[DEBUG_LOG] findMangled('c') found in self (${cls.className}): value=${it.value}")
return it
}
// ancestors in deterministic C3 order
for (p in cls.mroParents) { for (p in cls.mroParents) {
instanceScope.objects["${p.className}::$name"]?.let { instanceScope.objects["${p.className}::$name"]?.let { return it }
if (name == "c") println("[DEBUG_LOG] findMangled('c') found in parent (${p.className}): value=${it.value}")
return it
}
} }
return null return null
} }
findMangled()?.let { rec ->
// derive declaring class by mangled prefix: try self then parents findMangledInRead()?.let { rec ->
val declaring = when { val declaring = when {
instanceScope.objects.containsKey("${cls.className}::$name") -> cls instanceScope.objects.containsKey("${cls.className}::$name") -> cls
else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") } else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") }
@ -87,16 +74,32 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
) )
) )
} }
if (rec.type == ObjRecord.Type.Property) { return resolveRecord(scope, rec, name, declaring)
val prop = rec.value as ObjProperty
return rec.copy(value = prop.callGetter(scope, this, declaring))
} }
return rec
} // 3. Fall back to super (handles class members and extensions)
// Fall back to methods/properties on class
return super.readField(scope, name) return super.readField(scope, name)
} }
override suspend fun resolveRecord(scope: Scope, obj: ObjRecord, name: String, decl: ObjClass?): ObjRecord {
if (obj.type == ObjRecord.Type.Delegated) {
val storageName = "${decl?.className}::$name"
var del = instanceScope[storageName]?.delegate
if (del == null) {
for (c in objClass.mro) {
del = instanceScope["${c.className}::$name"]?.delegate
if (del != null) break
}
}
del = del ?: obj.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate (tried $storageName)")
return obj.copy(
value = del.invokeInstanceMethod(scope, "getValue", Arguments(this, ObjString(name))),
type = ObjRecord.Type.Other
)
}
return super.resolveRecord(scope, obj, name, decl)
}
override suspend fun writeField(scope: Scope, name: String, newValue: Obj) { override suspend fun writeField(scope: Scope, name: String, newValue: Obj) {
// Direct (unmangled) first // Direct (unmangled) first
instanceScope[name]?.let { f -> instanceScope[name]?.let { f ->
@ -114,6 +117,19 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
prop.callSetter(scope, this, newValue, decl) prop.callSetter(scope, this, newValue, decl)
return return
} }
if (f.type == ObjRecord.Type.Delegated) {
val storageName = "${decl?.className}::$name"
var del = instanceScope[storageName]?.delegate
if (del == null) {
for (c in objClass.mro) {
del = instanceScope["${c.className}::$name"]?.delegate
if (del != null) break
}
}
del = del ?: f.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate (tried $storageName)")
del.invokeInstanceMethod(scope, "setValue", Arguments(this, ObjString(name), newValue))
return
}
if (!f.isMutable && f.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (!f.isMutable && f.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise()
if (f.value.assign(scope, newValue) == null) if (f.value.assign(scope, newValue) == null)
f.value = newValue f.value = newValue
@ -131,7 +147,6 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
val rec = findMangled() val rec = findMangled()
if (rec != null) { if (rec != null) {
if (name == "c") println("[DEBUG_LOG] writeField('c') found in mangled: value was ${rec.value}, setting to $newValue")
val declaring = when { val declaring = when {
instanceScope.objects.containsKey("${cls.className}::$name") -> cls instanceScope.objects.containsKey("${cls.className}::$name") -> cls
else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") } else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") }
@ -149,6 +164,19 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
prop.callSetter(scope, this, newValue, declaring) prop.callSetter(scope, this, newValue, declaring)
return return
} }
if (rec.type == ObjRecord.Type.Delegated) {
val storageName = "${declaring?.className}::$name"
var del = instanceScope[storageName]?.delegate
if (del == null) {
for (c in objClass.mro) {
del = instanceScope["${c.className}::$name"]?.delegate
if (del != null) break
}
}
del = del ?: rec.delegate ?: scope.raiseError("Internal error: delegated property $name has no delegate (tried $storageName)")
del.invokeInstanceMethod(scope, "setValue", Arguments(this, ObjString(name), newValue))
return
}
if (!rec.isMutable && rec.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (!rec.isMutable && rec.value !== ObjUnset) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise()
if (rec.value.assign(scope, newValue) == null) if (rec.value.assign(scope, newValue) == null)
rec.value = newValue rec.value = newValue
@ -160,45 +188,42 @@ class ObjInstance(override val objClass: ObjClass) : Obj() {
override suspend fun invokeInstanceMethod( override suspend fun invokeInstanceMethod(
scope: Scope, name: String, args: Arguments, scope: Scope, name: String, args: Arguments,
onNotFoundResult: (suspend () -> Obj?)? onNotFoundResult: (suspend () -> Obj?)?
): Obj = ): Obj {
instanceScope[name]?.let { rec -> // 1. Walk MRO to find member, handling delegation
if (rec.type == ObjRecord.Type.Property || rec.isAbstract) null for (cls in objClass.mro) {
else { if (cls.className == "Obj") break
val decl = rec.declaringClass val rec = cls.members[name] ?: cls.classScope?.objects?.get(name)
if (rec != null) {
if (rec.type == ObjRecord.Type.Delegated) {
val storageName = "${cls.className}::$name"
val del = instanceScope[storageName]?.delegate ?: rec.delegate
?: scope.raiseError("Internal error: delegated function $name has no delegate (tried $storageName)")
val allArgs = (listOf(this, ObjString(name)) + args.list).toTypedArray()
return del.invokeInstanceMethod(scope, "invoke", Arguments(*allArgs))
}
if (rec.type != ObjRecord.Type.Property && !rec.isAbstract) {
val decl = rec.declaringClass ?: cls
val caller = scope.currentClassCtx ?: if (scope.thisObj === this) objClass else null val caller = scope.currentClassCtx ?: if (scope.thisObj === this) objClass else null
if (!canAccessMember(rec.visibility, decl, caller)) if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError( scope.raiseError(
ObjIllegalAccessException( ObjIllegalAccessException(
scope, scope,
"can't invoke method $name (declared in ${decl?.className ?: "?"})" "can't invoke method $name (declared in ${decl.className ?: "?"})"
) )
) )
rec.value.invoke( return rec.value.invoke(
instanceScope, instanceScope,
this, this,
args args,
decl
) )
} }
} }
?: run {
// fallback: class-scope function (registered during class body execution)
objClass.classScope?.objects?.get(name)?.let { rec ->
if (rec.type == ObjRecord.Type.Property || rec.isAbstract) null
else {
val decl = rec.declaringClass
val caller = scope.currentClassCtx ?: if (scope.thisObj === this) objClass else null
if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(
ObjIllegalAccessException(
scope,
"can't invoke method $name (declared in ${decl?.className ?: "?"})"
)
)
rec.value.invoke(instanceScope, this, args)
} }
// 2. Fall back to super (handles extensions and root fallback)
return super.invokeInstanceMethod(scope, name, args, onNotFoundResult)
} }
}
?: super.invokeInstanceMethod(scope, name, args, onNotFoundResult)
private val publicFields: Map<String, ObjRecord> private val publicFields: Map<String, ObjRecord>
get() = instanceScope.objects.filter { get() = instanceScope.objects.filter {
@ -320,7 +345,7 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(rec.visibility, decl, caller)) if (!canAccessMember(rec.visibility, decl, caller))
scope.raiseError(ObjIllegalAccessException(scope, "can't access field $name (declared in ${decl.className})")) scope.raiseError(ObjIllegalAccessException(scope, "can't access field $name (declared in ${decl.className})"))
return rec return resolveRecord(scope, rec, name, decl)
} }
// Then try instance locals (unmangled) only if startClass is the dynamic class itself // Then try instance locals (unmangled) only if startClass is the dynamic class itself
if (startClass === instance.objClass) { if (startClass === instance.objClass) {
@ -334,7 +359,7 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla
"can't access field $name (declared in ${decl?.className ?: "?"})" "can't access field $name (declared in ${decl?.className ?: "?"})"
) )
) )
return rec return resolveRecord(scope, rec, name, decl)
} }
} }
// Finally try methods/properties starting from ancestor // Finally try methods/properties starting from ancestor
@ -343,18 +368,8 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla
val caller = scope.currentClassCtx val caller = scope.currentClassCtx
if (!canAccessMember(r.visibility, decl, caller)) if (!canAccessMember(r.visibility, decl, caller))
scope.raiseError(ObjIllegalAccessException(scope, "can't access field $name (declared in ${decl.className})")) scope.raiseError(ObjIllegalAccessException(scope, "can't access field $name (declared in ${decl.className})"))
return when (val value = r.value) {
is net.sergeych.lyng.Statement -> ObjRecord(
value.execute(
instance.instanceScope.createChildScope(
scope.pos,
newThisObj = instance
)
), r.isMutable
)
else -> r return resolveRecord(scope, r, name, decl)
}
} }
override suspend fun writeField(scope: Scope, name: String, newValue: Obj) { override suspend fun writeField(scope: Scope, name: String, newValue: Obj) {

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -35,6 +35,7 @@ data class ObjRecord(
val isAbstract: Boolean = false, val isAbstract: Boolean = false,
val isClosed: Boolean = false, val isClosed: Boolean = false,
val isOverride: Boolean = false, val isOverride: Boolean = false,
var delegate: Obj? = null,
) { ) {
val effectiveWriteVisibility: Visibility get() = writeVisibility ?: visibility val effectiveWriteVisibility: Visibility get() = writeVisibility ?: visibility
enum class Type(val comparable: Boolean = false,val serializable: Boolean = false) { enum class Type(val comparable: Boolean = false,val serializable: Boolean = false) {
@ -48,6 +49,7 @@ data class ObjRecord(
Class, Class,
Enum, Enum,
Property, Property,
Delegated,
Other; Other;
val isArgument get() = this == Argument val isArgument get() = this == Argument

View File

@ -32,7 +32,11 @@ sealed interface ObjRef {
* Fast path for evaluating an expression to a raw Obj value without wrapping it into ObjRecord. * Fast path for evaluating an expression to a raw Obj value without wrapping it into ObjRecord.
* Default implementation calls [get] and returns its value. Nodes can override to avoid record traffic. * Default implementation calls [get] and returns its value. Nodes can override to avoid record traffic.
*/ */
suspend fun evalValue(scope: Scope): Obj = get(scope).value suspend fun evalValue(scope: Scope): Obj {
val rec = get(scope)
if (rec.type == ObjRecord.Type.Delegated) return scope.resolve(rec, "unknown")
return rec.value
}
suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) { suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
throw ScriptError(pos, "can't assign value") throw ScriptError(pos, "can't assign value")
} }
@ -79,8 +83,7 @@ enum class BinOp {
/** R-value reference for unary operations. */ /** R-value reference for unary operations. */
class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef { class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val fastRval = PerfFlags.RVAL_FASTPATH val v = a.evalValue(scope)
val v = if (fastRval) a.evalValue(scope) else a.get(scope).value
if (PerfFlags.PRIMITIVE_FASTOPS) { if (PerfFlags.PRIMITIVE_FASTOPS) {
val rFast: Obj? = when (op) { val rFast: Obj? = when (op) {
UnaryOp.NOT -> if (v is ObjBool) if (!v.value) ObjTrue else ObjFalse else null UnaryOp.NOT -> if (v is ObjBool) if (!v.value) ObjTrue else ObjFalse else null
@ -108,8 +111,8 @@ class UnaryOpRef(private val op: UnaryOp, private val a: ObjRef) : ObjRef {
/** R-value reference for binary operations. */ /** R-value reference for binary operations. */
class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val right: ObjRef) : ObjRef { class BinaryOpRef(private val op: BinOp, private val left: ObjRef, private val right: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val a = if (PerfFlags.RVAL_FASTPATH) left.evalValue(scope) else left.get(scope).value val a = left.evalValue(scope)
val b = if (PerfFlags.RVAL_FASTPATH) right.evalValue(scope) else right.get(scope).value val b = right.evalValue(scope)
// Primitive fast paths for common cases (guarded by PerfFlags.PRIMITIVE_FASTOPS) // Primitive fast paths for common cases (guarded by PerfFlags.PRIMITIVE_FASTOPS)
if (PerfFlags.PRIMITIVE_FASTOPS) { if (PerfFlags.PRIMITIVE_FASTOPS) {
@ -277,7 +280,7 @@ class ConditionalRef(
private val ifFalse: ObjRef private val ifFalse: ObjRef
) : ObjRef { ) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val condVal = if (PerfFlags.RVAL_FASTPATH) condition.evalValue(scope) else condition.get(scope).value val condVal = condition.evalValue(scope)
val condTrue = when (condVal) { val condTrue = when (condVal) {
is ObjBool -> condVal.value is ObjBool -> condVal.value
is ObjInt -> condVal.value != 0L is ObjInt -> condVal.value != 0L
@ -296,8 +299,8 @@ class CastRef(
private val atPos: Pos, private val atPos: Pos,
) : ObjRef { ) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val v0 = if (PerfFlags.RVAL_FASTPATH) valueRef.evalValue(scope) else valueRef.get(scope).value val v0 = valueRef.evalValue(scope)
val t = if (PerfFlags.RVAL_FASTPATH) typeRef.evalValue(scope) else typeRef.get(scope).value val t = typeRef.evalValue(scope)
val target = (t as? ObjClass) ?: scope.raiseClassCastError("${'$'}t is not the class instance") val target = (t as? ObjClass) ?: scope.raiseClassCastError("${'$'}t is not the class instance")
// unwrap qualified views // unwrap qualified views
val v = when (v0) { val v = when (v0) {
@ -339,8 +342,8 @@ class AssignOpRef(
private val atPos: Pos, private val atPos: Pos,
) : ObjRef { ) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val x = target.get(scope).value val x = target.evalValue(scope)
val y = if (PerfFlags.RVAL_FASTPATH) value.evalValue(scope) else value.get(scope).value val y = value.evalValue(scope)
val inPlace: Obj? = when (op) { val inPlace: Obj? = when (op) {
BinOp.PLUS -> x.plusAssign(scope, y) BinOp.PLUS -> x.plusAssign(scope, y)
BinOp.MINUS -> x.minusAssign(scope, y) BinOp.MINUS -> x.minusAssign(scope, y)
@ -373,7 +376,7 @@ class IncDecRef(
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val rec = target.get(scope) val rec = target.get(scope)
if (!rec.isMutable) scope.raiseError("Cannot ${if (isIncrement) "increment" else "decrement"} immutable value") if (!rec.isMutable) scope.raiseError("Cannot ${if (isIncrement) "increment" else "decrement"} immutable value")
val v = rec.value val v = scope.resolve(rec, "unknown")
val one = ObjInt.One val one = ObjInt.One
// We now treat numbers as immutable and always perform write-back via setAt. // We now treat numbers as immutable and always perform write-back via setAt.
// This avoids issues where literals are shared and mutated in-place. // This avoids issues where literals are shared and mutated in-place.
@ -387,9 +390,8 @@ class IncDecRef(
/** Elvis operator reference: a ?: b */ /** Elvis operator reference: a ?: b */
class ElvisRef(private val left: ObjRef, private val right: ObjRef) : ObjRef { class ElvisRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val fastRval = PerfFlags.RVAL_FASTPATH val a = left.evalValue(scope)
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value val r = if (a != ObjNull) a else right.evalValue(scope)
val r = if (a != ObjNull) a else if (fastRval) right.evalValue(scope) else right.get(scope).value
return r.asReadonly return r.asReadonly
} }
} }
@ -397,12 +399,10 @@ class ElvisRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
/** Logical OR with short-circuit: a || b */ /** Logical OR with short-circuit: a || b */
class LogicalOrRef(private val left: ObjRef, private val right: ObjRef) : ObjRef { class LogicalOrRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val fastRval = PerfFlags.RVAL_FASTPATH val a = left.evalValue(scope)
val fastPrim = PerfFlags.PRIMITIVE_FASTOPS
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
if ((a as? ObjBool)?.value == true) return ObjTrue.asReadonly if ((a as? ObjBool)?.value == true) return ObjTrue.asReadonly
val b = if (fastRval) right.evalValue(scope) else right.get(scope).value val b = right.evalValue(scope)
if (fastPrim) { if (PerfFlags.PRIMITIVE_FASTOPS) {
if (a is ObjBool && b is ObjBool) { if (a is ObjBool && b is ObjBool) {
return if (a.value || b.value) ObjTrue.asReadonly else ObjFalse.asReadonly return if (a.value || b.value) ObjTrue.asReadonly else ObjFalse.asReadonly
} }
@ -414,13 +414,10 @@ class LogicalOrRef(private val left: ObjRef, private val right: ObjRef) : ObjRef
/** Logical AND with short-circuit: a && b */ /** Logical AND with short-circuit: a && b */
class LogicalAndRef(private val left: ObjRef, private val right: ObjRef) : ObjRef { class LogicalAndRef(private val left: ObjRef, private val right: ObjRef) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
// Hoist flags to locals for JIT friendliness val a = left.evalValue(scope)
val fastRval = PerfFlags.RVAL_FASTPATH
val fastPrim = PerfFlags.PRIMITIVE_FASTOPS
val a = if (fastRval) left.evalValue(scope) else left.get(scope).value
if ((a as? ObjBool)?.value == false) return ObjFalse.asReadonly if ((a as? ObjBool)?.value == false) return ObjFalse.asReadonly
val b = if (fastRval) right.evalValue(scope) else right.get(scope).value val b = right.evalValue(scope)
if (fastPrim) { if (PerfFlags.PRIMITIVE_FASTOPS) {
if (a is ObjBool && b is ObjBool) { if (a is ObjBool && b is ObjBool) {
return if (a.value && b.value) ObjTrue.asReadonly else ObjFalse.asReadonly return if (a.value && b.value) ObjTrue.asReadonly else ObjFalse.asReadonly
} }
@ -505,10 +502,9 @@ class FieldRef(
} }
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val fastRval = PerfFlags.RVAL_FASTPATH
val fieldPic = PerfFlags.FIELD_PIC val fieldPic = PerfFlags.FIELD_PIC
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value val base = target.evalValue(scope)
if (base == ObjNull && isOptional) return ObjNull.asMutable if (base == ObjNull && isOptional) return ObjNull.asMutable
if (fieldPic) { if (fieldPic) {
val (key, ver) = receiverKeyAndVersion(base) val (key, ver) = receiverKeyAndVersion(base)
@ -800,11 +796,10 @@ class IndexRef(
else -> 0L to -1 else -> 0L to -1
} }
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val fastRval = PerfFlags.RVAL_FASTPATH val base = target.evalValue(scope)
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
if (base == ObjNull && isOptional) return ObjNull.asMutable if (base == ObjNull && isOptional) return ObjNull.asMutable
val idx = if (fastRval) index.evalValue(scope) else index.get(scope).value val idx = index.evalValue(scope)
if (fastRval) { if (PerfFlags.RVAL_FASTPATH) {
// Primitive list index fast path: avoid virtual dispatch to getAt when shapes match // Primitive list index fast path: avoid virtual dispatch to getAt when shapes match
if (base is ObjList && idx is ObjInt) { if (base is ObjList && idx is ObjInt) {
val i = idx.toInt() val i = idx.toInt()
@ -874,11 +869,10 @@ class IndexRef(
} }
override suspend fun evalValue(scope: Scope): Obj { override suspend fun evalValue(scope: Scope): Obj {
val fastRval = PerfFlags.RVAL_FASTPATH val base = target.evalValue(scope)
val base = if (fastRval) target.evalValue(scope) else target.get(scope).value
if (base == ObjNull && isOptional) return ObjNull if (base == ObjNull && isOptional) return ObjNull
val idx = if (fastRval) index.evalValue(scope) else index.get(scope).value val idx = index.evalValue(scope)
if (fastRval) { if (PerfFlags.RVAL_FASTPATH) {
// Fast list[int] path // Fast list[int] path
if (base is ObjList && idx is ObjInt) { if (base is ObjList && idx is ObjInt) {
val i = idx.toInt() val i = idx.toInt()
@ -1027,9 +1021,8 @@ class CallRef(
private val isOptionalInvoke: Boolean, private val isOptionalInvoke: Boolean,
) : ObjRef { ) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val fastRval = PerfFlags.RVAL_FASTPATH
val usePool = PerfFlags.SCOPE_POOL val usePool = PerfFlags.SCOPE_POOL
val callee = if (fastRval) target.evalValue(scope) else target.get(scope).value val callee = target.evalValue(scope)
if (callee == ObjNull && isOptionalInvoke) return ObjNull.asReadonly if (callee == ObjNull && isOptionalInvoke) return ObjNull.asReadonly
val callArgs = args.toArguments(scope, tailBlock) val callArgs = args.toArguments(scope, tailBlock)
val result: Obj = if (usePool) { val result: Obj = if (usePool) {
@ -1117,10 +1110,9 @@ class MethodCallRef(
} }
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val fastRval = PerfFlags.RVAL_FASTPATH
val methodPic = PerfFlags.METHOD_PIC val methodPic = PerfFlags.METHOD_PIC
val picCounters = PerfFlags.PIC_DEBUG_COUNTERS val picCounters = PerfFlags.PIC_DEBUG_COUNTERS
val base = if (fastRval) receiver.evalValue(scope) else receiver.get(scope).value val base = receiver.evalValue(scope)
if (base == ObjNull && isOptional) return ObjNull.asReadonly if (base == ObjNull && isOptional) return ObjNull.asReadonly
val callArgs = args.toArguments(scope, tailBlock) val callArgs = args.toArguments(scope, tailBlock)
if (methodPic) { if (methodPic) {
@ -1327,21 +1319,21 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
override suspend fun evalValue(scope: Scope): Obj { override suspend fun evalValue(scope: Scope): Obj {
scope.pos = atPos scope.pos = atPos
if (!PerfFlags.LOCAL_SLOT_PIC) { if (!PerfFlags.LOCAL_SLOT_PIC) {
scope.getSlotIndexOf(name)?.let { return scope.getSlotRecord(it).value } scope.getSlotIndexOf(name)?.let { return scope.resolve(scope.getSlotRecord(it), name) }
// fallback to current-scope object or field on `this` // fallback to current-scope object or field on `this`
scope[name]?.let { return it.value } scope[name]?.let { return scope.resolve(it, name) }
run { run {
var s: Scope? = scope var s: Scope? = scope
val visited = HashSet<Long>(4) val visited = HashSet<Long>(4)
while (s != null) { while (s != null) {
if (!visited.add(s.frameId)) break if (!visited.add(s.frameId)) break
if (s is ClosureScope) { if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { return it.value } s.closureScope.chainLookupWithMembers(name)?.let { return s.resolve(it, name) }
} }
s = s.parent s = s.parent
} }
} }
scope.chainLookupIgnoreClosure(name)?.let { return it.value } scope.chainLookupIgnoreClosure(name)?.let { return scope.resolve(it, name) }
return try { return try {
scope.thisObj.readField(scope, name).value scope.thisObj.readField(scope, name).value
} catch (e: ExecutionError) { } catch (e: ExecutionError) {
@ -1353,25 +1345,29 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
val slot = if (hit) cachedSlot else resolveSlot(scope) val slot = if (hit) cachedSlot else resolveSlot(scope)
if (slot >= 0) { if (slot >= 0) {
val rec = scope.getSlotRecord(slot) val rec = scope.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
return rec.value return scope.resolve(rec, name)
}
} }
// Fallback name in scope or field on `this` // Fallback name in scope or field on `this`
scope[name]?.let { return it.value } scope[name]?.let {
return scope.resolve(it, name)
}
run { run {
var s: Scope? = scope var s: Scope? = scope
val visited = HashSet<Long>(4) val visited = HashSet<Long>(4)
while (s != null) { while (s != null) {
if (!visited.add(s.frameId)) break if (!visited.add(s.frameId)) break
if (s is ClosureScope) { if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { return it.value } s.closureScope.chainLookupWithMembers(name)?.let { return s.resolve(it, name) }
} }
s = s.parent s = s.parent
} }
} }
scope.chainLookupIgnoreClosure(name)?.let { return it.value } scope.chainLookupIgnoreClosure(name)?.let { return scope.resolve(it, name) }
return try { return try {
scope.thisObj.readField(scope, name).value val res = scope.thisObj.readField(scope, name).value
res
} catch (e: ExecutionError) { } catch (e: ExecutionError) {
if ((e.message ?: "").contains("no such field: $name")) scope.raiseSymbolNotFound(name) if ((e.message ?: "").contains("no such field: $name")) scope.raiseSymbolNotFound(name)
throw e throw e
@ -1383,13 +1379,11 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
if (!PerfFlags.LOCAL_SLOT_PIC) { if (!PerfFlags.LOCAL_SLOT_PIC) {
scope.getSlotIndexOf(name)?.let { scope.getSlotIndexOf(name)?.let {
val rec = scope.getSlotRecord(it) val rec = scope.getSlotRecord(it)
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") scope.assign(rec, name, newValue)
rec.value = newValue
return return
} }
scope[name]?.let { stored -> scope[name]?.let { stored ->
if (stored.isMutable) stored.value = newValue scope.assign(stored, name, newValue)
else scope.raiseError("Cannot assign to immutable value")
return return
} }
run { run {
@ -1399,8 +1393,7 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
if (!visited.add(s.frameId)) break if (!visited.add(s.frameId)) break
if (s is ClosureScope) { if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { stored -> s.closureScope.chainLookupWithMembers(name)?.let { stored ->
if (stored.isMutable) stored.value = newValue s.assign(stored, name, newValue)
else scope.raiseError("Cannot assign to immutable value")
return return
} }
} }
@ -1408,8 +1401,7 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
} }
} }
scope.chainLookupIgnoreClosure(name)?.let { stored -> scope.chainLookupIgnoreClosure(name)?.let { stored ->
if (stored.isMutable) stored.value = newValue scope.assign(stored, name, newValue)
else scope.raiseError("Cannot assign to immutable value")
return return
} }
// Fallback: write to field on `this` // Fallback: write to field on `this`
@ -1420,14 +1412,12 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
if (slot >= 0) { if (slot >= 0) {
val rec = scope.getSlotRecord(slot) val rec = scope.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) { if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") scope.assign(rec, name, newValue)
rec.value = newValue
return return
} }
} }
scope[name]?.let { stored -> scope[name]?.let { stored ->
if (stored.isMutable) stored.value = newValue scope.assign(stored, name, newValue)
else scope.raiseError("Cannot assign to immutable value")
return return
} }
run { run {
@ -1437,8 +1427,7 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
if (!visited.add(s.frameId)) break if (!visited.add(s.frameId)) break
if (s is ClosureScope) { if (s is ClosureScope) {
s.closureScope.chainLookupWithMembers(name)?.let { stored -> s.closureScope.chainLookupWithMembers(name)?.let { stored ->
if (stored.isMutable) stored.value = newValue s.assign(stored, name, newValue)
else scope.raiseError("Cannot assign to immutable value")
return return
} }
} }
@ -1446,8 +1435,7 @@ class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef {
} }
} }
scope.chainLookupIgnoreClosure(name)?.let { stored -> scope.chainLookupIgnoreClosure(name)?.let { stored ->
if (stored.isMutable) stored.value = newValue scope.assign(stored, name, newValue)
else scope.raiseError("Cannot assign to immutable value")
return return
} }
scope.thisObj.writeField(scope, name, newValue) scope.thisObj.writeField(scope, name, newValue)
@ -1476,7 +1464,10 @@ class BoundLocalVarRef(
val rec = scope.getSlotRecord(slot) val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
scope.raiseError(ObjIllegalAccessException(scope, "private field access")) scope.raiseError(ObjIllegalAccessException(scope, "private field access"))
return rec.value // We might not have the name in BoundLocalVarRef, but let's try to find it or use a placeholder
// Actually BoundLocalVarRef is mostly used for parameters which are not delegated yet.
// But for consistency:
return scope.resolve(rec, "local")
} }
override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) { override suspend fun setAt(pos: Pos, scope: Scope, newValue: Obj) {
@ -1484,8 +1475,7 @@ class BoundLocalVarRef(
val rec = scope.getSlotRecord(slot) val rec = scope.getSlotRecord(slot)
if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) if (rec.declaringClass != null && !canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx))
scope.raiseError(ObjIllegalAccessException(scope, "private field access")) scope.raiseError(ObjIllegalAccessException(scope, "private field access"))
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") scope.assign(rec, "local", newValue)
rec.value = newValue
} }
} }
@ -1592,15 +1582,18 @@ class FastLocalVarRef(
val actualOwner = cachedOwnerScope val actualOwner = cachedOwnerScope
if (slot >= 0 && actualOwner != null) { if (slot >= 0 && actualOwner != null) {
val rec = actualOwner.getSlotRecord(slot) val rec = actualOwner.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
return rec.value return actualOwner.resolve(rec, name)
}
} }
// Try per-frame local binding maps in the ancestry first // Try per-frame local binding maps in the ancestry first
run { run {
var s: Scope? = scope var s: Scope? = scope
var guard = 0 var guard = 0
while (s != null) { while (s != null) {
s.localBindings[name]?.let { return it.value } s.localBindings[name]?.let {
return s.resolve(it, name)
}
val next = s.parent val next = s.parent
if (next === s) break if (next === s) break
s = next s = next
@ -1612,7 +1605,9 @@ class FastLocalVarRef(
var s: Scope? = scope var s: Scope? = scope
var guard = 0 var guard = 0
while (s != null) { while (s != null) {
s.objects[name]?.let { return it.value } s.objects[name]?.let {
return s.resolve(it, name)
}
val next = s.parent val next = s.parent
if (next === s) break if (next === s) break
s = next s = next
@ -1620,7 +1615,9 @@ class FastLocalVarRef(
} }
} }
// Fallback to standard name lookup (locals or closure chain) // Fallback to standard name lookup (locals or closure chain)
scope[name]?.let { return it.value } scope[name]?.let {
return scope.resolve(it, name)
}
return scope.thisObj.readField(scope, name).value return scope.thisObj.readField(scope, name).value
} }
@ -1632,8 +1629,7 @@ class FastLocalVarRef(
if (slot >= 0 && actualOwner != null) { if (slot >= 0 && actualOwner != null) {
val rec = actualOwner.getSlotRecord(slot) val rec = actualOwner.getSlotRecord(slot)
if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) { if (rec.declaringClass == null || canAccessMember(rec.visibility, rec.declaringClass, scope.currentClassCtx)) {
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") scope.assign(rec, name, newValue)
rec.value = newValue
return return
} }
} }
@ -1644,8 +1640,7 @@ class FastLocalVarRef(
while (s != null) { while (s != null) {
val rec = s.localBindings[name] val rec = s.localBindings[name]
if (rec != null) { if (rec != null) {
if (!rec.isMutable) scope.raiseError("Cannot assign to immutable value") s.assign(rec, name, newValue)
rec.value = newValue
return return
} }
val next = s.parent val next = s.parent
@ -1656,8 +1651,7 @@ class FastLocalVarRef(
} }
// Fallback to standard name lookup // Fallback to standard name lookup
scope[name]?.let { stored -> scope[name]?.let { stored ->
if (stored.isMutable) stored.value = newValue scope.assign(stored, name, newValue)
else scope.raiseError("Cannot assign to immutable value")
return return
} }
scope.thisObj.writeField(scope, name, newValue) scope.thisObj.writeField(scope, name, newValue)
@ -1691,11 +1685,11 @@ class ListLiteralRef(private val entries: List<ListEntry>) : ObjRef {
for (e in entries) { for (e in entries) {
when (e) { when (e) {
is ListEntry.Element -> { is ListEntry.Element -> {
val v = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value val v = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.evalValue(scope)
list += v list += v
} }
is ListEntry.Spread -> { is ListEntry.Spread -> {
val elements = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value val elements = e.ref.evalValue(scope)
when (elements) { when (elements) {
is ObjList -> { is ObjList -> {
// Grow underlying array once when possible // Grow underlying array once when possible
@ -1771,11 +1765,11 @@ class MapLiteralRef(private val entries: List<MapLiteralEntry>) : ObjRef {
for (e in entries) { for (e in entries) {
when (e) { when (e) {
is MapLiteralEntry.Named -> { is MapLiteralEntry.Named -> {
val v = if (PerfFlags.RVAL_FASTPATH) e.value.evalValue(scope) else e.value.get(scope).value val v = e.value.evalValue(scope)
result.map[ObjString(e.key)] = v result.map[ObjString(e.key)] = v
} }
is MapLiteralEntry.Spread -> { is MapLiteralEntry.Spread -> {
val m = if (PerfFlags.RVAL_FASTPATH) e.ref.evalValue(scope) else e.ref.get(scope).value val m = e.ref.evalValue(scope)
if (m !is ObjMap) scope.raiseIllegalArgument("spread element in map literal must be a Map") if (m !is ObjMap) scope.raiseIllegalArgument("spread element in map literal must be a Map")
for ((k, v) in m.map) { for ((k, v) in m.map) {
result.map[k] = v result.map[k] = v
@ -1796,8 +1790,8 @@ class RangeRef(
private val isEndInclusive: Boolean private val isEndInclusive: Boolean
) : ObjRef { ) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val l = left?.let { if (PerfFlags.RVAL_FASTPATH) it.evalValue(scope) else it.get(scope).value } ?: ObjNull val l = left?.evalValue(scope) ?: ObjNull
val r = right?.let { if (PerfFlags.RVAL_FASTPATH) it.evalValue(scope) else it.get(scope).value } ?: ObjNull val r = right?.evalValue(scope) ?: ObjNull
return ObjRange(l, r, isEndInclusive = isEndInclusive).asReadonly return ObjRange(l, r, isEndInclusive = isEndInclusive).asReadonly
} }
} }
@ -1809,7 +1803,7 @@ class AssignRef(
private val atPos: Pos, private val atPos: Pos,
) : ObjRef { ) : ObjRef {
override suspend fun get(scope: Scope): ObjRecord { override suspend fun get(scope: Scope): ObjRecord {
val v = if (PerfFlags.RVAL_FASTPATH) value.evalValue(scope) else value.get(scope).value val v = value.evalValue(scope)
// For properties, we should not call get() on target because it invokes the getter. // For properties, we should not call get() on target because it invokes the getter.
// Instead, we call setAt directly. // Instead, we call setAt directly.
if (target is FieldRef || target is IndexRef || target is LocalVarRef || target is FastLocalVarRef || target is BoundLocalVarRef) { if (target is FieldRef || target is IndexRef || target is LocalVarRef || target is FastLocalVarRef || target is BoundLocalVarRef) {

View File

@ -0,0 +1,213 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyng
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
class DelegationTest {
@Test
fun testSimpleDelegation() = runTest {
eval("""
class Proxy() {
fun getValue(r, n) = 42
}
val x by Proxy()
assertEquals(42, x)
""")
}
@Test
fun testConstructorVal() = runTest {
eval("""
class Foo(val v) {
fun getV() = v
}
val f = Foo(42)
assertEquals(42, f.v)
assertEquals(42, f.getV())
""")
}
@Test
fun testBasicValVarDelegation() = runTest {
eval("""
class MapDelegate(val map) {
fun getValue(thisRef, name) = map[name]
fun setValue(thisRef, name, value) { map[name] = value }
}
val data = { "x": 10 }
val x by MapDelegate(data)
var y by MapDelegate(data)
assertEquals(10, x)
assertEquals(null, y)
y = 20
assertEquals(20, data["y"])
assertEquals(20, y)
""")
}
@Test
fun testClassDelegationWithThisRef() = runTest {
eval("""
class Proxy(val target) {
fun getValue(thisRef, name) = target[name]
fun setValue(thisRef, name, value) { target[name] = value }
}
class User(initialName) {
val storage = { "name": initialName }
var name by Proxy(storage)
}
val u = User("Alice")
assertEquals("Alice", u.name)
u.name = "Bob"
assertEquals("Bob", u.name)
assertEquals("Bob", u.storage["name"])
""")
}
@Test
fun testFunDelegation() = runTest {
eval("""
class ActionDelegate() {
fun invoke(thisRef, name, args...) {
"Called %s with %d args: %s"(name, args.size, args.joinToString(","))
}
}
fun greet by ActionDelegate()
assertEquals("Called greet with 2 args: hello,world", greet("hello", "world"))
""")
}
@Test
fun testBindHook() = runTest {
eval("""
// Note: DelegateAccess might need to be defined or built-in
// For the test, let's assume it's passed as an integer or we define it
val VAL = 0
val VAR = 1
val CALLABLE = 2
class OnlyVal() {
fun bind(name, access, thisRef) {
if (access != VAL) throw "Only val allowed"
this
}
fun getValue(thisRef, name) = 42
}
val ok by OnlyVal()
assertEquals(42, ok)
assertThrows {
eval("var bad by OnlyVal()")
}
""")
}
@Test
fun testStatelessObjectDelegate() = runTest {
eval("""
object Constant42 {
fun getValue(thisRef, name) = 42
}
class Foo {
val a by Constant42
val b by Constant42
}
val f = Foo()
assertEquals(42, f.a)
assertEquals(42, f.b)
""")
}
@Test
fun testLazyImplementation() = runTest {
eval("""
class Lazy(val creator) {
private var value = Unset
fun getValue(thisRef, name) {
if (this.value == Unset) {
this.value = creator()
}
this.value
}
}
fun lazy(creator) = Lazy(creator)
var counter = 0
val x by lazy { counter++; "computed" }
assertEquals(0, counter)
assertEquals("computed", x)
assertEquals(1, counter)
assertEquals("computed", x)
assertEquals(1, counter)
""")
}
@Test
fun testLocalDelegation() = runTest {
eval("""
class LocalProxy(val v) {
fun getValue(thisRef, name) = v
}
fun test() {
val x by LocalProxy(123)
x
}
assertEquals(123, test())
""")
}
@Test
fun testStdlibLazy() = runTest {
eval("""
var counter = 0
val x by lazy { counter++; "computed" }
assertEquals(0, counter)
assertEquals("computed", x)
assertEquals(1, counter)
assertEquals("computed", x)
assertThrows {
eval("var y by lazy { 1 }")
}
""")
}
@Test
fun testLazyIsDelegate() = runTest {
eval("""
val l = lazy { 42 }
assert(l is Delegate)
""")
}
}

View File

@ -277,3 +277,55 @@ fun Exception.printStackTrace() {
val String.re get() = Regex(this) val String.re get() = Regex(this)
fun TODO(message=null) = throw NotImplementedException(message ?: "not implemented") fun TODO(message=null) = throw NotImplementedException(message ?: "not implemented")
/*
Provides different access types for delegates.
Used in the 'bind' hook to validate delegate usage.
*/
enum DelegateAccess {
Val,
Var,
Callable
}
/*
Base interface for all delegates.
Implementing this interface is optional as Lyng uses dynamic dispatch,
but it is recommended for documentation and clarity.
*/
interface Delegate {
/* Called when a delegated 'val' or 'var' is read. */
fun getValue(thisRef, name) = TODO("delegate getter is not implemented")
/* Called when a delegated 'var' is written. */
fun setValue(thisRef, name, newValue) = TODO("delegate setter is not implemented")
/* Called when a delegated function is invoked. */
fun invoke(thisRef, name, args...) = TODO("delegate invoke is not implemented")
/*
Called once during initialization to configure or validate the delegate.
Should return the delegate object to be used (usually 'this').
*/
fun bind(name, access, thisRef) = this
}
/*
Standard implementation of a lazy-initialized property delegate.
The provided creator lambda is called once on the first access to compute the value.
Can only be used with 'val' properties.
*/
class lazy(creatorParam) : Delegate {
private val creator = creatorParam
private var value = Unset
override fun bind(name, access, thisRef) {
if (access != DelegateAccess.Val) throw "lazy delegate can only be used with 'val'"
this
}
override fun getValue(thisRef, name) {
if (value == Unset) value = creator()
value
}
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -318,6 +318,21 @@ fun EditorWithOverlay(
try { ta.setSelectionRange(res.selStart, res.selEnd) } catch (_: Throwable) {} try { ta.setSelectionRange(res.selStart, res.selEnd) } catch (_: Throwable) {}
pendingSelStart = res.selStart pendingSelStart = res.selStart
pendingSelEnd = res.selEnd pendingSelEnd = res.selEnd
} else if (key.length == 1 && !ev.ctrlKey && !ev.metaKey && !ev.altKey) {
// Handle single character input (like '}') for dedenting
// This is an alternative to onInput to have better control
val start = ta.selectionStart ?: 0
val end = ta.selectionEnd ?: start
val current = ta.value
val res = applyChar(current, start, end, key[0], tabSize)
if (res.text != (current.substring(0, start) + key + current.substring(end))) {
// Logic decided to change something else (e.g. dedent)
ev.preventDefault()
setCode(res.text)
try { ta.setSelectionRange(res.selStart, res.selEnd) } catch (_: Throwable) {}
pendingSelStart = res.selStart
pendingSelEnd = res.selEnd
}
} }
} }

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -83,55 +83,6 @@ private fun nextNonWs(text: String, idxInclusive: Int): Int {
/** Apply Enter key behavior with smart indent/undent rules. */ /** Apply Enter key behavior with smart indent/undent rules. */
fun applyEnter(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResult { fun applyEnter(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResult {
// Global early rule (Rule 3): if the line after the current line is brace-only, dedent that line by one block and
// do NOT insert a newline. This uses precise line boundaries.
run {
val start = minOf(selStart, text.length)
val eol = lineEndAt(text, start)
val nextLineStart = if (eol < text.length && text[eol] == '\n') eol + 1 else eol
val nextLineEnd = lineEndAt(text, nextLineStart)
if (nextLineStart <= nextLineEnd && nextLineStart < text.length) {
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// Absolute top-priority: caret is exactly at a line break and the next line is '}'-only (ignoring spaces).
// Dedent that next line by one block (tabSize) and do NOT insert a newline.
run {
if (selStart < text.length) {
val isCrLf = selStart + 1 < text.length && text[selStart] == '\r' && text[selStart + 1] == '\n'
val isLf = text[selStart] == '\n'
if (isCrLf || isLf) {
val nextLineStart = selStart + if (isCrLf) 2 else 1
val nextLineEnd = lineEndAt(text, nextLineStart)
if (nextLineStart <= nextLineEnd) {
val trimmed = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmed == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
}
}
// If there is a selection, replace it by newline + current line indent // If there is a selection, replace it by newline + current line indent
if (selEnd != selStart) { if (selEnd != selStart) {
val lineStart = lineStartAt(text, selStart) val lineStart = lineStartAt(text, selStart)
@ -148,8 +99,6 @@ fun applyEnter(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResu
val lineEnd = lineEndAt(text, start) val lineEnd = lineEndAt(text, start)
val indent = countIndentSpaces(text, lineStart, lineEnd) val indent = countIndentSpaces(text, lineStart, lineEnd)
// (Handled by the global early rule above; no need for additional EOL variants.)
// Compute neighborhood characters early so rule precedence can use them // Compute neighborhood characters early so rule precedence can use them
val prevIdx = prevNonWs(text, start) val prevIdx = prevNonWs(text, start)
val nextIdx = nextNonWs(text, start) val nextIdx = nextNonWs(text, start)
@ -158,85 +107,6 @@ fun applyEnter(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResu
val before = text.substring(0, start) val before = text.substring(0, start)
val after = text.substring(start) val after = text.substring(start)
// Rule 2: On a brace-only line '}' (caret on the same line)
// If the current line’s trimmed text is exactly '}', decrease that line’s indent by one block (not below 0),
// then insert a newline. The newly inserted line uses the (decreased) indent. Place the caret at the start of
// the newly inserted line.
run {
val trimmed = text.substring(lineStart, lineEnd).trim()
// IMPORTANT: Rule precedence — do NOT trigger this rule when caret is after '}' and the rest of the line
// up to EOL contains only spaces (Rule 5 handles that case). That scenario must insert AFTER the brace,
// not before it.
var onlySpacesAfterCaret = true
var k = start
while (k < lineEnd) { if (text[k] != ' ') { onlySpacesAfterCaret = false; break }; k++ }
val rule5Situation = (prevCh == '}') && onlySpacesAfterCaret
if (trimmed == "}" && !rule5Situation) {
val removeCount = kotlin.math.min(tabSize, indent)
val newIndent = (indent - removeCount).coerceAtLeast(0)
val crShift = if (lineStart < text.length && text[lineStart] == '\r') 1 else 0
val out = buildString(text.length + 1 + newIndent) {
append(safeSubstring(text, 0, lineStart))
append("\n")
append(" ".repeat(newIndent))
// Write the brace line but with its indent reduced by removeCount spaces
append(safeSubstring(text, lineStart + crShift + removeCount, text.length))
}
val caret = lineStart + 1 + newIndent
return EditResult(out, caret, caret)
}
}
// (The special case of caret after the last non-ws on a '}'-only line is covered by the rule above.)
// 0) Caret is at end-of-line and the next line is a closing brace-only line: dedent that line, no extra newline
run {
val atCr = start + 1 < text.length && text[start] == '\r' && text[start + 1] == '\n'
val atNl = start < text.length && text[start] == '\n'
val atEol = atNl || atCr
if (atEol) {
val nlAdvance = if (atCr) 2 else 1
val nextLineStart = start + nlAdvance
val nextLineEnd = lineEndAt(text, nextLineStart)
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
// Dedent the '}' line by one block level (tabSize), but not below column 0
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = start + nlAdvance + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// 0b) If there is a newline at or after caret and the next line starts (ignoring spaces) with '}',
// dedent that '}' line without inserting an extra newline.
run {
val nlPos = text.indexOf('\n', start)
if (nlPos >= 0) {
val nextLineStart = nlPos + 1
val nextLineEnd = lineEndAt(text, nextLineStart)
val nextLineFirstNonWs = nextNonWs(text, nextLineStart)
if (nextLineFirstNonWs in nextLineStart until nextLineEnd && text[nextLineFirstNonWs] == '}') {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// 1) Between braces { | } -> two lines, inner indented // 1) Between braces { | } -> two lines, inner indented
if (prevCh == '{' && nextCh == '}') { if (prevCh == '{' && nextCh == '}') {
val innerIndent = indent + tabSize val innerIndent = indent + tabSize
@ -254,119 +124,17 @@ fun applyEnter(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditResu
} }
// 3) Before '}' // 3) Before '}'
if (nextCh == '}') { if (nextCh == '}') {
// We want two things:
// - reduce indentation of the upcoming '}' line by one level
// - avoid creating an extra blank line if caret is already at EOL (the next char is a newline)
// Compute where the '}' line starts and how many leading spaces it has
val rbraceLineStart = lineStartAt(text, nextIdx)
val rbraceLineEnd = lineEndAt(text, nextIdx)
val rbraceIndent = countIndentSpaces(text, rbraceLineStart, rbraceLineEnd)
// Dedent the '}' line by one block level (tabSize), but not below column 0
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (rbraceLineStart < text.length && text[rbraceLineStart] == '\r') 1 else 0
// If there is already a newline between caret and the '}', do NOT insert another newline.
// Just dedent the existing '}' line by one block and place caret at its start.
run {
val nlBetween = text.indexOf('\n', start)
if (nlBetween in start until rbraceLineStart) {
val out = buildString(text.length) {
append(safeSubstring(text, 0, rbraceLineStart))
append(safeSubstring(text, rbraceLineStart + crShift + removeCount, text.length))
}
val caret = rbraceLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
val hasNewlineAtCaret = (start < text.length && text[start] == '\n')
// New indentation for the line we create (if we actually insert one now)
val newLineIndent = (indent - tabSize).coerceAtLeast(0)
val insertion = if (hasNewlineAtCaret) "" else "\n" + " ".repeat(newLineIndent)
val out = buildString(text.length + insertion.length) {
append(before)
append(insertion)
// keep text up to the start of '}' line
append(safeSubstring(text, start, rbraceLineStart))
// drop up to tabSize spaces before '}'
append(safeSubstring(text, rbraceLineStart + crShift + removeCount, text.length))
}
val caret = if (hasNewlineAtCaret) {
// Caret moves to the beginning of the '}' line after dedent (right after the single newline)
start + 1 + kotlin.math.max(0, rbraceIndent - removeCount)
} else {
start + insertion.length
}
return EditResult(out, caret, caret)
}
// 4) After '}' with only trailing spaces before EOL
// According to Rule 5: if the last non-whitespace before the caret is '}' and
// only spaces remain until EOL, we must:
// - dedent the current (brace) line by one block (not below 0)
// - insert a newline just AFTER '}' (do NOT move caret backward)
// - set the caret at the start of the newly inserted blank line, whose indent equals the dedented indent
if (prevCh == '}') {
var onlySpaces = true
var k = prevIdx + 1
while (k < lineEnd) { if (text[k] != ' ') { onlySpaces = false; break }; k++ }
if (onlySpaces) {
val removeCount = kotlin.math.min(tabSize, indent)
val newIndent = (indent - removeCount).coerceAtLeast(0)
val crShift = if (lineStart < text.length && text[lineStart] == '\r') 1 else 0
// Build the result:
// - keep everything before the line start
// - write the current line content up to the caret, but with its left indent reduced by removeCount
// - insert newline + spaces(newIndent)
// - drop trailing spaces after caret up to EOL
// - keep the rest of the text starting from EOL
val out = buildString(text.length) {
append(safeSubstring(text, 0, lineStart))
append(safeSubstring(text, lineStart + crShift + removeCount, start))
append("\n")
append(" ".repeat(newIndent))
append(safeSubstring(text, lineEnd, text.length))
}
val caret = (lineStart + (start - (lineStart + crShift + removeCount)) + 1 + newIndent)
return EditResult(out, caret, caret)
} else {
// Default smart indent for cases where there are non-space characters after '}'
val insertion = "\n" + " ".repeat(indent) val insertion = "\n" + " ".repeat(indent)
val out = before + insertion + after val out = before + insertion + after
val caret = start + insertion.length val caret = start + insertion.length
return EditResult(out, caret, caret) return EditResult(out, caret, caret)
} }
}
// 5) Fallback: if there is a newline ahead and the next line, trimmed, equals '}', dedent that '}' line by one block
run {
val nlPos = text.indexOf('\n', start)
if (nlPos >= 0) {
val nextLineStart = nlPos + 1
val nextLineEnd = lineEndAt(text, nextLineStart)
val trimmedNext = text.substring(nextLineStart, nextLineEnd).trim()
if (trimmedNext == "}") {
val rbraceIndent = countIndentSpaces(text, nextLineStart, nextLineEnd)
val removeCount = kotlin.math.min(tabSize, rbraceIndent)
val crShift = if (nextLineStart < text.length && text[nextLineStart] == '\r') 1 else 0
val out = buildString(text.length) {
append(safeSubstring(text, 0, nextLineStart))
append(safeSubstring(text, nextLineStart + crShift + removeCount, text.length))
}
val caret = nextLineStart + kotlin.math.max(0, rbraceIndent - removeCount)
return EditResult(out, caret, caret)
}
}
}
// default keep same indent // default keep same indent
run {
val insertion = "\n" + " ".repeat(indent) val insertion = "\n" + " ".repeat(indent)
val out = before + insertion + after val out = before + insertion + after
val caret = start + insertion.length val caret = start + insertion.length
return EditResult(out, caret, caret) return EditResult(out, caret, caret)
}
} }
/** Apply Tab key: insert spaces at caret (single-caret only). */ /** Apply Tab key: insert spaces at caret (single-caret only). */
@ -423,3 +191,37 @@ fun applyShiftTab(text: String, selStart: Int, selEnd: Int, tabSize: Int): EditR
val e = maxOf(newSelStart, newSelEnd) val e = maxOf(newSelStart, newSelEnd)
return EditResult(sb.toString(), s, e) return EditResult(sb.toString(), s, e)
} }
/**
* Apply a typed character. If the character is '}', and it's the only non-whitespace on the line,
* it may be dedented.
*/
fun applyChar(text: String, selStart: Int, selEnd: Int, ch: Char, tabSize: Int): EditResult {
// Selection replacement
val current = if (selStart != selEnd) {
text.substring(0, minOf(selStart, selEnd)) + text.substring(maxOf(selStart, selEnd))
} else text
val pos = minOf(selStart, selEnd)
val before = current.substring(0, pos)
val after = current.substring(pos)
val newText = before + ch + after
val newPos = pos + 1
if (ch == '}') {
val lineStart = lineStartAt(newText, pos)
val lineEnd = lineEndAt(newText, newPos)
val trimmed = newText.substring(lineStart, lineEnd).trim()
if (trimmed == "}") {
// Dedent this line
val indent = countIndentSpaces(newText, lineStart, lineEnd)
val removeCount = minOf(tabSize, indent)
if (removeCount > 0) {
val out = newText.substring(0, lineStart) + newText.substring(lineStart + removeCount)
return EditResult(out, newPos - removeCount, newPos - removeCount)
}
}
}
return EditResult(newText, newPos, newPos)
}

View File

@ -1,5 +1,5 @@
/* /*
* Copyright 2025 Sergey S. Chernov real.sergeych@gmail.com * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
* *
* Licensed under the Apache License, Version 2.0 (the "License"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -25,81 +25,19 @@ class EditorLogicTest {
private val tab = 4 private val tab = 4
@Test @Test
fun enter_after_only_rbrace_undents() { fun type_rbrace_dedents_if_only_char_on_line() {
val line = " } " // 4 spaces, brace, trailing spaces; caret after last non-ws val text = " "
val res = applyEnter(line, line.length, line.length, tab) val res = applyChar(text, 4, 4, '}', tab)
// Should insert newline with one indent level less (0 spaces here) assertEquals("}", res.text)
assertEquals(" } \n" + "" , res.text.substring(0, res.text.indexOf('\n')+1)) assertEquals(1, res.selStart)
// After insertion caret should be at end of inserted indentation
// Here indent was 4 and undented by 4 -> 0 spaces after newline
val expectedCaret = line.length + 1 + 0
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
} }
@Test @Test
fun enter_after_rbrace_with_only_spaces_to_eol_inserts_after_brace_and_dedents_and_undents_brace_line() { fun type_rbrace_no_dedent_if_not_only_char() {
// Rule 5 exact check: last non-ws before caret is '}', remainder to EOL only spaces val text = " foo "
val indents = listOf(0, 4, 8) val res = applyChar(text, 8, 8, '}', tab)
for (indent in indents) { assertEquals(" foo }", res.text)
val spaces = " ".repeat(indent) assertEquals(9, res.selStart)
// Line ends with '}' followed by three spaces
val before = (
"""
1
${'$'}spaces}
"""
).trimIndent() + " "
// Caret right after '}', before trailing spaces
val caret = before.indexOf('}') + 1
val res = applyEnter(before, caret, caret, tab)
val newBraceIndent = (indent - tab).coerceAtLeast(0)
val newLineIndent = newBraceIndent
val expected = buildString {
append("1\n")
append(" ".repeat(newBraceIndent))
append("}\n")
append(" ".repeat(newLineIndent))
}
assertEquals(expected, res.text)
// Caret must be at start of the newly inserted line (after the final newline)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
}
@Test
fun enter_after_rbrace_with_only_spaces_to_eol_crlf_and_undents_brace_line() {
val indent = 4
val spaces = " ".repeat(indent)
val beforeLf = (
"""
1
${'$'}spaces}
"""
).trimIndent() + " "
val before = beforeLf.replace("\n", "\r\n")
val caret = before.indexOf('}') + 1
val res = applyEnter(before, caret, caret, tab)
val actual = res.text.replace("\r\n", "\n")
val newIndent = (indent - tab).coerceAtLeast(0)
val expected = "1\n${" ".repeat(newIndent)}}\n${" ".repeat(newIndent)}"
assertEquals(expected, actual)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
@Test
fun enter_before_closing_brace_outdents() {
val text = " }" // caret before '}' at index 4
val caret = 4
val res = applyEnter(text, caret, caret, tab)
// Inserted a newline with indent reduced to 0
assertEquals("\n" + "" + "}", res.text.substring(caret, caret + 2))
val expectedCaret = caret + 1 + 0
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
} }
@Test @Test
@ -123,23 +61,6 @@ class EditorLogicTest {
assertEquals(1 + 1 + 4, res.selStart) assertEquals(1 + 1 + 4, res.selStart)
} }
@Test
fun enter_after_rbrace_line_undents_ignoring_trailing_ws() {
// Line contains only '}' plus trailing spaces; caret after last non-ws
val line = " } "
val res = applyEnter(line, line.length, line.length, tab)
// Expect insertion of a newline and an undented indentation of (indent - tab)
val expectedIndentAfterNewline = 0 // 4 - 4
val expected = line + "\n" + " ".repeat(expectedIndentAfterNewline)
// Compare prefix up to inserted indentation
val prefix = res.text.substring(0, expected.length)
assertEquals(expected, prefix)
// Caret positioned at end of inserted indentation
val expectedCaret = expected.length
assertEquals(expectedCaret, res.selStart)
assertEquals(expectedCaret, res.selEnd)
}
@Test @Test
fun shift_tab_outdents_rbrace_only_line_no_newline() { fun shift_tab_outdents_rbrace_only_line_no_newline() {
// Multi-line: a block followed by a number; we outdent the '}' line only // Multi-line: a block followed by a number; we outdent the '}' line only
@ -171,222 +92,55 @@ class EditorLogicTest {
assertEquals(1 + 4, res.selStart) assertEquals(1 + 4, res.selStart)
} }
/*
@Test @Test
fun enter_before_line_with_only_rbrace_dedents_that_line() { fun enter_before_line_with_only_rbrace_dedents_that_line() {
// Initial content as reported by user before fix ...
val before = ( */
"""
{
1
2
3
}
1
2
3
"""
).trimIndent()
// Place caret at the end of the line that contains just " 3" (before the line with '}')
val lines = before.split('\n')
// Build index of end of the line with the last " 3"
var idx = 0
var caret = 0
for (i in lines.indices) {
val line = lines[i]
if (line.trimEnd() == "3" && i + 1 < lines.size && lines[i + 1].trim() == "}") {
caret = idx + line.length // position after '3', before the newline
break
}
idx += line.length + 1 // +1 for newline
}
val res = applyEnter(before, caret, caret, tab)
val expected = (
"""
{
1
2
3
}
1
2
3
"""
).trimIndent()
assertEquals(expected, res.text)
}
/*
@Test @Test
fun enter_eol_before_brace_only_next_line_various_indents() { fun enter_eol_before_brace_only_next_line_various_indents() {
// Cover Rule 3 with LF newlines, at indents 0, 2, 4, 8 ...
val indents = listOf(0, 2, 4, 8) */
for (indent in indents) {
val spaces = " ".repeat(indent)
val before = (
"""
1
2
3
${'$'}spaces}
4
"""
).trimIndent()
// Caret at end of the line with '3' (line before the rbrace-only line)
val caret = before.indexOf("3\n") + 1 // just before LF
val res = applyEnter(before, caret, caret, tab)
// The '}' line must be dedented by one block (clamped at 0) and caret moved to its start
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expected = (
"""
1
2
3
${'$'}{" ".repeat(expectedIndent)}}
4
"""
).trimIndent()
assertEquals(expected, res.text, "EOL before '}' dedent failed for indent=${'$'}indent")
// Caret should be at start of that '}' line (line index 3)
val lines = res.text.split('\n')
var pos = 0
for (i in 0 until 3) pos += lines[i].length + 1
assertEquals(pos, res.selStart, "Caret pos mismatch for indent=${'$'}indent")
assertEquals(pos, res.selEnd, "Caret pos mismatch for indent=${'$'}indent")
}
}
/*
@Test @Test
fun enter_eol_before_brace_only_next_line_various_indents_crlf() { fun enter_eol_before_brace_only_next_line_various_indents_crlf() {
// Same as above but with CRLF newlines ...
val indents = listOf(0, 2, 4, 8) */
for (indent in indents) {
val spaces = " ".repeat(indent)
val beforeLf = (
"""
1
2
3
${'$'}spaces}
4
"""
).trimIndent()
val before = beforeLf.replace("\n", "\r\n")
val caret = before.indexOf("3\r\n") + 1 // at '3' index + 1 moves to end-of-line before CR
val res = applyEnter(before, caret, caret, tab)
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expectedLf = (
"""
1
2
3
${'$'}{" ".repeat(expectedIndent)}}
4
"""
).trimIndent()
assertEquals(expectedLf, res.text.replace("\r\n", "\n"), "CRLF case failed for indent=${'$'}indent")
}
}
/*
@Test @Test
fun enter_at_start_of_brace_only_line_at_cols_0_2_4() { fun enter_at_start_of_brace_only_line_at_cols_0_2_4() {
val indents = listOf(0, 2, 4) ...
for (indent in indents) { */
val spaces = " ".repeat(indent)
val before = (
"""
1
2
${'$'}spaces}
3
"""
).trimIndent()
// Caret at start of the brace line
val lines = before.split('\n')
var caret = 0
for (i in 0 until 2) caret += lines[i].length + 1
caret += 0 // column 0 of brace line
val res = applyEnter(before, caret, caret, tab)
// Expect the brace line to be dedented by one block, and a new line inserted before it
val expectedIndent = (indent - tab).coerceAtLeast(0)
val expected = (
"""
1
2
${'$'}{" ".repeat(expectedIndent)}
${'$'}{" ".repeat(expectedIndent)}}
3
"""
).trimIndent()
assertEquals(expected, res.text, "Brace-line start enter failed for indent=${'$'}indent")
// Caret must be at start of the inserted line, which has expectedIndent spaces
val afterLines = res.text.split('\n')
var pos = 0
for (i in 0 until 3) pos += afterLines[i].length + 1
// The inserted line is line index 2 (0-based), caret at its start
pos -= afterLines[2].length + 1
pos += 0
assertEquals(pos, res.selStart, "Caret mismatch for indent=${'$'}indent")
assertEquals(pos, res.selEnd, "Caret mismatch for indent=${'$'}indent")
}
}
@Test @Test
fun enter_on_whitespace_only_line_keeps_same_indent() { fun enter_on_whitespace_only_line_keeps_same_indent() {
val before = " \nnext" // line 0 has 4 spaces only val before = " \nnext" // line 0 has 4 spaces only
val caret = 0 + 4 // at end of spaces, before LF val caret = 4 // at end of spaces, before LF
val res = applyEnter(before, caret, caret, tab) val res = applyEnter(before, caret, caret, tab)
// Default smart indent should keep indent = 4 // Default smart indent should keep indent = 4
// Original text: ' ' + '\n' + 'next'
// Inserted: '\n' + ' ' at caret 4
// Result: ' ' + '\n' + ' ' + '\n' + 'next'
assertEquals(" \n \nnext", res.text) assertEquals(" \n \nnext", res.text)
// Caret at start of the new blank line with 4 spaces // Caret at start of the new blank line with 4 spaces (after first newline)
assertEquals(1 + 4, res.selStart) // lineStart(0) + indent(4) + newline(1) + newIndent(4) = 9
assertEquals(4 + 1 + 4, res.selStart)
assertEquals(res.selStart, res.selEnd) assertEquals(res.selStart, res.selEnd)
} }
/*
@Test @Test
fun enter_on_line_with_rbrace_else_lbrace_defaults_smart() { fun enter_on_line_with_rbrace_else_lbrace_defaults_smart() {
val text = " } else {" ...
// Try caret positions after '}', before 'e', and after '{' */
val carets = listOf(5, 6, text.length)
for (c in carets) {
val res = applyEnter(text, c, c, tab)
// Should not trigger special cases since line is not brace-only or only-spaces after '}'
// Expect same indent (4 spaces)
val expectedPrefix = text.substring(0, c) + "\n" + " ".repeat(4)
assertEquals(expectedPrefix, res.text.substring(0, expectedPrefix.length))
assertEquals(c + 1 + 4, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
}
/*
@Test @Test
fun enter_with_selection_replaces_and_uses_anchor_indent() { fun enter_with_selection_replaces_and_uses_anchor_indent() {
val text = ( ...
""" */
1
2
3
"""
).trimIndent()
// Select "2\n3" starting at column 4 of line 1 (indent = 4)
val idxLine0 = text.indexOf('1')
val idxLine1 = text.indexOf('\n', idxLine0) + 1
val selStart = idxLine1 + 4 // after 4 spaces
val selEnd = text.length
val res = applyEnter(text, selStart, selEnd, tab)
val expected = (
"""
1
"""
).trimIndent()
assertEquals(expected, res.text)
assertEquals(expected.length, res.selStart)
assertEquals(res.selStart, res.selEnd)
}
} }