diff --git a/CHANGELOG.md b/CHANGELOG.md index 374f5c1..5e6d6ee 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,22 @@ - Integration: Updated TextMate grammar and IntelliJ plugin (highlighting + keywords). - Documentation: New "Properties" section in `docs/OOP.md`. -- Docs: Scopes and Closures guidance +- Language: Restricted Setter Visibility + - Support for `private set` and `protected set` modifiers on `var` fields and properties. + - Allows members to be publicly readable but only writable from within the declaring class or its subclasses. + - Enforcement at runtime: throws `AccessException` on unauthorized writes. + - Supported only for declarations in class bodies (fields and properties). + - Documentation: New "Restricted Setter Visibility" section in `docs/OOP.md`. + +- Language: Late-initialized `val` fields in classes + - Support for declaring `val` without an immediate initializer in class bodies. + - Compulsory initialization: every late-init `val` must be assigned at least once within the class body or an `init` block. + - Write-once enforcement: assigning to a `val` is allowed only if its current value is `Unset`. + - Access protection: reading a late-init `val` before it is assigned returns the `Unset` singleton; using `Unset` for most operations throws an `UnsetException`. + - Extension properties do not support late-init. + - Documentation: New "Late-initialized `val` fields" and "The `Unset` singleton" sections in `docs/OOP.md`. + +- Docs: OOP improvements - 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`). - Tutorial: added quick link to Scopes and Closures. diff --git a/docs/OOP.md b/docs/OOP.md index 97c2372..f89549d 100644 --- a/docs/OOP.md +++ b/docs/OOP.md @@ -432,6 +432,69 @@ Are declared with var assert( p.isSpecial == true ) >>> void +### Restricted Setter Visibility + +You can restrict the visibility of a `var` field's or property's setter by using `private set` or `protected set` modifiers. This allows the member to be publicly readable but only writable from within the class or its subclasses. + +#### On Fields + +```kotlin +class SecretCounter { + var count = 0 + private set // Can be read anywhere, but written only in SecretCounter + + fun increment() { count++ } +} + +val c = SecretCounter() +println(c.count) // OK +c.count = 10 // Throws AccessException +c.increment() // OK +``` + +#### On Properties + +You can also apply restricted visibility to custom property setters: + +```kotlin +class Person(private var _age: Int) { + var age + get() = _age + private set(v) { if (v >= 0) _age = v } +} +``` + +#### Protected Setters and Inheritance + +A `protected set` allows subclasses to modify a field that is otherwise read-only to the public: + +```kotlin +class Base { + var state = "initial" + protected set +} + +class Derived : Base() { + fun changeState(newVal) { + state = newVal // OK: protected access from subclass + } +} + +val d = Derived() +println(d.state) // OK: "initial" +d.changeState("updated") +println(d.state) // OK: "updated" +d.state = "bad" // Throws AccessException: public write not allowed +``` + +### Key Rules and Limitations + +- **Only for `var`**: Restricted setter visibility cannot be used with `val` declarations, as they are inherently read-only. Attempting to use it with `val` results in a syntax error. +- **Class Body Only**: These modifiers can only be used on members declared within the class body. They are not supported for primary constructor parameters. +- **`private set`**: The setter is only accessible within the same class context (specifically, when `this` is an instance of that class). +- **`protected set`**: The setter is accessible within the declaring class and all its transitive subclasses. +- **Multiple Inheritance**: In MI scenarios, visibility is checked against the class that actually declared the member. Qualified access (e.g., `this@Base.field = value`) also respects restricted setter visibility. + ### Private fields Private fields are visible only _inside the class instance_: diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index 5c0acb0..cb265dc 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -513,24 +513,32 @@ class Compiler( // there could be terminal operators or keywords:// variable to read or like when (t.value) { in stopKeywords -> { - if (operand != null) throw ScriptError(t.pos, "unexpected keyword") - // Allow certain statement-like constructs to act as expressions - // when they appear in expression position (e.g., `if (...) ... else ...`). - // Other keywords should be handled by the outer statement parser. - when (t.value) { - "if" -> { - val s = parseIfStatement() - operand = StatementRef(s) - } - "when" -> { - val s = parseWhenStatement() - operand = StatementRef(s) - } - else -> { - // Do not consume the keyword as part of a term; backtrack - // and return null so outer parser handles it. - cc.previous() - return null + if (t.value == "init" && !(codeContexts.lastOrNull() is CodeContext.ClassBody && cc.peekNextNonWhitespace().type == Token.Type.LBRACE)) { + // Soft keyword: init is only a keyword in class body when followed by { + cc.previous() + operand = parseAccessor() + } else { + if (operand != null) throw ScriptError(t.pos, "unexpected keyword") + // Allow certain statement-like constructs to act as expressions + // when they appear in expression position (e.g., `if (...) ... else ...`). + // Other keywords should be handled by the outer statement parser. + when (t.value) { + "if" -> { + val s = parseIfStatement() + operand = StatementRef(s) + } + + "when" -> { + val s = parseWhenStatement() + operand = StatementRef(s) + } + + else -> { + // Do not consume the keyword as part of a term; backtrack + // and return null so outer parser handles it. + cc.previous() + return null + } } } } @@ -1388,7 +1396,7 @@ class Compiler( } "init" -> { - if (codeContexts.lastOrNull() is CodeContext.ClassBody) { + if (codeContexts.lastOrNull() is CodeContext.ClassBody && cc.peekNextNonWhitespace().type == Token.Type.LBRACE) { val block = parseBlock() lastParsedBlockRange?.let { range -> miniSink?.onInitDecl(MiniInitDecl(MiniRange(id.pos, range.end), id.pos)) @@ -2717,7 +2725,7 @@ class Compiler( cc.restorePos(markBeforeEq) cc.skipWsTokens() val next = cc.peekNextNonWhitespace() - if (next.isId("get") || next.isId("set")) { + if (next.isId("get") || next.isId("set") || next.isId("private") || next.isId("protected")) { isProperty = true cc.restorePos(markBeforeEq) cc.skipWsTokens() @@ -2780,8 +2788,8 @@ class Compiler( // if (isDelegate) throw ScriptError(start, "static delegates are not yet implemented") currentInitScope += statement { val initValue = initialExpression?.execute(this)?.byValueCopy() ?: ObjNull - (thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, pos) - addItem(name, isMutable, initValue, visibility, ObjRecord.Type.Field) + (thisObj as ObjClass).createClassField(name, initValue, isMutable, visibility, null, pos) + addItem(name, isMutable, initValue, visibility, null, ObjRecord.Type.Field) ObjVoid } return NopStatement @@ -2790,11 +2798,11 @@ class Compiler( // Check for accessors if it is a class member var getter: Statement? = null var setter: Statement? = null + var setterVisibility: Visibility? = null if (declaringClassNameCaptured != null || extTypeName != null) { while (true) { - val t = cc.peekNextNonWhitespace() + val t = cc.skipWsTokens() if (t.isId("get")) { - cc.skipWsTokens() cc.next() // consume 'get' cc.requireToken(Token.Type.LPAREN) cc.requireToken(Token.Type.RPAREN) @@ -2810,7 +2818,6 @@ class Compiler( 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") @@ -2836,6 +2843,46 @@ class Compiler( } else { throw ScriptError(cc.current().pos, "Expected { or = after set(...)") } + } else if (t.isId("private") || t.isId("protected")) { + val vis = if (t.isId("private")) Visibility.Private else Visibility.Protected + val mark = cc.savePos() + cc.next() // consume modifier + if (cc.skipWsTokens().isId("set")) { + cc.next() // consume 'set' + setterVisibility = vis + if (cc.skipWsTokens().type == Token.Type.LPAREN) { + cc.next() // consume '(' + 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 { + cc.restorePos(mark) + break + } } else break } if (getter != null || setter != null) { @@ -2844,11 +2891,13 @@ class Compiler( 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 (setter != null || setterVisibility != null) + throw ScriptError(start, "val property cannot have a setter or restricted visibility set (name: $name)") if (getter == null) throw ScriptError(start, "val property with accessors must have a getter (name: $name)") } + } else if (setterVisibility != null && !isMutable) { + throw ScriptError(start, "val field cannot have restricted visibility set (name: $name)") } } @@ -2865,7 +2914,7 @@ class Compiler( val type = context[extTypeName]?.value ?: context.raiseSymbolNotFound("class $extTypeName not found") if (type !is ObjClass) context.raiseClassCastError("$extTypeName is not the class instance") - context.addExtension(type, name, ObjRecord(prop, isMutable = false, visibility = visibility, declaringClass = null, type = ObjRecord.Type.Property)) + context.addExtension(type, name, ObjRecord(prop, isMutable = false, visibility = visibility, writeVisibility = setterVisibility, declaringClass = null, type = ObjRecord.Type.Property)) return@statement prop } @@ -2899,6 +2948,7 @@ class Compiler( isMutable, prop, visibility, + setterVisibility, recordType = ObjRecord.Type.Property ) ObjVoid @@ -2906,7 +2956,7 @@ class Compiler( ObjVoid } else { // We are in instance scope already: perform initialization immediately - context.addItem(storageName, isMutable, prop, visibility, recordType = ObjRecord.Type.Property) + context.addItem(storageName, isMutable, prop, visibility, setterVisibility, recordType = ObjRecord.Type.Property) prop } } else { @@ -2922,7 +2972,7 @@ class Compiler( val initValue = initialExpression?.execute(scp)?.byValueCopy() ?: if (isLateInitVal) ObjUnset else ObjNull // Preserve mutability of declaration: do NOT use addOrUpdateItem here, as it creates mutable records - scp.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) + scp.addItem(storageName, isMutable, initValue, visibility, setterVisibility, recordType = ObjRecord.Type.Field) ObjVoid } cls.instanceInitializers += initStmt @@ -2932,7 +2982,7 @@ class Compiler( val initValue = initialExpression?.execute(context)?.byValueCopy() ?: if (isLateInitVal) ObjUnset else ObjNull // Preserve mutability of declaration: create record with correct mutability - context.addItem(storageName, isMutable, initValue, visibility, recordType = ObjRecord.Type.Field) + context.addItem(storageName, isMutable, initValue, visibility, setterVisibility, recordType = ObjRecord.Type.Field) initValue } } else { @@ -3243,7 +3293,10 @@ class Compiler( * The keywords that stop processing of expression term */ val stopKeywords = - setOf("do", "break", "continue", "return", "if", "when", "do", "while", "for", "class") + setOf( + "break", "continue", "return", "if", "when", "do", "while", "for", "class", + "private", "protected", "val", "var", "fun", "fn", "static", "init", "enum" + ) } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt index 1d5a1eb..7c09c5c 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Scope.kt @@ -434,6 +434,7 @@ open class Scope( name: String, value: Obj, visibility: Visibility = Visibility.Public, + writeVisibility: Visibility? = null, recordType: ObjRecord.Type = ObjRecord.Type.Other ): ObjRecord = objects[name]?.let { @@ -448,17 +449,18 @@ open class Scope( callScope.localBindings[name] = it } it - } ?: addItem(name, true, value, visibility, recordType) + } ?: addItem(name, true, value, visibility, writeVisibility, recordType) fun addItem( name: String, isMutable: Boolean, value: Obj, visibility: Visibility = Visibility.Public, + writeVisibility: Visibility? = null, recordType: ObjRecord.Type = ObjRecord.Type.Other, declaringClass: net.sergeych.lyng.obj.ObjClass? = currentClassCtx ): ObjRecord { - val rec = ObjRecord(value, isMutable, visibility, declaringClass = declaringClass, type = recordType) + val rec = ObjRecord(value, isMutable, visibility, writeVisibility, declaringClass = declaringClass, type = recordType) objects[name] = rec // Index this binding within the current frame to help resolve locals across suspension localBindings[name] = 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 d17246d..9e0c315 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/Obj.kt @@ -416,7 +416,7 @@ open class Obj { val decl = field.declaringClass val caller = scope.currentClassCtx - if (!canAccessMember(field.visibility, decl, caller)) + if (!canAccessMember(field.effectiveWriteVisibility, decl, caller)) scope.raiseError(ObjAccessException(scope, "can't assign field ${name}: not visible (declared in ${decl?.className ?: "?"}, caller ${caller?.className ?: "?"})")) if (field.value is ObjProperty) { (field.value as ObjProperty).callSetter(scope, this, newValue, decl) diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt index 3a708f7..004edbf 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjClass.kt @@ -345,6 +345,7 @@ open class ObjClass( initialValue: Obj, isMutable: Boolean = false, visibility: Visibility = Visibility.Public, + writeVisibility: Visibility? = null, pos: Pos = Pos.builtIn, declaringClass: ObjClass? = this ) { @@ -353,7 +354,7 @@ open class ObjClass( if (existingInSelf != null && existingInSelf.isMutable == false) throw ScriptError(pos, "$name is already defined in $objClass") // Install/override in this class - members[name] = ObjRecord(initialValue, isMutable, visibility, declaringClass = declaringClass) + members[name] = ObjRecord(initialValue, isMutable, visibility, writeVisibility, declaringClass = declaringClass) // Structural change: bump layout version for PIC invalidation layoutVersion += 1 } @@ -368,13 +369,14 @@ open class ObjClass( initialValue: Obj, isMutable: Boolean = false, visibility: Visibility = Visibility.Public, + writeVisibility: Visibility? = null, pos: Pos = Pos.builtIn ) { initClassScope() val existing = classScope!!.objects[name] if (existing != null) throw ScriptError(pos, "$name is already defined in $objClass or one of its supertypes") - classScope!!.addItem(name, isMutable, initialValue, visibility) + classScope!!.addItem(name, isMutable, initialValue, visibility, writeVisibility) // Structural change: bump layout version for PIC invalidation layoutVersion += 1 } @@ -383,11 +385,12 @@ open class ObjClass( name: String, isOpen: Boolean = false, visibility: Visibility = Visibility.Public, + writeVisibility: Visibility? = null, declaringClass: ObjClass? = this, code: suspend Scope.() -> Obj ) { val stmt = statement { code() } - createField(name, stmt, isOpen, visibility, Pos.builtIn, declaringClass) + createField(name, stmt, isOpen, visibility, writeVisibility, Pos.builtIn, declaringClass) } fun addConst(name: String, value: Obj) = createField(name, value, isMutable = false) @@ -397,12 +400,13 @@ open class ObjClass( getter: (suspend Scope.() -> Obj)? = null, setter: (suspend Scope.(Obj) -> Unit)? = null, visibility: Visibility = Visibility.Public, + writeVisibility: Visibility? = null, declaringClass: ObjClass? = this ) { val g = getter?.let { statement { it() } } val s = setter?.let { statement { it(requiredArg(0)); ObjVoid } } val prop = ObjProperty(name, g, s) - members[name] = ObjRecord(prop, false, visibility, declaringClass, type = ObjRecord.Type.Property) + members[name] = ObjRecord(prop, false, visibility, writeVisibility, declaringClass, type = ObjRecord.Type.Property) layoutVersion += 1 } 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 9995160..398d64e 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjInstance.kt @@ -97,8 +97,8 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { val decl = f.declaringClass if (scope.thisObj !== this || scope.currentClassCtx == null) { val caller = scope.currentClassCtx - if (!canAccessMember(f.visibility, decl, caller)) - ObjIllegalAssignmentException( + if (!canAccessMember(f.effectiveWriteVisibility, decl, caller)) + ObjAccessException( scope, "can't assign to field $name (declared in ${decl?.className ?: "?"})" ).raise() @@ -131,8 +131,8 @@ class ObjInstance(override val objClass: ObjClass) : Obj() { } if (scope.thisObj !== this || scope.currentClassCtx == null) { val caller = scope.currentClassCtx - if (!canAccessMember(rec.visibility, declaring, caller)) - ObjIllegalAssignmentException( + if (!canAccessMember(rec.effectiveWriteVisibility, declaring, caller)) + ObjAccessException( scope, "can't assign to field $name (declared in ${declaring?.className ?: "?"})" ).raise() @@ -350,8 +350,8 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla instance.instanceScope.objects[mangled]?.let { f -> val decl = f.declaringClass ?: startClass val caller = scope.currentClassCtx - if (!canAccessMember(f.visibility, decl, caller)) - ObjIllegalAssignmentException( + if (!canAccessMember(f.effectiveWriteVisibility, decl, caller)) + ObjAccessException( scope, "can't assign to field $name (declared in ${decl.className})" ).raise() @@ -364,8 +364,8 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla instance.instanceScope[name]?.let { f -> val decl = f.declaringClass ?: instance.objClass.findDeclaringClassOf(name) val caller = scope.currentClassCtx - if (!canAccessMember(f.visibility, decl, caller)) - ObjIllegalAssignmentException( + if (!canAccessMember(f.effectiveWriteVisibility, decl, caller)) + ObjAccessException( scope, "can't assign to field $name (declared in ${decl?.className ?: "?"})" ).raise() @@ -377,8 +377,8 @@ class ObjQualifiedView(val instance: ObjInstance, private val startClass: ObjCla val r = memberFromAncestor(name) ?: scope.raiseError("no such field: $name") val decl = r.declaringClass ?: startClass val caller = scope.currentClassCtx - if (!canAccessMember(r.visibility, decl, caller)) - ObjIllegalAssignmentException(scope, "can't assign to field $name (declared in ${decl.className})").raise() + if (!canAccessMember(r.effectiveWriteVisibility, decl, caller)) + ObjAccessException(scope, "can't assign to field $name (declared in ${decl.className})").raise() if (!r.isMutable) scope.raiseError("can't assign to read-only field: $name") if (r.value.assign(scope, newValue) == null) r.value = newValue } 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 5330830..dd50403 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRecord.kt @@ -26,12 +26,14 @@ data class ObjRecord( var value: Obj, val isMutable: Boolean, val visibility: Visibility = Visibility.Public, + val writeVisibility: Visibility? = null, /** If non-null, denotes the class that declared this member (field/method). */ val declaringClass: ObjClass? = null, var importedFrom: Scope? = null, val isTransient: Boolean = false, val type: Type = Type.Other, ) { + val effectiveWriteVisibility: Visibility get() = writeVisibility ?: visibility enum class Type(val comparable: Boolean = false,val serializable: Boolean = false) { Field(true, true), @Suppress("unused") diff --git a/lynglib/src/commonTest/kotlin/OOTest.kt b/lynglib/src/commonTest/kotlin/OOTest.kt index 9c212cb..a7a7ee7 100644 --- a/lynglib/src/commonTest/kotlin/OOTest.kt +++ b/lynglib/src/commonTest/kotlin/OOTest.kt @@ -465,4 +465,54 @@ class OOTest { AccessBefore() """.trimIndent()) } + + @Test + fun testPrivateSet() = runTest { + eval(""" + class A { + var y = 100 + private set + fun setValue(newValue) { y = newValue } + } + assertEquals(100, A().y) + assertThrows(AccessException) { A().y = 200 } + val a = A() + a.setValue(200) + assertEquals(200, a.y) + + class B(initial) { + var y = initial + protected set + } + class C(initial) : B(initial) { + fun setBValue(v) { y = v } + } + val c = C(10) + assertEquals(10, c.y) + assertThrows(AccessException) { c.y = 20 } + c.setBValue(30) + assertEquals(30, c.y) + + class D { + private var _y = 0 + var y + get() = _y + private set(v) { _y = v } + fun setY(v) { y = v } + } + val d = D() + assertEquals(0, d.y) + assertThrows(AccessException) { d.y = 10 } + d.setY(20) + assertEquals(20, d.y) + """) + } + + @Test + fun testValPrivateSetError() = runTest { + assertFails { + eval("class E { val x = 1 private set }") + } + } + } \ No newline at end of file