fix #19 set of null-coalesce operators

This commit is contained in:
Sergey Chernov 2025-06-13 22:25:18 +04:00
parent bd2b6bf06e
commit b961296425
9 changed files with 165 additions and 29 deletions

View File

@ -131,7 +131,7 @@ _this functionality is not yet released_
| class | notes | | class | notes |
|----------------------------|-------------------------------------------------------| |----------------------------|-------------------------------------------------------|
| Exception | root of al throwable objects | | Exception | root of al throwable objects |
| NullPointerException | | | NullReferenceException | |
| AssertionFailedException | | | AssertionFailedException | |
| ClassCastException | | | ClassCastException | |
| IndexOutOfBoundsException | | | IndexOutOfBoundsException | |

View File

@ -118,6 +118,51 @@ These operators return rvalue, unmodifiable.
## Assignment return r-value! ## Assignment return r-value!
Naturally, assignment returns its value:
var x
x = 11
>>> 11
rvalue means you cant assign the result if the assignment
var x
assertThrows { (x = 11) = 5 }
void
>>> void
This also prevents chain assignments so use parentheses:
var x
var y
x = (y = 1)
>>> 1
## Nullability
When the value is `null`, it might throws `NullReferenceException`, the name is somewhat a tradition. To avoid it
one can check it against null or use _null coalescing_. The null coalescing means, if the operand (left) is null,
the operation won't be performed and the result will be null. Here is the difference:
val ref = null
assertThrows { ref.field }
assertThrows { ref.method() }
assertThrows { ref.array[1] }
assertThrows { ref[1] }
assertThrows { ref() }
assert( ref?.field == null )
assert( ref?.method() == null )
assert( ref?.array?[1] == null )
assert( ref?[1] == null )
assert( ref?() == null )
>>> void
There is also "elvis operator", null-coalesce infix operator '?:' that returns rvalue if lvalue is `null`:
null ?: "nothing"
>>> "nothing"
## Math ## Math
It is rather simple, like everywhere else: It is rather simple, like everywhere else:

View File

