package structure/import for CLI
This commit is contained in:
parent
b6c6ef021a
commit
ef95ed4405
@ -4,6 +4,7 @@ The Lyng CLI is the reference command-line tool for the Lyng language. It lets y
|
||||
|
||||
- Run Lyng scripts from files or inline strings (shebangs accepted)
|
||||
- Use standard argument passing (`ARGV`) to your scripts.
|
||||
- Resolve local file imports from the executed script's directory tree.
|
||||
- Format Lyng source files via the built-in `fmt` subcommand.
|
||||
|
||||
|
||||
@ -73,6 +74,7 @@ lyng -- -my-script.lyng arg1 arg2
|
||||
```
|
||||
|
||||
- Execute inline code with `-x/--execute` and pass positional args to `ARGV`:
|
||||
- Inline execution does not scan the filesystem for local modules; only file-based execution does.
|
||||
|
||||
```
|
||||
lyng -x "println(\"Hello\")" more args
|
||||
@ -85,6 +87,63 @@ lyng --version
|
||||
lyng --help
|
||||
```
|
||||
|
||||
##### Local imports for file execution
|
||||
|
||||
When you execute a script file, the CLI builds a temporary local import manager rooted at the directory that contains the entry script.
|
||||
|
||||
Formal structure:
|
||||
|
||||
- Root directory: the parent directory of the script passed to `lyng`.
|
||||
- Scan scope: every `.lyng` file under that root directory, recursively.
|
||||
- Entry script: the executed file itself is not registered as an importable module.
|
||||
- Module name mapping: `relative/path/to/file.lyng` maps to import name `relative.path.to.file`.
|
||||
- Package declaration: if a scanned file starts with `package ...` as its first non-blank line, that package name must exactly match the relative path mapping.
|
||||
- Package omission: if there is no leading `package` declaration, the CLI uses the relative path mapping as the module name.
|
||||
- Duplicates: if two files resolve to the same module name, CLI execution fails before script execution starts.
|
||||
- Import visibility: only files inside the entry root subtree are considered. Parent directories and sibling projects are not searched.
|
||||
|
||||
Examples:
|
||||
|
||||
```
|
||||
project/
|
||||
main.lyng
|
||||
util/answer.lyng
|
||||
math/add.lyng
|
||||
```
|
||||
|
||||
`util/answer.lyng` is imported as `import util.answer`.
|
||||
|
||||
`math/add.lyng` is imported as `import math.add`.
|
||||
|
||||
Example contents:
|
||||
|
||||
```lyng
|
||||
// util/answer.lyng
|
||||
package util.answer
|
||||
|
||||
import math.add
|
||||
|
||||
fun answer() = plus(40, 2)
|
||||
```
|
||||
|
||||
```lyng
|
||||
// math/add.lyng
|
||||
fun plus(a, b) = a + b
|
||||
```
|
||||
|
||||
```lyng
|
||||
// main.lyng
|
||||
import util.answer
|
||||
|
||||
println(answer())
|
||||
```
|
||||
|
||||
Rationale:
|
||||
|
||||
- The module name is deterministic from the filesystem layout.
|
||||
- Explicit `package` remains available as a consistency check instead of a second, conflicting naming system.
|
||||
- The import search space stays local to the executed script, which avoids accidental cross-project resolution.
|
||||
|
||||
### Use in shell scripts
|
||||
|
||||
Standard unix shebangs (`#!`) are supported, so you can make Lyng scripts directly executable on Unix-like systems. For example:
|
||||
|
||||
@ -31,6 +31,8 @@ import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.sergeych.lyng.EvalSession
|
||||
import net.sergeych.lyng.LyngVersion
|
||||
import net.sergeych.lyng.Pos
|
||||
import net.sergeych.lyng.Scope
|
||||
import net.sergeych.lyng.Script
|
||||
import net.sergeych.lyng.ScriptError
|
||||
import net.sergeych.lyng.Source
|
||||
@ -40,17 +42,15 @@ import net.sergeych.lyng.io.http.createHttpModule
|
||||
import net.sergeych.lyng.io.net.createNetModule
|
||||
import net.sergeych.lyng.io.ws.createWsModule
|
||||
import net.sergeych.lyng.obj.*
|
||||
import net.sergeych.lyng.pacman.ImportManager
|
||||
import net.sergeych.lyngio.console.security.PermitAllConsoleAccessPolicy
|
||||
import net.sergeych.lyngio.fs.security.PermitAllAccessPolicy
|
||||
import net.sergeych.lyngio.http.security.PermitAllHttpAccessPolicy
|
||||
import net.sergeych.lyngio.net.security.PermitAllNetAccessPolicy
|
||||
import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy
|
||||
import net.sergeych.mp_tools.globalDefer
|
||||
import okio.FileSystem
|
||||
import okio.*
|
||||
import okio.Path.Companion.toPath
|
||||
import okio.SYSTEM
|
||||
import okio.buffer
|
||||
import okio.use
|
||||
|
||||
// common code
|
||||
|
||||
@ -72,21 +72,157 @@ data class CommandResult(
|
||||
|
||||
val baseScopeDefer = globalDefer {
|
||||
Script.newScope().apply {
|
||||
installCliBuiltins()
|
||||
installCliModules(importManager)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Scope.installCliBuiltins() {
|
||||
addFn("exit") {
|
||||
exit(requireOnlyArg<ObjInt>().toInt())
|
||||
ObjVoid
|
||||
}
|
||||
// Install lyng.io.fs module with full access by default for the CLI tool's Scope.
|
||||
// Scripts still need to `import lyng.io.fs` to use Path API.
|
||||
createFs(PermitAllAccessPolicy, this)
|
||||
// Install console access by default for interactive CLI scripts.
|
||||
// Scripts still need to `import lyng.io.console` to use it.
|
||||
createConsoleModule(PermitAllConsoleAccessPolicy, this)
|
||||
// Install network-oriented lyngio modules for CLI scripts.
|
||||
}
|
||||
|
||||
private fun installCliModules(manager: ImportManager) {
|
||||
// Scripts still need to import the modules they use explicitly.
|
||||
createHttpModule(PermitAllHttpAccessPolicy, this)
|
||||
createWsModule(PermitAllWsAccessPolicy, this)
|
||||
createNetModule(PermitAllNetAccessPolicy, this)
|
||||
createFs(PermitAllAccessPolicy, manager)
|
||||
createConsoleModule(PermitAllConsoleAccessPolicy, manager)
|
||||
createHttpModule(PermitAllHttpAccessPolicy, manager)
|
||||
createWsModule(PermitAllWsAccessPolicy, manager)
|
||||
createNetModule(PermitAllNetAccessPolicy, manager)
|
||||
}
|
||||
|
||||
private data class LocalCliModule(
|
||||
val packageName: String,
|
||||
val source: Source
|
||||
)
|
||||
|
||||
private fun readUtf8(path: Path): String =
|
||||
FileSystem.SYSTEM.source(path).use { fileSource ->
|
||||
fileSource.buffer().use { bs ->
|
||||
bs.readUtf8()
|
||||
}
|
||||
}
|
||||
|
||||
private fun stripShebang(text: String): String {
|
||||
if (!text.startsWith("#!")) return text
|
||||
val pos = text.indexOf('\n')
|
||||
return if (pos >= 0) text.substring(pos + 1) else ""
|
||||
}
|
||||
|
||||
private fun extractDeclaredPackageNameOrNull(source: Source): String? {
|
||||
for (line in source.lines) {
|
||||
if (line.isBlank()) continue
|
||||
return if (line.startsWith("package ")) {
|
||||
line.substring(8).trim()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun canonicalPath(path: Path): Path = FileSystem.SYSTEM.canonicalize(path)
|
||||
|
||||
private fun relativeModuleName(rootDir: Path, file: Path): String {
|
||||
val rootText = rootDir.toString().trimEnd('/', '\\')
|
||||
val fileText = file.toString()
|
||||
val prefix = "$rootText/"
|
||||
if (!fileText.startsWith(prefix)) {
|
||||
throw ScriptError(Pos.builtIn, "local import root mismatch: $fileText is not under $rootText")
|
||||
}
|
||||
val relative = fileText.removePrefix(prefix)
|
||||
val modulePath = relative.removeSuffix(".lyng")
|
||||
return modulePath
|
||||
.split('/', '\\')
|
||||
.filter { it.isNotEmpty() }
|
||||
.joinToString(".")
|
||||
}
|
||||
|
||||
private fun scanLyngFiles(rootDir: Path): List<Path> {
|
||||
val system = FileSystem.SYSTEM
|
||||
val pending = ArrayDeque<Path>()
|
||||
val visited = linkedSetOf<String>()
|
||||
val files = mutableListOf<Path>()
|
||||
pending.add(rootDir)
|
||||
while (pending.isNotEmpty()) {
|
||||
val dir = pending.removeLast()
|
||||
val canonicalDir = canonicalPath(dir)
|
||||
if (!visited.add(canonicalDir.toString())) continue
|
||||
val children = try {
|
||||
system.list(canonicalDir)
|
||||
} catch (_: Exception) {
|
||||
continue
|
||||
}
|
||||
for (child in children) {
|
||||
val meta = try {
|
||||
system.metadata(child)
|
||||
} catch (_: Exception) {
|
||||
continue
|
||||
}
|
||||
when {
|
||||
meta.isDirectory -> pending.add(child)
|
||||
child.name.endsWith(".lyng") -> {
|
||||
val canonicalFile = try {
|
||||
canonicalPath(child)
|
||||
} catch (_: Exception) {
|
||||
continue
|
||||
}
|
||||
files += canonicalFile
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return files
|
||||
}
|
||||
|
||||
private fun discoverLocalCliModules(entryFile: Path): List<LocalCliModule> {
|
||||
val rootDir = entryFile.parent ?: ".".toPath()
|
||||
val seenPackages = linkedMapOf<String, Path>()
|
||||
return scanLyngFiles(rootDir)
|
||||
.asSequence()
|
||||
.filter { it != entryFile }
|
||||
.map { file ->
|
||||
val text = stripShebang(readUtf8(file))
|
||||
val source = Source(file.toString(), text)
|
||||
val expectedPackage = relativeModuleName(rootDir, file)
|
||||
val declaredPackage = extractDeclaredPackageNameOrNull(source)
|
||||
if (declaredPackage != null && declaredPackage != expectedPackage) {
|
||||
throw ScriptError(
|
||||
source.startPos,
|
||||
"local module package mismatch: expected '$expectedPackage' for ${file.toString()} but found '$declaredPackage'"
|
||||
)
|
||||
}
|
||||
val packageName = declaredPackage ?: expectedPackage
|
||||
val previous = seenPackages.putIfAbsent(packageName, file)
|
||||
if (previous != null) {
|
||||
throw ScriptError(
|
||||
source.startPos,
|
||||
"duplicate local module '$packageName': ${previous.toString()} and ${file.toString()}"
|
||||
)
|
||||
}
|
||||
LocalCliModule(packageName, source)
|
||||
}
|
||||
.toList()
|
||||
}
|
||||
|
||||
private fun registerLocalCliModules(manager: ImportManager, entryFile: Path) {
|
||||
for (module in discoverLocalCliModules(entryFile)) {
|
||||
manager.addPackage(module.packageName) { scope ->
|
||||
scope.eval(module.source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun newCliScope(argv: List<String>, entryFileName: String? = null): Scope {
|
||||
val manager = baseScopeDefer.await().importManager.copy()
|
||||
if (entryFileName != null) {
|
||||
registerLocalCliModules(manager, canonicalPath(entryFileName.toPath()))
|
||||
}
|
||||
return manager.newStdScope().apply {
|
||||
installCliBuiltins()
|
||||
addConst("ARGV", ObjList(argv.map { ObjString(it) }.toMutableList()))
|
||||
}
|
||||
}
|
||||
|
||||
@ -200,7 +336,6 @@ private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand()
|
||||
if (currentContext.invokedSubcommand != null) return
|
||||
|
||||
runBlocking {
|
||||
val baseScope = baseScopeDefer.await()
|
||||
when {
|
||||
version -> {
|
||||
println("Lyng language version ${LyngVersion}")
|
||||
@ -210,15 +345,13 @@ private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand()
|
||||
val objargs = mutableListOf<String>()
|
||||
script?.let { objargs += it }
|
||||
objargs += args
|
||||
baseScope.addConst(
|
||||
"ARGV", ObjList(
|
||||
objargs.map { ObjString(it) }.toMutableList()
|
||||
)
|
||||
)
|
||||
launcher {
|
||||
// there is no script name, it is a first argument instead:
|
||||
processErrors {
|
||||
executeSource(Source("<eval>", execute!!))
|
||||
executeSource(
|
||||
Source("<eval>", execute!!),
|
||||
newCliScope(objargs)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -228,8 +361,7 @@ private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand()
|
||||
println("Error: no script specified.\n")
|
||||
echoFormattedHelp()
|
||||
} else {
|
||||
baseScope.addConst("ARGV", ObjList(args.map { ObjString(it) }.toMutableList()))
|
||||
launcher { executeFile(script!!) }
|
||||
launcher { executeFile(script!!, args) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -239,13 +371,12 @@ private class Lyng(val launcher: (suspend () -> Unit) -> Unit) : CliktCommand()
|
||||
|
||||
fun executeFileWithArgs(fileName: String, args: List<String>) {
|
||||
runBlocking {
|
||||
baseScopeDefer.await().addConst("ARGV", ObjList(args.map { ObjString(it) }.toMutableList()))
|
||||
executeFile(fileName)
|
||||
executeFile(fileName, args)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun executeSource(source: Source) {
|
||||
val session = EvalSession(baseScopeDefer.await())
|
||||
suspend fun executeSource(source: Source, initialScope: Scope? = null) {
|
||||
val session = EvalSession(initialScope ?: baseScopeDefer.await())
|
||||
try {
|
||||
evalOnCliDispatcher(session, source)
|
||||
} finally {
|
||||
@ -258,19 +389,14 @@ internal suspend fun evalOnCliDispatcher(session: EvalSession, source: Source):
|
||||
session.eval(source)
|
||||
}
|
||||
|
||||
suspend fun executeFile(fileName: String) {
|
||||
var text = FileSystem.SYSTEM.source(fileName.toPath()).use { fileSource ->
|
||||
fileSource.buffer().use { bs ->
|
||||
bs.readUtf8()
|
||||
}
|
||||
}
|
||||
if( text.startsWith("#!") ) {
|
||||
// skip shebang
|
||||
val pos = text.indexOf('\n')
|
||||
text = text.substring(pos + 1)
|
||||
}
|
||||
suspend fun executeFile(fileName: String, args: List<String> = emptyList()) {
|
||||
val canonicalFile = canonicalPath(fileName.toPath())
|
||||
val text = stripShebang(readUtf8(canonicalFile))
|
||||
processErrors {
|
||||
executeSource(Source(fileName, text))
|
||||
executeSource(
|
||||
Source(canonicalFile.toString(), text),
|
||||
newCliScope(args, canonicalFile.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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.
|
||||
*
|
||||
*/
|
||||
package net.sergeych.lyng_cli
|
||||
|
||||
import net.sergeych.jvmExitImpl
|
||||
import net.sergeych.runMain
|
||||
import org.junit.After
|
||||
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
|
||||
|
||||
class CliLocalImportsJvmTest {
|
||||
private val originalOut: PrintStream = System.out
|
||||
private val originalErr: PrintStream = System.err
|
||||
|
||||
private class TestExit(val code: Int) : RuntimeException()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
jvmExitImpl = { code -> throw TestExit(code) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
System.setOut(originalOut)
|
||||
System.setErr(originalErr)
|
||||
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 cliDiscoversSiblingAndNestedLocalImportsFromEntryRoot() {
|
||||
val dir = Files.createTempDirectory("lyng_cli_local_imports_")
|
||||
try {
|
||||
val mathDir = Files.createDirectories(dir.resolve("math"))
|
||||
val utilDir = Files.createDirectories(dir.resolve("util"))
|
||||
val mainFile = dir.resolve("main.lyng")
|
||||
Files.writeString(
|
||||
mathDir.resolve("add.lyng"),
|
||||
"""
|
||||
fun plus(a, b) = a + b
|
||||
""".trimIndent()
|
||||
)
|
||||
Files.writeString(
|
||||
utilDir.resolve("answer.lyng"),
|
||||
"""
|
||||
package util.answer
|
||||
|
||||
import math.add
|
||||
|
||||
fun answer() = plus(40, 2)
|
||||
""".trimIndent()
|
||||
)
|
||||
Files.writeString(
|
||||
mainFile,
|
||||
"""
|
||||
import util.answer
|
||||
|
||||
println(answer())
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val result = runCli(mainFile.toString())
|
||||
assertTrue(result.err, result.err.isBlank())
|
||||
assertTrue(result.out, result.out.contains("42"))
|
||||
} finally {
|
||||
dir.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cliRejectsPackageThatDoesNotMatchRelativePath() {
|
||||
val dir = Files.createTempDirectory("lyng_cli_local_imports_badpkg_")
|
||||
try {
|
||||
val utilDir = Files.createDirectories(dir.resolve("util"))
|
||||
val mainFile = dir.resolve("main.lyng")
|
||||
Files.writeString(
|
||||
utilDir.resolve("answer.lyng"),
|
||||
"""
|
||||
package util.wrong
|
||||
|
||||
fun answer() = 42
|
||||
""".trimIndent()
|
||||
)
|
||||
Files.writeString(
|
||||
mainFile,
|
||||
"""
|
||||
import util.answer
|
||||
|
||||
println(answer())
|
||||
""".trimIndent()
|
||||
)
|
||||
|
||||
val result = runCli(mainFile.toString())
|
||||
assertTrue(result.out, result.out.contains("local module package mismatch"))
|
||||
assertTrue(result.out, result.out.contains("expected 'util.answer'"))
|
||||
} finally {
|
||||
dir.toFile().deleteRecursively()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user