v1.5.5-SNAPSHOT started. compile if support

This commit is contained in:
Sergey Chernov 2026-04-06 11:09:14 +03:00
parent 214f1aec9e
commit 671583638b
7 changed files with 467 additions and 3 deletions

View File

@ -132,6 +132,9 @@ Primary sources used: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/{Parser,T
## 6. Control Flow
- `if` is expression-like.
- `compile if (cond) { ... } else { ... }` is a compile-time-only conditional.
- current condition grammar is restricted to `defined(NameOr.Package)`, `!`, `&&`, `||`, and parentheses.
- the untaken branch is skipped by the compiler and is not name-resolved or type-checked.
- `when(value) { ... }` supported.
- branch conditions support equality, `in`, `!in`, `is`, `!is`, and `nullable` predicate.
- `when { ... }` (subject-less) is currently not implemented.

View File

@ -6,6 +6,7 @@ This page documents the **current** rules: static name resolution, closure captu
## Current rules (bytecode compiler)
- **All names resolve at compile time**: locals, parameters, captures, members, imports, and module globals must be known when compiling. Missing symbols are compile-time errors.
- **Exception: `compile if` can skip dead branches**: inside an untaken `compile if (...)` branch, names are not resolved or type-checked at all. This is the supported way to guard optional classes or packages such as `defined(Udp)` or `defined(lyng.io.net)`.
- **No runtime fallbacks**: there is no dynamic name lookup, no fallback opcodes, and no “search parent scopes” at runtime for missing names.
- **Object members on unknown types only**: `toString`, `toInspectString`, `let`, `also`, `apply`, `run` are allowed on unknown types; all other members require a statically known receiver type or an explicit cast.
- **Closures capture slots**: lambdas and nested functions capture **frame slots** directly. Captures are resolved at compile time and compiled to slot references.

View File

@ -1094,6 +1094,37 @@ Or, more neat:
>>> just 3
>>> void
## compile if
`compile if` is a compile-time conditional. Unlike normal `if`, the compiler evaluates its condition while compiling
the file and completely skips the untaken branch. This is useful when some class or package may or may not be
available:
compile if (defined(Udp)) {
val socket = Udp()
println("udp is available")
} else {
println("udp is not available")
}
`compile if` also supports single-statement branches:
compile if (defined(lyng.io.net) && !defined(Udp))
println("network module exists, but Udp is not visible here")
else
println("either Udp exists or the module is unavailable")
Current condition syntax is intentionally limited to compile-time symbol checks:
- `defined(Name)`
- `defined(package.name)`
- `!`, `&&`, `||`
- parentheses
Examples:
compile if (defined(Udp) && defined(Tcp))
println("both transports are available")
## When
See also: [Comprehensive guide to `when`](when.md)

View File

@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
group = "net.sergeych"
version = "1.5.4"
version = "1.5.5-SNAPSHOT"
// Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below

View File

@ -6763,6 +6763,16 @@ class Compiler(
"break" -> parseBreakStatement(id.pos)
"continue" -> parseContinueStatement(id.pos)
"if" -> parseIfStatement()
"compile" -> {
val saved = cc.savePos()
val next = cc.nextNonWhitespace()
if (next.type == Token.Type.ID && next.value == "if") {
parseCompileIfStatement(id.pos)
} else {
cc.restorePos(saved)
null
}
}
"class" -> {
pendingDeclStart = id.pos
pendingDeclDoc = consumePendingDoc()
@ -8336,6 +8346,307 @@ class Compiler(
return wrapBytecode(stmt)
}
private suspend fun parseCompileIfStatement(startPos: Pos): Statement {
val start = ensureLparen()
val condition = parseCompileCondition()
val pos = ensureRparen()
val ifBody = if (condition) {
parseCompileIfBranch(pos, "compile if")
} else {
skipCompileIfBranch(pos, "compile if")
NopStatement
}
val saved = cc.savePos()
val maybeElse = cc.nextNonWhitespace()
return if (maybeElse.type == Token.Type.ID && maybeElse.value == "else") {
if (condition) {
skipCompileIfBranch(pos, "compile else")
ifBody
} else {
parseCompileIfBranch(pos, "compile else")
}
} else {
cc.restorePos(saved)
if (condition) ifBody else NopStatement
}
}
private suspend fun parseCompileCondition(): Boolean = parseCompileConditionOr()
private suspend fun parseCompileConditionOr(): Boolean {
var value = parseCompileConditionAnd()
while (true) {
val saved = cc.savePos()
val token = cc.nextNonWhitespace()
if (token.type != Token.Type.OR) {
cc.restorePos(saved)
return value
}
value = value || parseCompileConditionAnd()
}
}
private suspend fun parseCompileConditionAnd(): Boolean {
var value = parseCompileConditionUnary()
while (true) {
val saved = cc.savePos()
val token = cc.nextNonWhitespace()
if (token.type != Token.Type.AND) {
cc.restorePos(saved)
return value
}
value = value && parseCompileConditionUnary()
}
}
private suspend fun parseCompileConditionUnary(): Boolean {
val saved = cc.savePos()
val token = cc.nextNonWhitespace()
return when {
token.type == Token.Type.NOT -> !parseCompileConditionUnary()
token.type == Token.Type.LPAREN -> {
val nested = parseCompileConditionOr()
val close = cc.nextNonWhitespace()
if (close.type != Token.Type.RPAREN) {
throw ScriptError(close.pos, "expected ')' in compile-time condition")
}
nested
}
token.type == Token.Type.ID && token.value == "defined" -> parseDefinedCompileCondition(token.pos)
else -> {
cc.restorePos(saved)
throw ScriptError(token.pos, "compile if condition supports only defined(...), !, &&, ||, and parentheses")
}
}
}
private suspend fun parseDefinedCompileCondition(atPos: Pos): Boolean {
val open = cc.nextNonWhitespace()
if (open.type != Token.Type.LPAREN) {
throw ScriptError(open.pos, "expected '(' after defined")
}
val target = parseDefinedCompileTarget()
val close = cc.nextNonWhitespace()
if (close.type != Token.Type.RPAREN) {
throw ScriptError(close.pos, "expected ')' after defined($target")
}
return isCompileDefined(target, atPos)
}
private fun parseDefinedCompileTarget(): String {
val first = cc.nextNonWhitespace()
if (first.type != Token.Type.ID) {
throw ScriptError(first.pos, "defined(...) expects a class or package name")
}
val parts = mutableListOf(first.value)
while (true) {
val saved = cc.savePos()
val dot = cc.nextNonWhitespace()
if (dot.type != Token.Type.DOT) {
cc.restorePos(saved)
break
}
val part = cc.nextNonWhitespace()
if (part.type != Token.Type.ID) {
throw ScriptError(part.pos, "expected identifier after '.' in defined(...)")
}
parts += part.value
}
return parts.joinToString(".")
}
private suspend fun isCompileDefined(name: String, pos: Pos): Boolean {
if (name.contains('.')) {
if (resolveClassByName(name) != null) return true
return try {
importManager.prepareImport(pos, name, null)
true
} catch (_: ImportException) {
false
}
}
if (name == "__PACKAGE__" || name == "this") return true
if (lookupSlotLocation(name, includeModule = false) != null) return true
val classCtx = codeContexts.lastOrNull { it is CodeContext.ClassBody } as? CodeContext.ClassBody
val implicitTypeFromFunc = implicitReceiverTypeForMember(name)
val hasImplicitClassMember = classCtx != null && hasImplicitThisMember(name, classCtx.name)
if (classCtx != null && classCtx.declaredMembers.contains(name)) return true
if (classCtx != null && classCtx.classScopeMembers.contains(name)) return true
if (classCtx != null && hasImplicitThisMember(name, classCtx.name)) return true
if (implicitTypeFromFunc != null) return true
if (codeContexts.any { it is CodeContext.ClassBody } && extensionNames.contains(name)) return true
val moduleLoc = if (slotPlanStack.size == 1) lookupSlotLocation(name, includeModule = true) else null
if (moduleLoc != null) {
val moduleDeclaredNames = localNamesStack.firstOrNull()
if (moduleDeclaredNames == null || !moduleDeclaredNames.contains(name)) {
if (resolveImportBinding(name, pos) != null) return true
if (predeclaredTopLevelValueNames.contains(name)) return false
}
return true
}
val modulePlan = moduleSlotPlan()
val moduleEntry = modulePlan?.slots?.get(name)
if (moduleEntry != null) {
val moduleDeclaredNames = localNamesStack.firstOrNull()
if (moduleDeclaredNames == null || !moduleDeclaredNames.contains(name)) {
if (resolveImportBinding(name, pos) != null) return true
if (predeclaredTopLevelValueNames.contains(name)) return false
}
return true
}
if (resolveImportBinding(name, pos) != null) return true
return resolveClassByName(name) != null
}
private suspend fun parseCompileIfBranch(pos: Pos, branchLabel: String): Statement {
return parseStatement() ?: throw ScriptError(pos, "$branchLabel expected statement")
}
private fun skipCompileIfBranch(pos: Pos, branchLabel: String) {
skipStandaloneStatement(pos, branchLabel)
}
private fun skipStandaloneStatement(pos: Pos, branchLabel: String) {
while (true) {
val token = cc.next()
when (token.type) {
Token.Type.NEWLINE,
Token.Type.SEMICOLON,
Token.Type.SINGLE_LINE_COMMENT,
Token.Type.MULTILINE_COMMENT -> continue
Token.Type.EOF -> throw ScriptError(pos, "$branchLabel expected statement")
else -> {
skipStatementFromToken(token, branchLabel)
return
}
}
}
}
private fun skipStatementFromToken(first: Token, branchLabel: String) {
if (first.type == Token.Type.LBRACE) {
skipBalancedGroup(Token.Type.LBRACE, Token.Type.RBRACE, branchLabel)
return
}
if (first.type == Token.Type.ID) {
when (first.value) {
"if" -> {
skipParenthesizedAfterKeyword(branchLabel)
skipStandaloneStatement(first.pos, branchLabel)
skipOptionalElseBranch(branchLabel)
return
}
"compile" -> {
val saved = cc.savePos()
val next = cc.nextNonWhitespace()
if (next.type == Token.Type.ID && next.value == "if") {
skipParenthesizedAfterKeyword(branchLabel)
skipStandaloneStatement(first.pos, branchLabel)
skipOptionalElseBranch(branchLabel)
return
}
cc.restorePos(saved)
}
"while", "for" -> {
skipParenthesizedAfterKeyword(branchLabel)
skipStandaloneStatement(first.pos, branchLabel)
skipOptionalElseBranch(branchLabel)
return
}
"do" -> {
skipStandaloneStatement(first.pos, branchLabel)
val whileTok = cc.nextNonWhitespace()
if (whileTok.type != Token.Type.ID || whileTok.value != "while") {
throw ScriptError(whileTok.pos, "expected 'while' after do body in skipped $branchLabel")
}
skipParenthesizedAfterKeyword(branchLabel)
skipOptionalElseBranch(branchLabel)
return
}
}
}
skipFlatStatementRemainder(branchLabel)
}
private fun skipOptionalElseBranch(branchLabel: String) {
val saved = cc.savePos()
cc.skipTokenOfType(Token.Type.NEWLINE, isOptional = true)
val token = cc.nextNonWhitespace()
if (token.type == Token.Type.ID && token.value == "else") {
skipStandaloneStatement(token.pos, branchLabel)
} else {
cc.restorePos(saved)
}
}
private fun skipParenthesizedAfterKeyword(branchLabel: String) {
val open = cc.nextNonWhitespace()
if (open.type != Token.Type.LPAREN) {
throw ScriptError(open.pos, "expected '(' in skipped $branchLabel")
}
skipBalancedGroup(Token.Type.LPAREN, Token.Type.RPAREN, branchLabel)
}
private fun skipBalancedGroup(openType: Token.Type, closeType: Token.Type, branchLabel: String) {
var depth = 1
while (depth > 0) {
val token = cc.next()
when (token.type) {
openType -> depth += 1
closeType -> depth -= 1
Token.Type.EOF -> throw ScriptError(token.pos, "unbalanced tokens in skipped $branchLabel")
else -> {}
}
}
}
private fun skipFlatStatementRemainder(branchLabel: String) {
var parenDepth = 0
var bracketDepth = 0
var braceDepth = 0
while (true) {
val saved = cc.savePos()
val token = cc.next()
when (token.type) {
Token.Type.LPAREN -> parenDepth += 1
Token.Type.RPAREN -> {
if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) {
cc.restorePos(saved)
return
}
parenDepth -= 1
}
Token.Type.LBRACKET -> bracketDepth += 1
Token.Type.RBRACKET -> {
if (bracketDepth == 0 && parenDepth == 0 && braceDepth == 0) {
cc.restorePos(saved)
return
}
bracketDepth -= 1
}
Token.Type.LBRACE -> braceDepth += 1
Token.Type.RBRACE -> {
if (braceDepth == 0 && parenDepth == 0 && bracketDepth == 0) {
cc.restorePos(saved)
return
}
braceDepth -= 1
}
Token.Type.NEWLINE, Token.Type.SEMICOLON -> {
if (parenDepth == 0 && bracketDepth == 0 && braceDepth == 0) {
return
}
}
Token.Type.EOF -> return
else -> {}
}
}
}
private suspend fun parseFunctionDeclaration(
visibility: Visibility = Visibility.Public,
isAbstract: Boolean = false,

View File

@ -0,0 +1,106 @@
/*
* 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.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* 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.
*
*/
import kotlinx.coroutines.test.runTest
import net.sergeych.lyng.eval
import net.sergeych.lyng.obj.toInt
import kotlin.test.Test
import kotlin.test.assertEquals
class CompileIfTest {
@Test
fun falseBranchSkipsMissingSymbols() = runTest {
val result = eval(
"""
var x = 0
compile if (defined(NoSuchClass)) {
x = NoSuchClass("foo")
} else {
x = 42
}
x
""".trimIndent()
)
assertEquals(42, result.toInt())
}
@Test
fun logicExpressionsWorkForDefinedChecks() = runTest {
val result = eval(
"""
var x = 0
compile if (defined(String) && !defined(NoSuchClass)) {
x = 7
} else {
x = NoSuchClass("foo")
}
x
""".trimIndent()
)
assertEquals(7, result.toInt())
}
@Test
fun packageChecksWorkInCompileIf() = runTest {
val result = eval(
"""
var x = 0
compile if (defined(lyng.stdlib) && !defined(no.such.pkg)) {
x = 1
} else {
x = 2
}
x
""".trimIndent()
)
assertEquals(1, result.toInt())
}
@Test
fun singleStatementBranchesWork() = runTest {
val result = eval(
"""
var x = 0
compile if (defined(String))
x = 9
else
x = NoSuchClass("foo")
x
""".trimIndent()
)
assertEquals(9, result.toInt())
}
@Test
fun nestedElseBranchIsSkippedAsSingleStatement() = runTest {
val result = eval(
"""
var x = 0
compile if (defined(NoSuchClass))
if (true)
x = NoSuchClass("foo")
else
x = 1
else
x = 5
x
""".trimIndent()
)
assertEquals(5, result.toInt())
}
}

View File

@ -20,9 +20,7 @@ import net.sergeych.lyng.Compiler
import net.sergeych.lyng.Script
import net.sergeych.lyng.Source
import net.sergeych.lyng.resolution.SymbolOrigin
import kotlin.test.Ignore
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class CompileTimeResolutionSpecTest {
@ -83,6 +81,20 @@ class CompileTimeResolutionSpecTest {
assertTrue(report.errors.any { it.message.contains("missingName") })
}
@Test
fun compileIfSkipsResolutionForUnselectedBranch() = runTest {
val report = dryRun(
"""
compile if (defined(NoSuchClass)) {
NoSuchClass("foo")
} else {
1
}
"""
)
assertTrue(report.errors.isEmpty())
}
@Test
fun miAmbiguityIsCompileError() = runTest {
val report = dryRun(