@ -4,7 +4,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych" group = "net.sergeych"
version = "0.6.1-SNAPSHOT" version = "0.6.-SNAPSHOT"
buildscript { buildscript {
repositories { repositories {

View File

@ -121,7 +121,8 @@ class Compiler(
operand = Accessor { op.getter(it).value.logicalNot(it).asReadonly } operand = Accessor { op.getter(it).value.logicalNot(it).asReadonly }
} }
Token.Type.DOT -> { Token.Type.DOT, Token.Type.NULL_COALESCE -> {
var isOptional = t.type == Token.Type.NULL_COALESCE
operand?.let { left -> operand?.let { left ->
// dotcall: calling method on the operand, if next is ID, "(" // dotcall: calling method on the operand, if next is ID, "("
var isCall = false var isCall = false
@ -138,17 +139,22 @@ class Compiler(
operand = Accessor { context -> operand = Accessor { context ->
context.pos = next.pos context.pos = next.pos
val v = left.getter(context).value val v = left.getter(context).value
ObjRecord( if (v == ObjNull && isOptional)
v.invokeInstanceMethod( ObjNull.asReadonly
context, else
next.value, ObjRecord(
args.toArguments(context, false) v.invokeInstanceMethod(
), isMutable = false context,
) next.value,
args.toArguments(context, false)
), isMutable = false
)
} }
} }
Token.Type.LBRACE -> {
Token.Type.LBRACE, Token.Type.NULL_COALESCE_BLOCKINVOKE -> {
isOptional = nt.type == Token.Type.NULL_COALESCE_BLOCKINVOKE
// single lambda arg, like assertTrows { ... } // single lambda arg, like assertTrows { ... }
cc.next() cc.next()
isCall = true isCall = true
@ -159,13 +165,16 @@ class Compiler(
operand = Accessor { context -> operand = Accessor { context ->
context.pos = next.pos context.pos = next.pos
val v = left.getter(context).value val v = left.getter(context).value
ObjRecord( if (v == ObjNull && isOptional)
v.invokeInstanceMethod( ObjNull.asReadonly
context, else
next.value, ObjRecord(
Arguments(listOf(lambda), true) v.invokeInstanceMethod(
), isMutable = false context,
) next.value,
Arguments(listOf(lambda), true)
), isMutable = false
)
} }
} }
@ -174,25 +183,30 @@ class Compiler(
} }
if (!isCall) { if (!isCall) {
operand = Accessor({ context -> operand = Accessor({ context ->
left.getter(context).value.readField(context, next.value) val x = left.getter(context).value
if (x == ObjNull && isOptional) ObjNull.asReadonly
else x.readField(context, next.value)
}) { cc, newValue -> }) { cc, newValue ->
left.getter(cc).value.writeField(cc, next.value, newValue) left.getter(cc).value.writeField(cc, next.value, newValue)
} }
} }
} ?: throw ScriptError(t.pos, "Expecting expression before dot") }
?: throw ScriptError(t.pos, "Expecting expression before dot")
} }
Token.Type.COLONCOLON -> { Token.Type.COLONCOLON -> {
operand = parseScopeOperator(operand, cc) operand = parseScopeOperator(operand, cc)
} }
Token.Type.LPAREN -> { Token.Type.LPAREN, Token.Type.NULL_COALESCE_INVOKE -> {
operand?.let { left -> operand?.let { left ->
// this is function call from <left> // this is function call from <left>
operand = parseFunctionCall( operand = parseFunctionCall(
cc, cc,
left, left,
false, false,
t.type == Token.Type.NULL_COALESCE_INVOKE
) )
} ?: run { } ?: run {
// Expression in parentheses // Expression in parentheses
@ -205,15 +219,18 @@ class Compiler(
} }
} }
Token.Type.LBRACKET -> { Token.Type.LBRACKET, Token.Type.NULL_COALESCE_INDEX -> {
operand?.let { left -> operand?.let { left ->
// array access // array access
val isOptional = t.type == Token.Type.NULL_COALESCE_INDEX
val index = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting index expression") val index = parseStatement(cc) ?: throw ScriptError(t.pos, "Expecting index expression")
cc.skipTokenOfType(Token.Type.RBRACKET, "missing ']' at the end of the list literal") cc.skipTokenOfType(Token.Type.RBRACKET, "missing ']' at the end of the list literal")
operand = Accessor({ cxt -> operand = Accessor({ cxt ->
val i = (index.execute(cxt) as? ObjInt)?.value?.toInt() val i = (index.execute(cxt) as? ObjInt)?.value?.toInt()
?: cxt.raiseError("index must be integer") ?: cxt.raiseError("index must be integer")
left.getter(cxt).value.getAt(cxt, i).asMutable val x = left.getter(cxt).value
if( x == ObjNull && isOptional) ObjNull.asReadonly
else x.getAt(cxt, i).asMutable
}) { cxt, newValue -> }) { cxt, newValue ->
val i = (index.execute(cxt) as? ObjInt)?.value?.toInt() val i = (index.execute(cxt) as? ObjInt)?.value?.toInt()
?: cxt.raiseError("index must be integer") ?: cxt.raiseError("index must be integer")
@ -337,10 +354,15 @@ class Compiler(
} }
} }
Token.Type.LBRACE -> { Token.Type.LBRACE, Token.Type.NULL_COALESCE_BLOCKINVOKE -> {
operand = operand?.let { left -> operand = operand?.let { left ->
cc.previous() cc.previous()
parseFunctionCall(cc, left, blockArgument = true) parseFunctionCall(
cc,
left,
blockArgument = true,
t.type == Token.Type.NULL_COALESCE_BLOCKINVOKE
)
} ?: parseLambdaExpression(cc) } ?: parseLambdaExpression(cc)
} }
@ -590,7 +612,12 @@ class Compiler(
} }
private fun parseFunctionCall(cc: CompilerContext, left: Accessor, blockArgument: Boolean): Accessor { private fun parseFunctionCall(
cc: CompilerContext,
left: Accessor,
blockArgument: Boolean,
isOptional: Boolean
): Accessor {
// insofar, functions always return lvalue // insofar, functions always return lvalue
var detectedBlockArgument = blockArgument var detectedBlockArgument = blockArgument
val args = if (blockArgument) { val args = if (blockArgument) {
@ -607,6 +634,7 @@ class Compiler(
return Accessor { context -> return Accessor { context ->
val v = left.getter(context) val v = left.getter(context)
if (v.value == ObjNull && isOptional) return@Accessor v.value.asReadonly
v.value.callOn( v.value.callOn(
context.copy( context.copy(
context.pos, context.pos,
@ -1600,6 +1628,9 @@ class Compiler(
Operator.simple(Token.Type.NOTIN, lastPrty) { c, a, b -> ObjBool(!b.contains(c, a)) }, Operator.simple(Token.Type.NOTIN, lastPrty) { c, a, b -> ObjBool(!b.contains(c, a)) },
Operator.simple(Token.Type.IS, lastPrty) { c, a, b -> ObjBool(a.isInstanceOf(b)) }, Operator.simple(Token.Type.IS, lastPrty) { c, a, b -> ObjBool(a.isInstanceOf(b)) },
Operator.simple(Token.Type.NOTIS, lastPrty) { c, a, b -> ObjBool(!a.isInstanceOf(b)) }, Operator.simple(Token.Type.NOTIS, lastPrty) { c, a, b -> ObjBool(!a.isInstanceOf(b)) },
Operator.simple(Token.Type.ELVIS, ++lastPrty) { c, a, b -> if( a == ObjNull) b else a },
// shuttle <=> 6 // shuttle <=> 6
Operator.simple(Token.Type.SHUTTLE, ++lastPrty) { c, a, b -> Operator.simple(Token.Type.SHUTTLE, ++lastPrty) { c, a, b ->
ObjInt(a.compareTo(c, b).toLong()) ObjInt(a.compareTo(c, b).toLong())

View File

@ -16,7 +16,7 @@ class Context(
fun raiseNotImplemented(what: String = "operation"): Nothing = raiseError("$what is not implemented") fun raiseNotImplemented(what: String = "operation"): Nothing = raiseError("$what is not implemented")
@Suppress("unused") @Suppress("unused")
fun raiseNPE(): Nothing = raiseError(ObjNullPointerException(this)) fun raiseNPE(): Nothing = raiseError(ObjNullReferenceException(this))
@Suppress("unused") @Suppress("unused")
fun raiseIndexOutOfBounds(message: String = "Index out of bounds"): Nothing = fun raiseIndexOutOfBounds(message: String = "Index out of bounds"): Nothing =

View File

@ -289,6 +289,26 @@ object ObjNull : Obj() {
return other is ObjNull || other == null return other is ObjNull || other == null
} }
override suspend fun readField(context: Context, name: String): ObjRecord {
context.raiseNPE()
}
override suspend fun invokeInstanceMethod(context: Context, name: String, args: Arguments): Obj {
context.raiseNPE()
}
override suspend fun getAt(context: Context, index: Int): Obj {
context.raiseNPE()
}
override suspend fun putAt(context: Context, index: Int, newValue: Obj) {
context.raiseNPE()
}
override suspend fun callOn(context: Context): Obj {
context.raiseNPE()
}
override fun toString(): String = "null" override fun toString(): String = "null"
} }
@ -383,7 +403,7 @@ open class ObjException(exceptionClass: ExceptionClass, val context: Context, va
context.addConst("Exception", Root) context.addConst("Exception", Root)
existingErrorClasses["Exception"] = Root existingErrorClasses["Exception"] = Root
for (name in listOf( for (name in listOf(
"NullPointerException", "NullReferenceException",
"AssertionFailedException", "AssertionFailedException",
"ClassCastException", "ClassCastException",
"IndexOutOfBoundsException", "IndexOutOfBoundsException",
@ -400,7 +420,7 @@ open class ObjException(exceptionClass: ExceptionClass, val context: Context, va
} }
} }
class ObjNullPointerException(context: Context) : ObjException("NullPointerException", context, "object is null") class ObjNullReferenceException(context: Context) : ObjException("NullReferenceException", context, "object is null")
class ObjAssertionFailedException(context: Context, message: String) : class ObjAssertionFailedException(context: Context, message: String) :
ObjException("AssertionFailedException", context, message) ObjException("AssertionFailedException", context, message)

View File

@ -267,6 +267,21 @@ private class Parser(fromPos: Pos) {
Token(value.toString(), start, Token.Type.CHAR) Token(value.toString(), start, Token.Type.CHAR)
} }
'?' -> {
when(currentChar.also { pos.advance() }) {
':' -> Token("??", from, Token.Type.ELVIS)
'?' -> Token("??", from, Token.Type.ELVIS)
'.' -> Token("?.", from, Token.Type.NULL_COALESCE)
'[' -> Token("?(", from, Token.Type.NULL_COALESCE_INDEX)
'(' -> Token("?(", from, Token.Type.NULL_COALESCE_INVOKE)
'{' -> Token("?{", from, Token.Type.NULL_COALESCE_BLOCKINVOKE)
else -> {
pos.back()
Token("?", from, Token.Type.QUESTION)
}
}
}
else -> { else -> {
// text infix operators: // text infix operators:
// Labels processing is complicated! // Labels processing is complicated!

View File

@ -21,6 +21,11 @@ data class Token(val value: String, val pos: Pos, val type: Type) {
ELLIPSIS, DOTDOT, DOTDOTLT, ELLIPSIS, DOTDOT, DOTDOTLT,
NEWLINE, NEWLINE,
EOF, EOF,
NULL_COALESCE,
ELVIS,
NULL_COALESCE_INDEX,
NULL_COALESCE_INVOKE,
NULL_COALESCE_BLOCKINVOKE,
} }
companion object { companion object {

View File

@ -2113,4 +2113,24 @@ class ScriptTest {
""".trimIndent() """.trimIndent()
) )
} }
@Test
fun testNull1() = runTest {
eval("""
var s = null
assertThrows { s.length }
assertThrows { s.size() }
assertEquals( null, s?.size() )
assertEquals( null, s?.length )
assertEquals( null, s?.length ?{ "test" } )
assertEquals( null, s?[1] )
assertEquals( null, s ?{ "test" } )
assertEquals( null, s.test ?{ "test" } )
s = "xx"
assert(s.lower().size == 2)
assert(s.length == 2)
""".trimIndent())
}
} }