null assignment operator added

This commit is contained in:
Sergey Chernov 2026-01-06 11:39:23 +01:00
parent 8cd980514b
commit 555c9b94de
11 changed files with 169 additions and 9 deletions

5
.gitignore vendored
View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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) }

View File

@ -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,

View File

@ -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,

View File

@ -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,

View File

@ -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())
}
}

View File

@ -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())
}
}