diff --git a/docs/lyng_cli.md b/docs/lyng_cli.md index 5cee5b0..d601526 100644 --- a/docs/lyng_cli.md +++ b/docs/lyng_cli.md @@ -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: diff --git a/lyng/src/commonMain/kotlin/Common.kt b/lyng/src/commonMain/kotlin/Common.kt index 19e9268..1e4235a 100644 --- a/lyng/src/commonMain/kotlin/Common.kt +++ b/lyng/src/commonMain/kotlin/Common.kt @@ -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 { - addFn("exit") { - exit(requireOnlyArg().toInt()) - ObjVoid + installCliBuiltins() + installCliModules(importManager) + } +} + +private fun Scope.installCliBuiltins() { + addFn("exit") { + exit(requireOnlyArg().toInt()) + ObjVoid + } +} + +private fun installCliModules(manager: ImportManager) { + // Scripts still need to import the modules they use explicitly. + 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() } - // 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. - // Scripts still need to import the modules they use explicitly. - createHttpModule(PermitAllHttpAccessPolicy, this) - createWsModule(PermitAllWsAccessPolicy, this) - createNetModule(PermitAllNetAccessPolicy, this) + } + +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 { + val system = FileSystem.SYSTEM + val pending = ArrayDeque() + val visited = linkedSetOf() + val files = mutableListOf() + 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 { + val rootDir = entryFile.parent ?: ".".toPath() + val seenPackages = linkedMapOf() + 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, 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() 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("", execute!!)) + executeSource( + Source("", 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) { 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 = 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()) + ) } } diff --git a/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliLocalImportsJvmTest.kt b/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliLocalImportsJvmTest.kt new file mode 100644 index 0000000..6f37af6 --- /dev/null +++ b/lyng/src/jvmTest/kotlin/net/sergeych/lyng_cli/CliLocalImportsJvmTest.kt @@ -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() + } + } +}