package structure/import for CLI

This commit is contained in:
Sergey Chernov 2026-04-08 22:38:34 +03:00
parent b6c6ef021a
commit ef95ed4405
3 changed files with 365 additions and 43 deletions

View File

@ -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:

View File

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

View File

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