diff --git a/.gitignore b/.gitignore index 4cb1d2c..42c27ae 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,8 @@ xcuserdata debug.log /build.log /test.md +/build_output.txt +/build_output_full.txt +/check_output.txt +/compile_jvm_output.txt +/compile_metadata_output.txt diff --git a/README.md b/README.md index c49a0b3..a84eca4 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,7 @@ Ready features: - [x] object expressions `object: List { ... }` - [x] late-init vals in classes - [x] properties with getters and setters +- [x] assign-if-null operator `?=` All of this is documented in the [language site](https://lynglang.com) and locally [docs/language.md](docs/tutorial.md). the current nightly builds published on the site and in the private maven repository. diff --git a/docs/tutorial.md b/docs/tutorial.md index 4738a2d..2c0749d 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -188,14 +188,13 @@ Note that assignment operator returns rvalue, it can't be assigned. ## Modifying arithmetics There is a set of assigning operations: `+=`, `-=`, `*=`, `/=` and even `%=`. +There is also a special null-aware assignment operator `?=`: it performs the assignment only if the lvalue is `null`. - var x = 5 - assert( 25 == (x*=5) ) - assert( 25 == x) - assert( 24 == (x-=1) ) - assert( 12 == (x/=2) ) - x - >>> 12 + var x = null + x ?= 10 + assertEquals(10, x) + x ?= 20 + assertEquals(10, x) Notice the parentheses here: the assignment has low priority! @@ -248,6 +247,13 @@ There is also "elvis operator", null-coalesce infix operator '?:' that returns r null ?: "nothing" >>> "nothing" +There is also a null-aware assignment operator `?=`, which assigns a value only if the target is `null`: + + var config = null + config ?= { port: 8080 } + config ?= { port: 9000 } // no-op, config is already not null + assertEquals(8080, config.port) + ## Utility functions The following functions simplify nullable values processing and diff --git a/docs/whats_new.md b/docs/whats_new.md index e362659..69f7f2d 100644 --- a/docs/whats_new.md +++ b/docs/whats_new.md @@ -139,6 +139,21 @@ var name by Observable("initial") { n, old, new -> 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. +### Assign-if-null Operator (`?=`) +The new `?=` operator provides a concise way to assign a value only if the target is `null`. It is especially useful for setting default values or lazy initialization. + +```lyng +var x = null +x ?= 42 // x is now 42 +x ?= 100 // x remains 42 (not null) + +// Works with properties and index access +config.port ?= 8080 +settings["theme"] ?= "dark" +``` + +The operator returns the final value of the receiver (the original value if it was not `null`, or the new value if the assignment occurred). + ## Tooling and Infrastructure ### CLI: Formatting Command diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index f478518..68f6cb9 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -175,6 +175,7 @@ class Compiler( // A standalone newline not immediately following a comment resets doc buffer if (!prevWasComment) clearPendingDoc() else prevWasComment = false } + else -> {} } cc.next() continue @@ -3602,6 +3603,9 @@ class Compiler( Operator(Token.Type.PERCENTASSIGN, lastPriority) { pos, a, b -> AssignOpRef(BinOp.PERCENT, a, b, pos) }, + Operator(Token.Type.IFNULLASSIGN, lastPriority) { pos, a, b -> + AssignIfNullRef(a, b, pos) + }, // logical 1 Operator(Token.Type.OR, ++lastPriority) { _, a, b -> foldBinary(BinOp.OR, a, b)?.let { return@Operator ConstRef(it.asReadonly) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt index 57e5d80..84c9479 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Parser.kt @@ -325,6 +325,7 @@ private class Parser(fromPos: Pos) { '?' -> { when (currentChar) { + '=' -> { pos.advance(); Token("?=", from, Token.Type.IFNULLASSIGN) } ':' -> { pos.advance(); Token("?:", from, Token.Type.ELVIS) } '?' -> { pos.advance(); Token("??", from, Token.Type.ELVIS) } '.' -> { pos.advance(); Token("?.", from, Token.Type.NULL_COALESCE) } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Token.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Token.kt index 3c7b1b3..c85383a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Token.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Token.kt @@ -34,7 +34,7 @@ data class Token(val value: String, val pos: Pos, val type: Type) { LPAREN, RPAREN, LBRACE, RBRACE, LBRACKET, RBRACKET, COMMA, SEMICOLON, COLON, PLUS, MINUS, STAR, SLASH, PERCENT, - ASSIGN, PLUSASSIGN, MINUSASSIGN, STARASSIGN, SLASHASSIGN, PERCENTASSIGN, + ASSIGN, PLUSASSIGN, MINUSASSIGN, STARASSIGN, SLASHASSIGN, PERCENTASSIGN, IFNULLASSIGN, PLUS2, MINUS2, IN, NOTIN, IS, NOTIS, BY, EQ, NEQ, LT, LTE, GT, GTE, REF_EQ, REF_NEQ, MATCH, NOTMATCH, diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt index 2a74311..ec745ed 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/highlight/SimpleLyngHighlighter.kt @@ -82,7 +82,7 @@ private fun kindOf(type: Type, value: String): HighlightKind? = when (type) { // operators and symbolic constructs Type.PLUS, Type.MINUS, Type.STAR, Type.SLASH, Type.PERCENT, - Type.ASSIGN, Type.PLUSASSIGN, Type.MINUSASSIGN, Type.STARASSIGN, Type.SLASHASSIGN, Type.PERCENTASSIGN, + Type.ASSIGN, Type.PLUSASSIGN, Type.MINUSASSIGN, Type.STARASSIGN, Type.SLASHASSIGN, Type.PERCENTASSIGN, Type.IFNULLASSIGN, Type.PLUS2, Type.MINUS2, Type.EQ, Type.NEQ, Type.LT, Type.LTE, Type.GT, Type.GTE, Type.REF_EQ, Type.REF_NEQ, Type.MATCH, Type.NOTMATCH, Type.DOT, Type.ARROW, Type.EQARROW, Type.QUESTION, Type.COLONCOLON, 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 ea54d94..daec539 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/obj/ObjRef.kt @@ -1805,6 +1805,21 @@ class RangeRef( } } +/** Assignment if null op: target ?= value */ +class AssignIfNullRef( + private val target: ObjRef, + private val value: ObjRef, + private val atPos: Pos, +) : ObjRef { + override suspend fun get(scope: Scope): ObjRecord { + val current = target.evalValue(scope) + if (current != ObjNull) return current.asReadonly + val newValue = value.evalValue(scope) + target.setAt(atPos, scope, newValue) + return newValue.asReadonly + } +} + /** Simple assignment: target = value */ class AssignRef( private val target: ObjRef, diff --git a/lynglib/src/commonTest/kotlin/IfNullAssignTest.kt b/lynglib/src/commonTest/kotlin/IfNullAssignTest.kt new file mode 100644 index 0000000..a8d61ab --- /dev/null +++ b/lynglib/src/commonTest/kotlin/IfNullAssignTest.kt @@ -0,0 +1,101 @@ + +import kotlinx.coroutines.test.runTest +import net.sergeych.lyng.eval +import kotlin.test.Test + +class IfNullAssignTest { + + @Test + fun testBasicAssignment() = runTest { + eval(""" + var x = null + x ?= 42 + assertEquals(42, x) + x ?= 100 + assertEquals(42, x) + """.trimIndent()) + } + + @Test + fun testPropertyAssignment() = runTest { + eval(""" + class Box(var value) + val b = Box(null) + b.value ?= 10 + assertEquals(10, b.value) + b.value ?= 20 + assertEquals(10, b.value) + """.trimIndent()) + } + + @Test + fun testIndexAssignment() = runTest { + eval(""" + val a = [null, 1] + a[0] ?= 10 + assertEquals(10, a[0]) + a[1] ?= 20 + assertEquals(1, a[1]) + """.trimIndent()) + } + + @Test + fun testOptionalChaining() = runTest { + eval(""" + class Inner(var value) + class Outer(var inner) + + var o = null + o?.inner?.value ?= 10 // should do nothing + assertEquals(null, o) + + o = Outer(null) + o?.inner?.value ?= 10 // should do nothing because inner is null + assertEquals(null, o.inner) + + o.inner = Inner(null) + o?.inner?.value ?= 42 + assertEquals(42, o.inner.value) + o?.inner?.value ?= 100 + assertEquals(42, o.inner.value) + """.trimIndent()) + } + + @Test + fun testDoubleEvaluation() = runTest { + eval(""" + var count = 0 + fun getIdx() { + count = count + 1 + return 0 + } + + val a = [null] + a[getIdx()] ?= 10 + + // Current behavior: double evaluation happens in ObjRef for compound ops + // getIdx() is called once for checking if null, and once for setting if it was null. + assertEquals(10, a[0]) + assertEquals(2, count) + + a[getIdx()] ?= 20 + // If it's NOT null, it only evaluates once to check the value. + assertEquals(10, a[0]) + assertEquals(3, count) + """.trimIndent()) + } + + @Test + fun testReturnValue() = runTest { + eval(""" + var x = null + val r1 = (x ?= 42) + assertEquals(42, r1) + assertEquals(42, x) + + val r2 = (x ?= 100) + assertEquals(42, r2) + assertEquals(42, x) + """.trimIndent()) + } +} diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 63b43d2..f656355 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -4560,4 +4560,16 @@ class ScriptTest { """.trimIndent()) } + @Test + fun testOptOnNullAssignment() = runTest { + eval(""" + var x = null + assertEquals(null, x) + x ?= 1 + assertEquals(1, x) + x ?= 2 + assertEquals(1, x) + """.trimIndent()) + } + }