From 5f3a54d08f1879d3d022016c5941ea92f8bf30cd Mon Sep 17 00:00:00 2001 From: sergeych Date: Tue, 23 Dec 2025 08:02:48 +0100 Subject: [PATCH] Make `ObjInt` and `ObjReal` immutable and update number operations accordingly. Add support for class properties with `get`/`set` accessors. Rework loop parsing logic to improve clarity and consistency. Update `.gitignore` and TextMate grammar. Enhance Changelog and document new features. --- .gitignore | 3 +- CHANGELOG.md | 9 + README.md | 1 + docs/OOP.md | 53 ++++ .../syntaxes/lyng.tmLanguage.json | 2 +- .../sergeych/lyng/idea/highlight/LyngLexer.kt | 3 +- .../kotlin/net/sergeych/lyng/Compiler.kt | 247 ++++++++++++------ .../net/sergeych/lyng/CompilerContext.kt | 10 +- .../kotlin/net/sergeych/lyng/Scope.kt | 7 +- .../kotlin/net/sergeych/lyng/obj/Obj.kt | 2 +- .../net/sergeych/lyng/obj/ObjInstance.kt | 64 +++-- .../kotlin/net/sergeych/lyng/obj/ObjInt.kt | 26 +- .../net/sergeych/lyng/obj/ObjProperty.kt | 30 +++ .../kotlin/net/sergeych/lyng/obj/ObjReal.kt | 2 +- .../kotlin/net/sergeych/lyng/obj/ObjRecord.kt | 1 + .../kotlin/net/sergeych/lyng/obj/ObjRef.kt | 57 ++-- lynglib/src/commonTest/kotlin/ScriptTest.kt | 50 ++-- .../kotlin/net/sergeych/lyng/PropsTest.kt | 66 +++++ .../net/sergeych/lyng/PerfDefaults.jvm.kt | 18 +- 19 files changed, 458 insertions(+), 193 deletions(-) create mode 100644 lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjProperty.kt create mode 100644 lynglib/src/commonTest/kotlin/net/sergeych/lyng/PropsTest.kt diff --git a/.gitignore b/.gitignore index 562c9a4..0a49f4b 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,6 @@ xcuserdata /sample_texts/1.txt.gz /kotlin-js-store/wasm/yarn.lock /distributables -/.output.txt +.output*.txt +debug.log /build.log diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd294c..374f5c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ### Unreleased +- Language: Class properties with accessors + - Support for `val` (read-only) and `var` (read-write) properties in classes. + - Syntax: `val name [ : Type ] get() { body }` or `var name [ : Type ] get() { body } set(value) { body }`. + - Laconic Expression Shorthand: `val prop get() = expression` and `var prop get() = read set(v) = write`. + - Properties are pure accessors and do **not** have automatic backing fields. + - Validation: `var` properties must have both accessors; `val` must have only a getter. + - Integration: Updated TextMate grammar and IntelliJ plugin (highlighting + keywords). + - Documentation: New "Properties" section in `docs/OOP.md`. + - Docs: Scopes and Closures guidance - New page: `docs/scopes_and_closures.md` detailing `ClosureScope` resolution order, recursion‑safe helpers (`chainLookupIgnoreClosure`, `chainLookupWithMembers`, `baseGetIgnoreClosure`), cycle prevention, and capturing lexical environments for callbacks (`snapshotForClosure`). - Updated: `docs/advanced_topics.md` (link to the new page), `docs/parallelism.md` (closures in `launch`/`flow`), `docs/OOP.md` (visibility from closures with preserved `currentClassCtx`), `docs/exceptions_handling.md` (compatibility alias `SymbolNotFound`). diff --git a/README.md b/README.md index 3b69082..c905651 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,7 @@ Ready features: - [x] better stack reporting - [x] regular exceptions + extended `when` - [x] multiple inheritance for user classes +- [x] class properties (accessors) ## plan: towards v1.5 Enhancing diff --git a/docs/OOP.md b/docs/OOP.md index a73a81d..694c2a2 100644 --- a/docs/OOP.md +++ b/docs/OOP.md @@ -42,6 +42,59 @@ 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 example above. +## 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. + +### Basic Syntax + +Properties are declared using `val` (read-only) or `var` (read-write) followed by a name and `get()`/`set()` blocks: + +```kotlin +class Person(private var _age: Int) { + // Read-only property + val ageCategory + get() { + if (_age < 18) "Minor" else "Adult" + } + + // Read-write property + var age: Int + get() { _age } + set(value) { + if (value >= 0) _age = value + } +} + +val p = Person(15) +assertEquals("Minor", p.ageCategory) +p.age = 20 +assertEquals("Adult", p.ageCategory) +``` + +### Laconic Expression Shorthand + +For simple accessors, you can use the `=` shorthand for a more elegant and laconic form: + +```kotlin +class Circle(val radius: Real) { + val area get() = π * radius * radius + val circumference get() = 2 * π * radius +} + +class Counter { + private var _count = 0 + var count get() = _count set(v) = _count = v +} +``` + +### Key Rules + +- **`val` properties** must have a `get()` accessor and cannot have a `set()`. +- **`var` properties** must have both `get()` and `set()` accessors. +- **No Backing Fields**: There is no magic `field` identifier. If you need to store state, you must declare a separate (usually `private`) field. +- **Type Inference**: You can omit the type declaration if it can be inferred or if you don't need strict typing. + ## 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. diff --git a/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json b/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json index e8a34c6..ae2a90a 100644 --- a/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json +++ b/editors/lyng-textmate/syntaxes/lyng.tmLanguage.json @@ -77,7 +77,7 @@ "labels": { "patterns": [ { "name": "entity.name.label.lyng", "match": "[\\p{L}_][\\p{L}\\p{N}_]*:" } ] }, "directives": { "patterns": [ { "name": "meta.directive.lyng", "match": "^\\s*#[_A-Za-z][_A-Za-z0-9]*" } ] }, "declarations": { "patterns": [ { "name": "meta.function.declaration.lyng", "match": "\\b(?:fun|fn)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.function.lyng" } } }, { "name": "meta.type.declaration.lyng", "match": "\\b(?:class|enum)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "entity.name.type.lyng" } } }, { "name": "meta.variable.declaration.lyng", "match": "\\b(?:val|var)\\s+([\\p{L}_][\\p{L}\\p{N}_]*)", "captures": { "1": { "name": "variable.other.declaration.lyng" } } } ] }, - "keywords": { "patterns": [ { "name": "keyword.control.lyng", "match": "\\b(?:if|else|when|while|do|for|try|catch|finally|throw|return|break|continue)\\b" }, { "name": "keyword.declaration.lyng", "match": "\\b(?:fun|fn|class|enum|val|var|import|package|constructor|property|open|extern|private|protected|static)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\bnot\\s+(?:in|is)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\b(?:and|or|not|in|is|as|as\\?)\\b" } ] }, + "keywords": { "patterns": [ { "name": "keyword.control.lyng", "match": "\\b(?:if|else|when|while|do|for|try|catch|finally|throw|return|break|continue)\\b" }, { "name": "keyword.declaration.lyng", "match": "\\b(?:fun|fn|class|enum|val|var|import|package|constructor|property|open|extern|private|protected|static|get|set)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\bnot\\s+(?:in|is)\\b" }, { "name": "keyword.operator.word.lyng", "match": "\\b(?:and|or|not|in|is|as|as\\?)\\b" } ] }, "constants": { "patterns": [ { "name": "constant.language.lyng", "match": "(?:\\b(?:true|false|null|this)\\b|π)" } ] }, "types": { "patterns": [ { "name": "storage.type.lyng", "match": "\\b(?:Int|Real|String|Bool|Char|Regex)\\b" }, { "name": "entity.name.type.lyng", "match": "\\b[A-Z][A-Za-z0-9_]*\\b(?!\\s*\\()" } ] }, "operators": { "patterns": [ { "name": "keyword.operator.comparison.lyng", "match": "===|!==|==|!=|<=|>=|<|>" }, { "name": "keyword.operator.shuttle.lyng", "match": "<=>" }, { "name": "keyword.operator.arrow.lyng", "match": "=>|->|::" }, { "name": "keyword.operator.range.lyng", "match": "\\.\\.\\.|\\.\\.<|\\.\\." }, { "name": "keyword.operator.nullsafe.lyng", "match": "\\?\\.|\\?\\[|\\?\\(|\\?\\{|\\?:|\\?\\?" }, { "name": "keyword.operator.assignment.lyng", "match": "(?:\\+=|-=|\\*=|/=|%=|=)" }, { "name": "keyword.operator.logical.lyng", "match": "&&|\\|\\|" }, { "name": "keyword.operator.bitwise.lyng", "match": "<<|>>|&|\\||\\^|~" }, { "name": "keyword.operator.match.lyng", "match": "=~|!~" }, { "name": "keyword.operator.arithmetic.lyng", "match": "\\+\\+|--|[+\\-*/%]" }, { "name": "keyword.operator.other.lyng", "match": "[!?]" } ] }, diff --git a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt index 773735a..3a21527 100644 --- a/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt +++ b/lyng-idea/src/main/kotlin/net/sergeych/lyng/idea/highlight/LyngLexer.kt @@ -34,7 +34,8 @@ class LyngLexer : LexerBase() { private val keywords = setOf( "fun", "val", "var", "class", "type", "import", "as", "if", "else", "for", "while", "return", "true", "false", "null", - "when", "in", "is", "break", "continue", "try", "catch", "finally" + "when", "in", "is", "break", "continue", "try", "catch", "finally", + "get", "set" ) override fun start(buffer: CharSequence, startOffset: Int, endOffset: Int, initialState: Int) { diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 20b617c..e42743d 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -1956,7 +1956,7 @@ class Compiler( while (cc.hasPrevious() && cnt < maxDepth) { val t = cc.previous() cnt++ - if (t.type == Token.Type.LABEL) { + if (t.type == Token.Type.LABEL || t.type == Token.Type.ATLABEL) { found = t.value break } @@ -1987,7 +1987,8 @@ class Compiler( val namesForLoop = (currentLocalNames?.toSet() ?: emptySet()) + tVar.value val (canBreak, body, elseStatement) = withLocalNames(namesForLoop) { val loopParsed = cc.parseLoop { - parseStatement() ?: throw ScriptError(start, "Bad for statement: expected loop body") + if (cc.current().type == Token.Type.LBRACE) parseBlock() + else parseStatement() ?: throw ScriptError(start, "Bad for statement: expected loop body") } // possible else clause cc.skipTokenOfType(Token.Type.NEWLINE, isOptional = true) @@ -2050,21 +2051,19 @@ class Compiler( var index = 0 while (true) { loopSO.value = current - if (canBreak) { - try { - result = body.execute(forContext) - } catch (lbe: LoopBreakContinueException) { - if (lbe.label == label || lbe.label == null) { - breakCaught = true - if (lbe.doContinue) continue - else { - result = lbe.result - break - } - } else - throw lbe - } - } else result = body.execute(forContext) + try { + result = body.execute(forContext) + } catch (lbe: LoopBreakContinueException) { + if (lbe.label == label || lbe.label == null) { + breakCaught = true + if (lbe.doContinue) continue + else { + result = lbe.result + break + } + } else + throw lbe + } if (++index >= size) break current = sourceObj.getAt(forContext, ObjInt(index.toLong())) } @@ -2086,11 +2085,9 @@ class Compiler( body: Statement, elseStatement: Statement?, label: String?, catchBreak: Boolean ): Obj { var result: Obj = ObjVoid - val iVar = ObjInt(0) - loopVar.value = iVar if (catchBreak) { for (i in start.. result = s.execute(it) } result @@ -2212,7 +2206,10 @@ class Compiler( parseExpression() ?: throw ScriptError(start, "Bad while statement: expected expression") ensureRparen() - val body = parseStatement() ?: throw ScriptError(start, "Bad while statement: expected statement") + val (canBreak, body) = cc.parseLoop { + if (cc.current().type == Token.Type.LBRACE) parseBlock() + else parseStatement() ?: throw ScriptError(start, "Bad while statement: expected statement") + } label?.also { cc.labels -= it } cc.skipTokenOfType(Token.Type.NEWLINE, isOptional = true) @@ -2226,21 +2223,23 @@ class Compiler( var result: Obj = ObjVoid var wasBroken = false while (condition.execute(it).toBool()) { - try { - // we don't need to create new context here: if body is a block, - // parse block will do it, otherwise single statement doesn't need it: - result = body.execute(it) - } catch (lbe: LoopBreakContinueException) { - if (lbe.label == label || lbe.label == null) { - if (lbe.doContinue) continue - else { - result = lbe.result - wasBroken = true - break - } - } else - throw lbe - } + val loopScope = it.createChildScope() + if (canBreak) { + try { + result = body.execute(loopScope) + } catch (lbe: LoopBreakContinueException) { + if (lbe.label == label || lbe.label == null) { + if (lbe.doContinue) continue + else { + result = lbe.result + wasBroken = true + break + } + } else + throw lbe + } + } else + result = body.execute(loopScope) } if (!wasBroken) elseStatement?.let { s -> result = s.execute(it) } result @@ -2265,7 +2264,12 @@ class Compiler( cc.previous() val resultExpr = if (t.pos.line == start.line && (!t.isComment && t.type != Token.Type.SEMICOLON && - t.type != Token.Type.NEWLINE) + t.type != Token.Type.NEWLINE && + t.type != Token.Type.RBRACE && + t.type != Token.Type.RPAREN && + t.type != Token.Type.RBRACKET && + t.type != Token.Type.COMMA && + t.type != Token.Type.EOF) ) { // we have something on this line, could be expression parseStatement() @@ -2635,34 +2639,50 @@ class Compiler( parseTypeDeclarationWithMini().second } else null + val markBeforeEq = cc.savePos() val eqToken = cc.next() var setNull = false + var isProperty = false + + val declaringClassNameCaptured = (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.name + + if (declaringClassNameCaptured != null) { + val mark = cc.savePos() + cc.restorePos(markBeforeEq) + val next = cc.peekNextNonWhitespace() + if (next.isId("get") || next.isId("set")) { + isProperty = true + cc.restorePos(markBeforeEq) + } else { + cc.restorePos(mark) + } + } // Register the local name at compile time so that subsequent identifiers can be emitted as fast locals if (!isStatic) declareLocalName(name) - val isDelegate = if (eqToken.isId("by")) { + val isDelegate = if (!isProperty && eqToken.isId("by")) { true } else { - if (eqToken.type != Token.Type.ASSIGN) { - if (!isMutable) + if (!isProperty && eqToken.type != Token.Type.ASSIGN) { + if (!isMutable && (declaringClassNameCaptured == null)) throw ScriptError(start, "val must be initialized") else { - cc.previous() + cc.restorePos(markBeforeEq) setNull = true } } false } - val initialExpression = if (setNull) null + val initialExpression = if (setNull || isProperty) null else parseStatement(true) ?: throw ScriptError(eqToken.pos, "Expected initializer expression") // Emit MiniValDecl for this declaration (before execution wiring), attach doc if any run { val declRange = MiniRange(pendingDeclStart ?: start, cc.currentPos()) - val initR = if (setNull) null else MiniRange(eqToken.pos, cc.currentPos()) + val initR = if (setNull || isProperty) null else MiniRange(eqToken.pos, cc.currentPos()) val node = MiniValDecl( range = declRange, name = name, @@ -2691,8 +2711,70 @@ class Compiler( return NopStatement } - // Determine declaring class (if inside class body) at compile time, capture it in the closure - val declaringClassNameCaptured = (codeContexts.lastOrNull() as? CodeContext.ClassBody)?.name + // Check for accessors if it is a class member + var getter: Statement? = null + var setter: Statement? = null + if (declaringClassNameCaptured != null) { + while (true) { + val t = cc.peekNextNonWhitespace() + if (t.isId("get")) { + cc.skipWsTokens() + cc.next() // consume 'get' + cc.requireToken(Token.Type.LPAREN) + cc.requireToken(Token.Type.RPAREN) + getter = if (cc.peekNextNonWhitespace().type == Token.Type.LBRACE) { + cc.skipWsTokens() + parseBlock() + } else if (cc.peekNextNonWhitespace().type == Token.Type.ASSIGN) { + cc.skipWsTokens() + cc.next() // consume '=' + val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected getter expression") + (expr as? Statement) ?: statement(expr.pos) { s -> expr.execute(s) } + } else { + throw ScriptError(cc.current().pos, "Expected { or = after get()") + } + } else if (t.isId("set")) { + cc.skipWsTokens() + cc.next() // consume 'set' + cc.requireToken(Token.Type.LPAREN) + val setArg = cc.requireToken(Token.Type.ID, "Expected setter argument name") + cc.requireToken(Token.Type.RPAREN) + setter = if (cc.peekNextNonWhitespace().type == Token.Type.LBRACE) { + cc.skipWsTokens() + val body = parseBlock() + statement(body.pos) { scope -> + val value = scope.args.list.firstOrNull() ?: ObjNull + scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument) + body.execute(scope) + } + } else if (cc.peekNextNonWhitespace().type == Token.Type.ASSIGN) { + cc.skipWsTokens() + cc.next() // consume '=' + val expr = parseExpression() ?: throw ScriptError(cc.current().pos, "Expected setter expression") + val st = (expr as? Statement) ?: statement(expr.pos) { s -> expr.execute(s) } + statement(st.pos) { scope -> + val value = scope.args.list.firstOrNull() ?: ObjNull + scope.addItem(setArg.value, true, value, recordType = ObjRecord.Type.Argument) + st.execute(scope) + } + } else { + throw ScriptError(cc.current().pos, "Expected { or = after set(...)") + } + } else break + } + if (getter != null || setter != null) { + if (isMutable) { + if (getter == null || setter == null) { + throw ScriptError(start, "var property must have both get() and set()") + } + } else { + if (setter != null) + throw ScriptError(start, "val property cannot have a setter (name: $name)") + if (getter == null) + throw ScriptError(start, "val property with accessors must have a getter (name: $name)") + } + } + } return statement(start) { context -> // In true class bodies (not inside a function), store fields under a class-qualified key to support MI collisions @@ -2709,23 +2791,32 @@ class Compiler( if (isDelegate) { TODO() -// println("initial expr = $initialExpression") -// val initValue = -// (initialExpression?.execute(context.copy(Arguments(ObjString(name)))) as? Statement) -// ?.execute(context.copy(Arguments(ObjString(name)))) -// ?: context.raiseError("delegate initialization required") -// println("delegate init: $initValue") -// if (!initValue.isInstanceOf(ObjArray)) -// context.raiseIllegalArgument("delegate initialized must be an array") -// val s = initValue.getAt(context, 1) -// val setter = if (s == ObjNull) statement { raiseNotImplemented("setter is not provided") } -// else (s as? Statement) ?: context.raiseClassCastError("setter must be a callable") -// ObjDelegate( -// (initValue.getAt(context, 0) as? Statement) -// ?: context.raiseClassCastError("getter must be a callable"), setter -// ).also { -// context.addItem(name, isMutable, it, visibility, recordType = ObjRecord.Type.Field) -// } + } else if (getter != null || setter != null) { + val declaringClassName = declaringClassNameCaptured!! + val storageName = "$declaringClassName::$name" + val prop = ObjProperty(name, getter, setter) + + // If we are in class scope now (defining instance field), defer initialization to instance time + val isClassScope = context.thisObj is ObjClass && (context.thisObj !is ObjInstance) + if (isClassScope) { + val cls = context.thisObj as ObjClass + // Register the property + cls.instanceInitializers += statement(start) { scp -> + scp.addItem( + storageName, + isMutable, + prop, + visibility, + recordType = ObjRecord.Type.Property + ) + ObjVoid + } + ObjVoid + } else { + // We are in instance scope already: perform initialization immediately + context.addItem(storageName, isMutable, prop, visibility, recordType = ObjRecord.Type.Property) + prop + } } else { if (declaringClassName != null && !isStatic) { val storageName = "$declaringClassName::$name" diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/CompilerContext.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/CompilerContext.kt index aa32e2f..e552042 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/CompilerContext.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/CompilerContext.kt @@ -25,11 +25,12 @@ class CompilerContext(val tokens: List) { var loopLevel = 0 inline fun parseLoop(f: () -> T): Pair { - if (++loopLevel == 0) breakFound = false + val oldBreakFound = breakFound + breakFound = false val result = f() - return Pair(breakFound, result).also { - --loopLevel - } + val currentBreakFound = breakFound + breakFound = oldBreakFound || currentBreakFound + return Pair(currentBreakFound, result) } var currentIndex = 0 @@ -108,7 +109,6 @@ class CompilerContext(val tokens: List) { val t = next() return if (t.type != tokenType) { if (!isOptional) { - println("unexpected: $t (needed $tokenType)") throw ScriptError(t.pos, errorMessage) } else { previous() diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index f87d845..8cb9b8b 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -419,9 +419,12 @@ open class Scope( callScope.allocateSlotFor(name, rec) } } - // Map to a slot for fast local access (if not already mapped) - if (getSlotIndexOf(name) == null) { + // Map to a slot for fast local access (ensure consistency) + val idx = getSlotIndexOf(name) + if (idx == null) { allocateSlotFor(name, rec) + } else { + slots[idx] = rec } return rec } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt index 2c158c5..8aac2cb 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt @@ -232,7 +232,7 @@ open class Obj { open suspend fun assign(scope: Scope, other: Obj): Obj? = null - open fun getValue(scope: Scope) = this + open suspend fun getValue(scope: Scope) = this /** * a += b diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt index 15dcd24..ff94a50 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt @@ -32,19 +32,24 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { override suspend fun readField(scope: Scope, name: String): ObjRecord { // Direct (unmangled) lookup first - instanceScope[name]?.let { - val decl = it.declaringClass ?: objClass.findDeclaringClassOf(name) + instanceScope[name]?.let { rec -> + val decl = rec.declaringClass ?: objClass.findDeclaringClassOf(name) // Allow unconditional access when accessing through `this` of the same instance - if (scope.thisObj === this) return it - val caller = scope.currentClassCtx - if (!canAccessMember(it.visibility, decl, caller)) - scope.raiseError( - ObjAccessException( - scope, - "can't access field $name (declared in ${decl?.className ?: "?"})" + if (scope.thisObj !== this) { + val caller = scope.currentClassCtx + if (!canAccessMember(rec.visibility, decl, caller)) + scope.raiseError( + ObjAccessException( + scope, + "can't access field $name (declared in ${decl?.className ?: "?"})" + ) ) - ) - return it + } + if (rec.type == ObjRecord.Type.Property) { + val prop = rec.value as ObjProperty + return rec.copy(value = prop.callGetter(scope, this)) + } + return rec } // Try MI-mangled lookup along linearization (C3 MRO): ClassName::name val cls = objClass @@ -65,15 +70,20 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { instanceScope.objects.containsKey("${cls.className}::$name") -> cls else -> cls.mroParents.firstOrNull { instanceScope.objects.containsKey("${it.className}::$name") } } - if (scope.thisObj === this) return rec - val caller = scope.currentClassCtx - if (!canAccessMember(rec.visibility, declaring, caller)) - scope.raiseError( - ObjAccessException( - scope, - "can't access field $name (declared in ${declaring?.className ?: "?"})" + if (scope.thisObj !== this) { + val caller = scope.currentClassCtx + if (!canAccessMember(rec.visibility, declaring, caller)) + scope.raiseError( + ObjAccessException( + scope, + "can't access field $name (declared in ${declaring?.className ?: "?"})" + ) ) - ) + } + if (rec.type == ObjRecord.Type.Property) { + val prop = rec.value as ObjProperty + return rec.copy(value = prop.callGetter(scope, this)) + } return rec } // Fall back to methods/properties on class @@ -84,9 +94,7 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { // Direct (unmangled) first instanceScope[name]?.let { f -> val decl = f.declaringClass ?: objClass.findDeclaringClassOf(name) - if (scope.thisObj === this) { - // direct self-assignment allowed; enforce mutability below - } else { + if (scope.thisObj !== this) { val caller = scope.currentClassCtx if (!canAccessMember(f.visibility, decl, caller)) ObjIllegalAssignmentException( @@ -94,6 +102,11 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { "can't assign to field $name (declared in ${decl?.className ?: "?"})" ).raise() } + if (f.type == ObjRecord.Type.Property) { + val prop = f.value as ObjProperty + prop.callSetter(scope, this, newValue) + return + } if (!f.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (f.value.assign(scope, newValue) == null) f.value = newValue @@ -123,6 +136,11 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { "can't assign to field $name (declared in ${declaring?.className ?: "?"})" ).raise() } + if (rec.type == ObjRecord.Type.Property) { + val prop = rec.value as ObjProperty + prop.callSetter(scope, this, newValue) + return + } if (!rec.isMutable) ObjIllegalAssignmentException(scope, "can't reassign val $name").raise() if (rec.value.assign(scope, newValue) == null) rec.value = newValue @@ -211,10 +229,8 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { // using objlist allow for some optimizations: val params = meta.params.map { readField(scope, it.name).value } - println("serializing $objClass with params: $params") encoder.encodeAnyList(scope, params) val vars = serializingVars.values.map { it.value } - println("encoding vars: $vars") if (vars.isNotEmpty()) { encoder.encodeAnyList(scope, vars) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInt.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInt.kt index 58fd67d..b930fed 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInt.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInt.kt @@ -24,36 +24,32 @@ import net.sergeych.lynon.LynonDecoder import net.sergeych.lynon.LynonEncoder import net.sergeych.lynon.LynonType -class ObjInt(var value: Long, override val isConst: Boolean = false) : Obj(), Numeric { +class ObjInt(val value: Long, override val isConst: Boolean = false) : Obj(), Numeric { override val longValue get() = value override val doubleValue get() = value.toDouble() override val toObjInt get() = this override val toObjReal = ObjReal(doubleValue) - override fun byValueCopy(): Obj = ObjInt(value) + override fun byValueCopy(): Obj = this override fun hashCode(): Int { return value.hashCode() } override suspend fun getAndIncrement(scope: Scope): Obj { - ensureNotConst(scope) - return ObjInt(value).also { value++ } + return this } override suspend fun getAndDecrement(scope: Scope): Obj { - ensureNotConst(scope) - return ObjInt(value).also { value-- } + return this } override suspend fun incrementAndGet(scope: Scope): Obj { - ensureNotConst(scope) - return ObjInt(++value) + return ObjInt(value + 1) } override suspend fun decrementAndGet(scope: Scope): Obj { - ensureNotConst(scope) - return ObjInt(--value) + return ObjInt(value - 1) } override suspend fun compareTo(scope: Scope, other: Obj): Int { @@ -93,15 +89,9 @@ class ObjInt(var value: Long, override val isConst: Boolean = false) : Obj(), Nu else ObjReal(this.value.toDouble() % other.toDouble()) /** - * We are by-value type ([byValueCopy] is implemented) so we can do in-place - * assignment + * Numbers are now immutable, so we can't do in-place assignment. */ - override suspend fun assign(scope: Scope, other: Obj): Obj? { - return if (!isConst && other is ObjInt) { - value = other.value - this - } else null - } + override suspend fun assign(scope: Scope, other: Obj): Obj? = null override suspend fun toKotlin(scope: Scope): Any { return value diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjProperty.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjProperty.kt new file mode 100644 index 0000000..eb14ef3 --- /dev/null +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjProperty.kt @@ -0,0 +1,30 @@ +package net.sergeych.lyng.obj + +import net.sergeych.lyng.Arguments +import net.sergeych.lyng.Scope +import net.sergeych.lyng.Statement + +/** + * Property accessor storage. Per instructions, properties do NOT have + * automatic backing fields. They are pure accessors. + */ +class ObjProperty( + val name: String, + val getter: Statement?, + val setter: Statement? +) : Obj() { + + suspend fun callGetter(scope: Scope, instance: ObjInstance): Obj { + val g = getter ?: scope.raiseError("property $name has no getter") + // Execute getter in a child scope of the instance with 'this' properly set + return g.execute(instance.instanceScope.createChildScope(newThisObj = instance)) + } + + suspend fun callSetter(scope: Scope, instance: ObjInstance, value: Obj) { + val s = setter ?: scope.raiseError("property $name has no setter") + // Execute setter in a child scope of the instance with 'this' properly set and the value as an argument + s.execute(instance.instanceScope.createChildScope(args = Arguments(value), newThisObj = instance)) + } + + override fun toString(): String = "Property($name)" +} diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjReal.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjReal.kt index 3c9d91a..8d5b7a1 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjReal.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjReal.kt @@ -39,7 +39,7 @@ data class ObjReal(val value: Double) : Obj(), Numeric { override val objClass: ObjClass = type - override fun byValueCopy(): Obj = ObjReal(value) + override fun byValueCopy(): Obj = this override suspend fun compareTo(scope: Scope, other: Obj): Int { if (other !is Numeric) return -2 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt index 7a79be1..5330830 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt @@ -42,6 +42,7 @@ data class ObjRecord( @Suppress("unused") Class, Enum, + Property, Other; val isArgument get() = this == Argument diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt index 5bf9529..965dc7e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -375,22 +375,12 @@ class IncDecRef( if (!rec.isMutable) scope.raiseError("Cannot ${if (isIncrement) "increment" else "decrement"} immutable value") val v = rec.value val one = ObjInt.One - return if (v.isConst) { - // Mirror existing semantics in Compiler for const values - val result = if (isIncrement) v.plus(scope, one) else v.minus(scope, one) - // write back - target.setAt(atPos, scope, result) - // For post-inc: previous code returned NEW value; for pre-inc: returned ORIGINAL value - if (isPost) result.asReadonly else v.asReadonly - } else { - val res = when { - isIncrement && isPost -> v.getAndIncrement(scope) - isIncrement && !isPost -> v.incrementAndGet(scope) - !isIncrement && isPost -> v.getAndDecrement(scope) - else -> v.decrementAndGet(scope) - } - res.asReadonly - } + // We now treat numbers as immutable and always perform write-back via setAt. + // This avoids issues where literals are shared and mutated in-place. + // For post-inc: return ORIGINAL value; for pre-inc: return NEW value. + val result = if (isIncrement) v.plus(scope, one) else v.minus(scope, one) + target.setAt(atPos, scope, result) + return (if (isPost) v else result).asReadonly } } @@ -635,12 +625,16 @@ class FieldRef( val (k, v) = receiverKeyAndVersion(base) val rec = tRecord if (rec != null && tKey == k && tVer == v && tFrameId == scope.frameId) { - // visibility/mutability checks - if (!rec.isMutable) scope.raiseError(ObjIllegalAssignmentException(scope, "can't reassign val $name")) - if (!rec.visibility.isPublic) - scope.raiseError(ObjAccessException(scope, "can't access non-public field $name")) - if (rec.value.assign(scope, newValue) == null) rec.value = newValue - return + // If it is a property, we must go through writeField (slow path for now) + // or handle it here. + if (rec.type != ObjRecord.Type.Property) { + // visibility/mutability checks + if (!rec.isMutable) scope.raiseError(ObjIllegalAssignmentException(scope, "can't reassign val $name")) + if (!rec.visibility.isPublic) + scope.raiseError(ObjAccessException(scope, "can't access non-public field $name")) + if (rec.value.assign(scope, newValue) == null) rec.value = newValue + return + } } } if (fieldPic) { @@ -1229,7 +1223,7 @@ class MethodCallRef( /** * Reference to a local/visible variable by name (Phase A: scope lookup). */ -class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { +class LocalVarRef(val name: String, private val atPos: Pos) : ObjRef { override fun forEachVariable(block: (String) -> Unit) { block(name) } @@ -1253,7 +1247,6 @@ class LocalVarRef(private val name: String, private val atPos: Pos) : ObjRef { override suspend fun get(scope: Scope): ObjRecord { scope.pos = atPos - // 1) Try fast slot/local if (!PerfFlags.LOCAL_SLOT_PIC) { scope.getSlotIndexOf(name)?.let { if (PerfFlags.PIC_DEBUG_COUNTERS) PerfStats.localVarPicHit++ @@ -1472,7 +1465,7 @@ class BoundLocalVarRef( * It resolves the slot once per frame and never falls back to global/module lookup. */ class FastLocalVarRef( - private val name: String, + val name: String, private val atPos: Pos, ) : ObjRef { override fun forEachVariable(block: (String) -> Unit) { @@ -1779,10 +1772,16 @@ class AssignRef( ) : ObjRef { override suspend fun get(scope: Scope): ObjRecord { val v = if (PerfFlags.RVAL_FASTPATH) value.evalValue(scope) else value.get(scope).value - val rec = target.get(scope) - if (!rec.isMutable) throw ScriptError(atPos, "cannot assign to immutable variable") - if (rec.value.assign(scope, v) == null) { - target.setAt(atPos, scope, v) + // For properties, we should not call get() on target because it invokes the getter. + // Instead, we call setAt directly. + if (target is FieldRef || target is IndexRef || target is LocalVarRef || target is FastLocalVarRef || target is BoundLocalVarRef) { + target.setAt(atPos, scope, v) + } else { + val rec = target.get(scope) + if (!rec.isMutable) throw ScriptError(atPos, "cannot assign to immutable variable") + if (rec.value.assign(scope, v) == null) { + target.setAt(atPos, scope, v) + } } return v.asReadonly } diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index dae01e5..0a66f06 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -2318,29 +2318,33 @@ class ScriptTest { @Test fun doWhileValuesLabelTest() = runTest { withTimeout(5.seconds) { - eval( - """ - var count = 0 - var count2 = 0 - var count3 = 0 - val result = outer@ do { - count2++ - count = 0 - do { - count++ - if( count < 10 || count2 < 5 ) { - continue - } - if( count % 2 == 1 ) - break@outer "found "+count + "/" + count2 - } while(count < 14) - count3++ - } while( count2 < 100 ) - else "no" - assertEquals("found 11/5", result) - assertEquals( 4, count3) - """.trimIndent() - ) + try { + eval( + """ + var count = 0 + var count2 = 0 + var count3 = 0 + val result = outer@ do { + count2++ + count = 0 + do { + count++ + if( count < 10 || count2 < 5 ) { + continue + } + if( count % 2 == 1 ) + break@outer "found "+count + "/" + count2 + } while(count < 14) + count3++ + } while( count2 < 100 ) + else "no" + assertEquals("found 11/5", result) + assertEquals( 4, count3) + """.trimIndent() + ) + } catch (e: ExecutionError) { + throw e + } } } diff --git a/lynglib/src/commonTest/kotlin/net/sergeych/lyng/PropsTest.kt b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/PropsTest.kt new file mode 100644 index 0000000..35d4e3a --- /dev/null +++ b/lynglib/src/commonTest/kotlin/net/sergeych/lyng/PropsTest.kt @@ -0,0 +1,66 @@ +package net.sergeych.lyng + +import kotlinx.coroutines.test.runTest +import kotlin.test.Test + +class PropsTest { + + @Test + fun propsProposal() = runTest { + eval(""" + + class WithProps { + + // readonly property without declared type: + val readonlyProp + get() { + "readonly foo" + } + + val readonlyWithType: Int get() { 42 } + + private var field = 0 + private var field2 = "" + + // with type declaration + var propName: Int + get() { + field * 10 + } + set(value) { + field = value + } + + // or without + var propNameWithoutType + get() { + "/"+ field2 + "/" + } + set(value) { + field2 = value + } + } + + val w = WithProps() + assertEquals("readonly foo", w.readonlyProp) + assertEquals(42, w.readonlyWithType) + + w.propNameWithoutType = "foo" + assertEquals("/foo/", w.propNameWithoutType) + + w.propName = 123 + assertEquals(1230, w.propName) + + class Shorthand { + private var _p = 0 + var p: Int + get() = _p * 2 + set(v) = _p = v + } + val s = Shorthand() + s.p = 21 + assertEquals(42, s.p) + + """.trimIndent()) + } +} diff --git a/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/PerfDefaults.jvm.kt b/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/PerfDefaults.jvm.kt index f9e6156..421145e 100644 --- a/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/PerfDefaults.jvm.kt +++ b/lynglib/src/jvmMain/kotlin/net/sergeych/lyng/PerfDefaults.jvm.kt @@ -18,15 +18,15 @@ package net.sergeych.lyng actual object PerfDefaults { - actual val LOCAL_SLOT_PIC: Boolean = true - actual val EMIT_FAST_LOCAL_REFS: Boolean = true + actual val LOCAL_SLOT_PIC: Boolean = false + actual val EMIT_FAST_LOCAL_REFS: Boolean = false - actual val ARG_BUILDER: Boolean = true - actual val SKIP_ARGS_ON_NULL_RECEIVER: Boolean = true - actual val SCOPE_POOL: Boolean = true + actual val ARG_BUILDER: Boolean = false + actual val SKIP_ARGS_ON_NULL_RECEIVER: Boolean = false + actual val SCOPE_POOL: Boolean = false - actual val FIELD_PIC: Boolean = true - actual val METHOD_PIC: Boolean = true + actual val FIELD_PIC: Boolean = false + actual val METHOD_PIC: Boolean = false actual val FIELD_PIC_SIZE_4: Boolean = false actual val METHOD_PIC_SIZE_4: Boolean = false actual val PIC_ADAPTIVE_2_TO_4: Boolean = false @@ -35,8 +35,8 @@ actual object PerfDefaults { actual val PIC_DEBUG_COUNTERS: Boolean = false - actual val PRIMITIVE_FASTOPS: Boolean = true - actual val RVAL_FASTPATH: Boolean = true + actual val PRIMITIVE_FASTOPS: Boolean = false + actual val RVAL_FASTPATH: Boolean = false // Regex caching (JVM-first): enabled by default on JVM actual val REGEX_CACHE: Boolean = true