diff --git a/docs/ai_language_reference.md b/docs/ai_language_reference.md index e906cec..1190583 100644 --- a/docs/ai_language_reference.md +++ b/docs/ai_language_reference.md @@ -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. diff --git a/docs/scopes_and_closures.md b/docs/scopes_and_closures.md index 7bd77a9..0b39910 100644 --- a/docs/scopes_and_closures.md +++ b/docs/scopes_and_closures.md @@ -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. diff --git a/docs/tutorial.md b/docs/tutorial.md index 31a08e8..87ba6e8 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -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) diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index fe1b836..abd1313 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -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 diff --git a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt index faf0634..a058a52 100644 --- a/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt +++ b/lynglib/src/commonMain/kotlin/net/sergeych/lyng/Compiler.kt @@ -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, diff --git a/lynglib/src/commonTest/kotlin/CompileIfTest.kt b/lynglib/src/commonTest/kotlin/CompileIfTest.kt new file mode 100644 index 0000000..d3a7bcf --- /dev/null +++ b/lynglib/src/commonTest/kotlin/CompileIfTest.kt @@ -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()) + } +} diff --git a/lynglib/src/commonTest/kotlin/CompileTimeResolutionSpecTest.kt b/lynglib/src/commonTest/kotlin/CompileTimeResolutionSpecTest.kt index f868627..5307af4 100644 --- a/lynglib/src/commonTest/kotlin/CompileTimeResolutionSpecTest.kt +++ b/lynglib/src/commonTest/kotlin/CompileTimeResolutionSpecTest.kt @@ -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(