fix #78 add fmt CLI subcommand and improve legacy script execution paths
This commit is contained in:
parent
20f4e54a02
commit
b630d69186
24
CHANGELOG.md
24
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 `--- <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
1
lyng/${file}
Normal file
@ -0,0 +1 @@
|
||||
hello from cli test
|
||||
@ -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()))
|
||||
|
||||
@ -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)
|
||||
}
|
||||
134
lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliFmtJvmTest.kt
Normal file
134
lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliFmtJvmTest.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user