From b630d69186a1a9ee98d1d3983adcaa0cd1a5b04e Mon Sep 17 00:00:00 2001 From: sergeych Date: Fri, 5 Dec 2025 21:02:18 +0100 Subject: [PATCH] fix #78 add `fmt` CLI subcommand and improve legacy script execution paths --- CHANGELOG.md | 24 ++++ lyng/${file} | 1 + lyng/src/commonMain/kotlin/Common.kt | 123 ++++++++-------- lyng/src/jvmMain/kotlin/Common.jvm.kt | 7 +- .../net/sergeych/lyng_cli/CliFmtJvmTest.kt | 134 ++++++++++++++++++ lynglib/src/commonTest/kotlin/ScriptTest.kt | 17 ++- 6 files changed, 248 insertions(+), 58 deletions(-) create mode 100644 lyng/${file} create mode 100644 lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliFmtJvmTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 79dda64..5aea380 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,3 +43,27 @@ Notes: - Existing single-inheritance code continues to work; resolution reduces to the single base. - If code previously relied on non-deterministic parent set iteration, C3 MRO provides a predictable order; disambiguate explicitly if needed using `this@Type`/casts. + +# Changelog + +All notable changes to this project will be documented in this file. + +## Unreleased + +- CLI: Added `fmt` as a first-class Clikt subcommand. + - Default behavior: formats files to stdout (no in-place edits by default). + - Options: + - `--check`: check only; print files that would change; exit with code 2 if any changes are needed. + - `-i, --in-place`: write formatted result back to files. + - `--spacing`: apply spacing normalization. + - `--wrap`, `--wrapping`: enable line wrapping. + - Mutually exclusive: `--check` and `--in-place` together now produce an error and exit with code 1. + - Multi-file stdout prints headers `--- ---` per file. + - `lyng --help` shows `fmt`; `lyng fmt --help` displays dedicated help. + +- CLI: Preserved legacy script invocation fast-paths: + - `lyng script.lyng [args...]` executes the script directly. + - `lyng -- -file.lyng [args...]` executes a script whose name begins with `-`. + +- CLI: Fixed a regression where the root help banner could print before subcommands. + - Root command no longer prints help when a subcommand (e.g., `fmt`) is invoked. diff --git a/lyng/${file} b/lyng/${file} new file mode 100644 index 0000000..bc25baf --- /dev/null +++ b/lyng/${file} @@ -0,0 +1 @@ +hello from cli test \ No newline at end of file diff --git a/lyng/src/commonMain/kotlin/Common.kt b/lyng/src/commonMain/kotlin/Common.kt index f400c8b..1320f9d 100644 --- a/lyng/src/commonMain/kotlin/Common.kt +++ b/lyng/src/commonMain/kotlin/Common.kt @@ -20,6 +20,7 @@ package net.sergeych import com.github.ajalt.clikt.core.CliktCommand import com.github.ajalt.clikt.core.Context import com.github.ajalt.clikt.core.main +import com.github.ajalt.clikt.core.subcommands import com.github.ajalt.clikt.parameters.arguments.argument import com.github.ajalt.clikt.parameters.arguments.multiple import com.github.ajalt.clikt.parameters.arguments.optional @@ -71,69 +72,81 @@ val baseScopeDefer = globalDefer { } fun runMain(args: Array) { - if(args.isNotEmpty()) { - // CLI formatter: lyng fmt [--check] [--in-place] - if (args[0] == "fmt") { - formatCli(args.drop(1)) - return - } - if( args.size >= 2 && args[0] == "--" ) { - // -- -file.lyng + // Fast paths for legacy/positional script execution that should work without requiring explicit options + if (args.isNotEmpty()) { + // Support: jyng -- -file.lyng + if (args.size >= 2 && args[0] == "--") { executeFileWithArgs(args[1], args.drop(2)) return - } else if( args[0][0] != '-') { - // file.lyng + } + // Support: jyng script.lyng (when first token is not an option and not a subcommand name) + if (!args[0].startsWith('-') && args[0] != "fmt") { executeFileWithArgs(args[0], args.drop(1)) return } } - // normal processing - Lyng { runBlocking { it() } }.main(args) + + // Delegate all other parsing and dispatching to Clikt with proper subcommands. + Lyng { runBlocking { it() } } + .subcommands(Fmt()) + .main(args) } -private fun formatCli(args: List) { - var checkOnly = false - var inPlace = true - var enableSpacing = false - var enableWrapping = false - val files = mutableListOf() - for (a in args) { - when (a) { - "--check" -> { checkOnly = true; inPlace = false } - "--in-place", "-i" -> inPlace = true - "--spacing" -> enableSpacing = true - "--wrap", "--wrapping" -> enableWrapping = true - else -> files += a +private class Fmt : CliktCommand(name = "fmt") { + private val checkOnly by option("--check", help = "Check only; print files that would change").flag() + private val inPlace by option("-i", "--in-place", help = "Write changes back to files").flag() + private val enableSpacing by option("--spacing", help = "Apply spacing normalization").flag() + private val enableWrapping by option("--wrap", "--wrapping", help = "Enable line wrapping").flag() + private val files by argument(help = "One or more .lyng files to format").multiple() + + override fun help(context: Context): String = "Format Lyng source files" + + override fun run() { + // Validate inputs + if (files.isEmpty()) { + println("Error: no files specified. See --help for usage.") + exit(1) } - } - if (files.isEmpty()) { - println("Usage: lyng fmt [--check] [--in-place|-i] [--spacing] [--wrap] [file2.lyng ...]") - exit(1) - return - } - var changed = false - val cfg = net.sergeych.lyng.format.LyngFormatConfig( - applySpacing = enableSpacing, - applyWrapping = enableWrapping, - ) - for (path in files) { - val p = path.toPath() - val original = FileSystem.SYSTEM.source(p).use { it.buffer().use { bs -> bs.readUtf8() } } - val formatted = net.sergeych.lyng.format.LyngFormatter.format(original, cfg) - if (formatted != original) { - changed = true + if (checkOnly && inPlace) { + println("Error: --check and --in-place cannot be used together") + exit(1) + } + + val cfg = net.sergeych.lyng.format.LyngFormatConfig( + applySpacing = enableSpacing, + applyWrapping = enableWrapping, + ) + + var anyChanged = false + val multiFile = files.size > 1 + + for (path in files) { + val p = path.toPath() + val original = FileSystem.SYSTEM.source(p).use { it.buffer().use { bs -> bs.readUtf8() } } + val formatted = net.sergeych.lyng.format.LyngFormatter.format(original, cfg) + val changed = formatted != original if (checkOnly) { - println(path) + if (changed) { + println(path) + anyChanged = true + } } else if (inPlace) { - FileSystem.SYSTEM.write(p) { writeUtf8(formatted) } + // Write back regardless, but only touch file if content differs + if (changed) { + FileSystem.SYSTEM.write(p) { writeUtf8(formatted) } + } } else { - // default to stdout if not in-place and not --check - println("--- $path (formatted) ---\n$formatted") + // Default: stdout output + if (multiFile) { + println("--- $path ---") + } + println(formatted) } } - } - if (checkOnly) { - exit(if (changed) 2 else 0) + + if (checkOnly) { + exit(if (anyChanged) 2 else 0) + } } } @@ -162,6 +175,10 @@ private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand() """.trimIndent() override fun run() { + // If a subcommand (like `fmt`) was invoked, do nothing in the root command. + // This prevents the root from printing help before the subcommand runs. + if (currentContext.invokedSubcommand != null) return + runBlocking { val baseScope = baseScopeDefer.await() when { @@ -188,13 +205,7 @@ private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand() else -> { if (script == null) { - println( - """ - - Error: no script specified. - - """.trimIndent() - ) + println("Error: no script specified.\n") echoFormattedHelp() } else { baseScope.addConst("ARGV", ObjList(args.map { ObjString(it) }.toMutableList())) diff --git a/lyng/src/jvmMain/kotlin/Common.jvm.kt b/lyng/src/jvmMain/kotlin/Common.jvm.kt index 8b774ec..acf3001 100644 --- a/lyng/src/jvmMain/kotlin/Common.jvm.kt +++ b/lyng/src/jvmMain/kotlin/Common.jvm.kt @@ -19,6 +19,11 @@ package net.sergeych import kotlin.system.exitProcess +// Allow tests to override JVM exit behavior without terminating the whole VM. +// In production, this points to exitProcess; tests can replace it to throw. +@PublishedApi +internal var jvmExitImpl: (Int) -> Nothing = { code -> exitProcess(code) } + actual fun exit(code: Int) { - exitProcess(code) + jvmExitImpl(code) } \ No newline at end of file diff --git a/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliFmtJvmTest.kt b/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliFmtJvmTest.kt new file mode 100644 index 0000000..e0ac983 --- /dev/null +++ b/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliFmtJvmTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2025 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. + * + */ +package net.sergeych.lyng_cli + +import net.sergeych.jvmExitImpl +import net.sergeych.runMain +import org.junit.After +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.io.ByteArrayOutputStream +import java.io.PrintStream +import java.nio.file.Files +import java.nio.file.Path + +class CliFmtJvmTest { + private val originalOut: PrintStream = System.out + private val originalErr: PrintStream = System.err + + private class TestExit(val code: Int) : RuntimeException() + + @Before + fun setUp() { + // Make exit() throw in tests so we can assert the code + jvmExitImpl = { code -> throw TestExit(code) } + } + + @After + fun tearDown() { + System.setOut(originalOut) + System.setErr(originalErr) + // restore default exit behavior for safety + jvmExitImpl = { code -> kotlin.system.exitProcess(code) } + } + + private data class CliResult(val out: String, val err: String, val exitCode: Int?) + + private fun runCli(vararg args: String): CliResult { + val outBuf = ByteArrayOutputStream() + val errBuf = ByteArrayOutputStream() + System.setOut(PrintStream(outBuf, true, Charsets.UTF_8)) + System.setErr(PrintStream(errBuf, true, Charsets.UTF_8)) + + var exitCode: Int? = null + try { + runMain(arrayOf(*args)) + } catch (e: TestExit) { + exitCode = e.code + } finally { + System.out.flush() + System.err.flush() + } + return CliResult(outBuf.toString("UTF-8"), errBuf.toString("UTF-8"), exitCode) + } + + @Test + fun fmtDoesNotPrintRootHelp() { + val tmp: Path = Files.createTempFile("lyng_fmt_", ".lyng") + try { + Files.writeString(tmp, "println(1)\n") + val r = runCli("fmt", tmp.toString()) + // Root help banner should not appear + assertFalse(r.out.contains("The Lyng script language interpreter")) + // Should output formatted content (stdout default) + assertTrue("Expected some output", r.out.isNotBlank()) + } finally { + Files.deleteIfExists(tmp) + } + } + + @Test + fun fmtCheckAndInPlaceAreMutuallyExclusive() { + val r = runCli("fmt", "--check", "--in-place", "nonexistent.lyng") + // Should exit with code 1 and print an error + assertTrue("Expected exit code 1", r.exitCode == 1) + assertTrue(r.out.contains("cannot be used together")) + } + + @Test + fun fmtMultipleFilesPrintsHeaders() { + val tmp1: Path = Files.createTempFile("lyng_fmt_", ".lyng") + val tmp2: Path = Files.createTempFile("lyng_fmt_", ".lyng") + try { + Files.writeString(tmp1, "println(1)\n") + Files.writeString(tmp2, "println(2)\n") + val r = runCli("fmt", tmp1.toString(), tmp2.toString()) + assertTrue(r.out.contains("--- ${tmp1.toString()} ---")) + assertTrue(r.out.contains("--- ${tmp2.toString()} ---")) + } finally { + Files.deleteIfExists(tmp1) + Files.deleteIfExists(tmp2) + } + } + + @Test + fun legacyPositionalScriptExecutes() { + // Create a tiny script and ensure it runs when passed positionally + val tmp: Path = Files.createTempFile("lyng_script_", ".lyng") + try { + Files.writeString(tmp, "println(\"OK\")\n") + val r = runCli(tmp.toString()) + assertTrue(r.out.contains("OK")) + } finally { + Files.deleteIfExists(tmp) + } + } + + @Test + fun legacyDoubleDashStopsParsingAndExecutesScript() { + val tmp: Path = Files.createTempFile("lyng_script_", ".lyng") + try { + Files.writeString(tmp, "println(\"DASH\")\n") + val r = runCli("--", tmp.toString()) + assertTrue(r.out.contains("DASH")) + } finally { + Files.deleteIfExists(tmp) + } + } +} diff --git a/lynglib/src/commonTest/kotlin/ScriptTest.kt b/lynglib/src/commonTest/kotlin/ScriptTest.kt index 6facf19..989f640 100644 --- a/lynglib/src/commonTest/kotlin/ScriptTest.kt +++ b/lynglib/src/commonTest/kotlin/ScriptTest.kt @@ -3992,7 +3992,22 @@ class ScriptTest { val x = eval(""" import lyng.serialization { value: 12, inner: { "foo": 1, "bar": "two" }} - """.trimIndent()).decodeSerializable()println(x) + """.trimIndent()).decodeSerializable() + assertEquals(TestJson3(12, JsonObject(mapOf("foo" to JsonPrimitive(1), "bar" to Json.encodeToJsonElement("two")))), x) + } + + @Serializable + data class TestEnum( + val value: Int, + val inner: JsonObject + ) + @Test + fun deserializeEnumJsonTest() = runTest { + val x = eval(""" + import lyng.serialization + enum + { value: 12, inner: { "foo": 1, "bar": "two" }} + """.trimIndent()).decodeSerializable() assertEquals(TestJson3(12, JsonObject(mapOf("foo" to JsonPrimitive(1), "bar" to Json.encodeToJsonElement("two")))), x) } }