From 37d093817e337d6c8b8cb4541674299e9c2cbf31 Mon Sep 17 00:00:00 2001 From: sergeych Date: Sat, 14 Mar 2026 20:57:15 +0300 Subject: [PATCH] added `T is nullable` support --- docs/generics.md | 18 +++++++ docs/samples/type_nullability_checks.lyng | 10 ++++ docs/tutorial.md | 12 +++++ .../kotlin/net/sergeych/lyng/Compiler.kt | 48 +++++++++++++++---- .../kotlin/net/sergeych/lyng/WhenStatement.kt | 12 ++++- .../lyng/bytecode/BytecodeCompiler.kt | 16 +++++++ .../lyng/bytecode/BytecodeStatement.kt | 1 + lynglib/src/commonTest/kotlin/TypesTest.kt | 43 +++++++++++++++++ .../commonTest/kotlin/WebsiteSamplesTest.kt | 22 +++++++++ 9 files changed, 171 insertions(+), 11 deletions(-) create mode 100644 docs/samples/type_nullability_checks.lyng diff --git a/docs/generics.md b/docs/generics.md index 2c933ed..0591baf 100644 --- a/docs/generics.md +++ b/docs/generics.md @@ -109,6 +109,24 @@ Examples (T = A | B): B in T // true T is A | B // true +# Nullability checks for types + +Use `is nullable` to check whether a type expression accepts `null`: + + T is nullable + T !is nullable + +This works with concrete and generic types: + + fun describe(x: T): String = when (T) { + nullable -> "nullable" + else -> "non-null" + } + +Equivalent legacy form: + + null is T + # Practical examples fun acceptInts(xs: List) { } diff --git a/docs/samples/type_nullability_checks.lyng b/docs/samples/type_nullability_checks.lyng new file mode 100644 index 0000000..f225ada --- /dev/null +++ b/docs/samples/type_nullability_checks.lyng @@ -0,0 +1,10 @@ +fun describe(x: T): String = when (T) { + nullable -> "nullable" + else -> "non-null" +} + +type MaybeInt = Int? +assert(MaybeInt is nullable) +assert(!(Int is nullable)) +assertEquals("nullable", describe(null)) +assertEquals("non-null", describe(1)) diff --git a/docs/tutorial.md b/docs/tutorial.md index a16c4f5..6da9dd0 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -502,6 +502,18 @@ Aliases expand to their underlying type expressions. See `docs/generics.md` for `Null` is the class of `null`. It is a singleton type and mostly useful for type inference results. +For type expressions, you can check nullability directly: + + T is nullable + T !is nullable + +This is especially useful in generic code and in `when` over a type parameter: + + fun describe(x: T): String = when (T) { + nullable -> "nullable" + else -> "non-null" + } + ## Type inference The compiler infers types from: diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index ab84791..a65975a 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -2247,6 +2247,7 @@ class Compiler( is WhenEqualsCondition -> containsDelegatedRefs(cond.expr) is WhenInCondition -> containsDelegatedRefs(cond.expr) is WhenIsCondition -> false + is WhenNullableCondition -> false } } || containsDelegatedRefs(case.block) } || @@ -2520,12 +2521,22 @@ class Compiler( CastRef(lvalue!!, typeRef, true, opToken.pos) } } else if (opToken.type == Token.Type.IS || opToken.type == Token.Type.NOTIS) { - val (typeDecl, _) = parseTypeExpressionWithMini() - val typeRef = net.sergeych.lyng.obj.TypeDeclRef(typeDecl, opToken.pos) - if (opToken.type == Token.Type.IS) { - BinaryOpRef(BinOp.IS, lvalue!!, typeRef) + val nullablePredicate = tryConsumeNullablePredicate() + if (nullablePredicate) { + val nullRef = ConstRef(ObjNull.asReadonly) + if (opToken.type == Token.Type.IS) { + BinaryOpRef(BinOp.IS, nullRef, lvalue!!) + } else { + BinaryOpRef(BinOp.NOTIS, nullRef, lvalue!!) + } } else { - BinaryOpRef(BinOp.NOTIS, lvalue!!, typeRef) + val (typeDecl, _) = parseTypeExpressionWithMini() + val typeRef = net.sergeych.lyng.obj.TypeDeclRef(typeDecl, opToken.pos) + if (opToken.type == Token.Type.IS) { + BinaryOpRef(BinOp.IS, lvalue!!, typeRef) + } else { + BinaryOpRef(BinOp.NOTIS, lvalue!!, typeRef) + } } } else { val rvalue = parseExpressionLevel(level + 1) @@ -2557,6 +2568,17 @@ class Compiler( return lvalue } + private fun tryConsumeNullablePredicate(): Boolean { + val save = cc.savePos() + val t = cc.nextNonWhitespace() + return if (t.type == Token.Type.ID && t.value == "nullable") { + true + } else { + cc.restorePos(save) + false + } + } + private suspend fun parseTerm(): ObjRef? { var operand: ObjRef? = null var pendingCallTypeArgs: List? = null @@ -6252,10 +6274,14 @@ class Compiler( Token.Type.IS, Token.Type.NOTIS -> { val negated = t.type == Token.Type.NOTIS - val (typeDecl, _) = parseTypeExpressionWithMini() - val typeRef = net.sergeych.lyng.obj.TypeDeclRef(typeDecl, t.pos) - val caseType = ExpressionStatement(typeRef, t.pos) - currentConditions += WhenIsCondition(caseType, negated, t.pos) + if (tryConsumeNullablePredicate()) { + currentConditions += WhenNullableCondition(negated, t.pos) + } else { + val (typeDecl, _) = parseTypeExpressionWithMini() + val typeRef = net.sergeych.lyng.obj.TypeDeclRef(typeDecl, t.pos) + val caseType = ExpressionStatement(typeRef, t.pos) + currentConditions += WhenIsCondition(caseType, negated, t.pos) + } } Token.Type.COMMA -> @@ -6268,7 +6294,9 @@ class Compiler( break@outer else -> { - if (t.value == "else") { + if (t.type == Token.Type.ID && t.value == "nullable") { + currentConditions += WhenNullableCondition(negated = false, t.pos) + } else if (t.value == "else") { cc.skipTokens(Token.Type.ARROW) if (elseCase != null) throw ScriptError( cc.currentPos(), diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/WhenStatement.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/WhenStatement.kt index ed3c4a2..5ca15bd 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/WhenStatement.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/WhenStatement.kt @@ -1,5 +1,5 @@ /* - * Copyright 2026 Sergey S. Chernov + * Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -12,6 +12,7 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * */ package net.sergeych.lyng @@ -55,6 +56,15 @@ class WhenIsCondition( } } +class WhenNullableCondition( + val negated: Boolean, + override val pos: Pos, +) : WhenCondition(ExpressionStatement(net.sergeych.lyng.obj.ConstRef(net.sergeych.lyng.obj.ObjVoid.asReadonly), pos), pos) { + override suspend fun matches(scope: Scope, value: Obj): Boolean { + return bytecodeOnly(scope) + } +} + data class WhenCase(val conditions: List, val block: Statement) class WhenStatement( diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt index 7d3fdc9..da3700b 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeCompiler.kt @@ -4345,6 +4345,22 @@ class BytecodeCompiler( CompiledValue(neg, SlotType.BOOL) } } + is WhenNullableCondition -> { + val nullSlot = allocSlot() + builder.emit(Opcode.CONST_NULL, nullSlot) + updateSlotType(nullSlot, SlotType.OBJ) + val baseDst = allocSlot() + builder.emit(Opcode.CHECK_IS, nullSlot, subject.slot, baseDst) + updateSlotType(baseDst, SlotType.BOOL) + if (!cond.negated) { + CompiledValue(baseDst, SlotType.BOOL) + } else { + val neg = allocSlot() + builder.emit(Opcode.NOT_BOOL, baseDst, neg) + updateSlotType(neg, SlotType.BOOL) + CompiledValue(neg, SlotType.BOOL) + } + } } } diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeStatement.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeStatement.kt index 982a879..b9454d4 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeStatement.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/bytecode/BytecodeStatement.kt @@ -443,6 +443,7 @@ class BytecodeStatement private constructor( is WhenEqualsCondition -> WhenEqualsCondition(unwrapDeep(cond.expr), cond.pos) is WhenInCondition -> WhenInCondition(unwrapDeep(cond.expr), cond.negated, cond.pos) is WhenIsCondition -> WhenIsCondition(unwrapDeep(cond.expr), cond.negated, cond.pos) + is WhenNullableCondition -> WhenNullableCondition(cond.negated, cond.pos) } } } diff --git a/lynglib/src/commonTest/kotlin/TypesTest.kt b/lynglib/src/commonTest/kotlin/TypesTest.kt index b244ef5..78bc2b6 100644 --- a/lynglib/src/commonTest/kotlin/TypesTest.kt +++ b/lynglib/src/commonTest/kotlin/TypesTest.kt @@ -535,6 +535,49 @@ class TypesTest { """.trimIndent()) } + @Test + fun testGenericNullableTypePredicate() = runTest { + eval(""" + fun isTypeNullable(x: T): Bool = T is nullable + type MaybeInt = Int? + assert(isTypeNullable(null)) + assert(!isTypeNullable(1)) + assert(MaybeInt is nullable) + assert(!(Int is nullable)) + """.trimIndent()) + } + + @Test + fun testWhenNullableTypeCase() = runTest { + eval(""" + fun describe(x: T): String = when(T) { + nullable -> "nullable" + else -> "non-null" + } + fun describeIs(x: T): String = when(T) { + is nullable -> "nullable" + else -> "non-null" + } + assertEquals("nullable", describe(null)) + assertEquals("non-null", describe(1)) + assertEquals("nullable", describeIs(null)) + assertEquals("non-null", describeIs(1)) + """.trimIndent()) + } + +// @Test +// fun testNullableGenericTypes() = runTest { +// eval(""" +// fun t(): String = +// when(T) { +// is Object -> "%s is Object"(T::class.name) +// else -> throw "It should not happen" +// } +// assert( Int is Object) +// assertEquals( t(), "Class is Object") +// """.trimIndent()) +// } + @Test fun testIndexer() = runTest { eval(""" class Greeter { diff --git a/lynglib/src/commonTest/kotlin/WebsiteSamplesTest.kt b/lynglib/src/commonTest/kotlin/WebsiteSamplesTest.kt index af24884..c24a220 100644 --- a/lynglib/src/commonTest/kotlin/WebsiteSamplesTest.kt +++ b/lynglib/src/commonTest/kotlin/WebsiteSamplesTest.kt @@ -201,6 +201,28 @@ class WebsiteSamplesTest { assertEquals(12L, (result as ObjInt).value) } + @Test + fun testNullableTypePredicate() = runTest { + val scope = Script.newScope() + val result = scope.eval( + """ + // Type nullability checks + fun describe(x: T): String = when(T) { + nullable -> "nullable" + else -> "non-null" + } + type MaybeInt = Int? + [describe(null), describe(1), Int is nullable, MaybeInt is nullable] + """.trimIndent() + ) + assertTrue(result is ObjList) + val list = result.list + assertEquals("nullable", (list[0] as ObjString).value) + assertEquals("non-null", (list[1] as ObjString).value) + assertEquals(false, (list[2] as ObjBool).value) + assertEquals(true, (list[3] as ObjBool).value) + } + @Test fun testEasyOperatorOverloading() = runTest { val scope = Script.newScope()