fix #78 add fmt CLI subcommand and improve legacy script execution paths

This commit is contained in:
Sergey Chernov 2025-12-05 21:02:18 +01:00
parent 20f4e54a02
commit b630d69186
6 changed files with 248 additions and 58 deletions

View File

@ -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 `--- <path> ---` 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.

1
lyng/${file} Normal file
View File

@ -0,0 +1 @@
hello from cli test

View File

@ -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<String>) {
if(args.isNotEmpty()) {
// CLI formatter: lyng fmt [--check] [--in-place] <files...>
if (args[0] == "fmt") {
formatCli(args.drop(1))
return
}
if( args.size >= 2 && args[0] == "--" ) {
// -- -file.lyng <args>
// Fast paths for legacy/positional script execution that should work without requiring explicit options
if (args.isNotEmpty()) {
// Support: jyng -- -file.lyng <args>
if (args.size >= 2 && args[0] == "--") {
executeFileWithArgs(args[1], args.drop(2))
return
} else if( args[0][0] != '-') {
// file.lyng <args>
}
// Support: jyng script.lyng <args> (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<String>) {
var checkOnly = false
var inPlace = true
var enableSpacing = false
var enableWrapping = false
val files = mutableListOf<String>()
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] <file1.lyng> [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()))

View File

@ -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)
}

View File

@ -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)
}
}
}

View File

@ -3992,7 +3992,22 @@ class ScriptTest {
val x = eval("""
import lyng.serialization
{ value: 12, inner: { "foo": 1, "bar": "two" }}
""".trimIndent()).decodeSerializable<TestJson3>()println(x)
""".trimIndent()).decodeSerializable<TestJson3>()
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<TestJson3>()
assertEquals(TestJson3(12, JsonObject(mapOf("foo" to JsonPrimitive(1), "bar" to Json.encodeToJsonElement("two")))), x)
}
}