diff --git a/docs/ai_stdlib_reference.md b/docs/ai_stdlib_reference.md index c19f6d3..9a79318 100644 --- a/docs/ai_stdlib_reference.md +++ b/docs/ai_stdlib_reference.md @@ -88,6 +88,10 @@ Sources: `lynglib/src/commonMain/kotlin/net/sergeych/lyng/Script.kt`, `lynglib/s Requires installing `lyngio` into the import manager from host code. - `import lyng.io.fs` (filesystem `Path` API) - `import lyng.io.process` (process execution API) + - Shell-script sugar: `sh(command): CommandRun` and `exec(executable, args=[]): CommandRun`. + - Prefer `sh("git status --short").out` for small shell output, `sh("...").lines` for large stdout streams, and `.check()` for commands that must exit with code 0. + - Prefer `exec("git", ["add", file])` when arguments come from data, filenames, or user input; it bypasses shell parsing. + - `CommandRun` is active and owns process pipes; choose one consumption path per stream (`out` or `lines`, `err` or `errorLines`). - `import lyng.io.console` (console capabilities, geometry, ANSI/output, events) - `import lyng.io.http` (HTTP/HTTPS client API) - `import lyng.io.http.server` (minimal HTTP/1.1 and WebSocket server API) diff --git a/docs/lyng.io.process.md b/docs/lyng.io.process.md index dd38f5c..a757b97 100644 --- a/docs/lyng.io.process.md +++ b/docs/lyng.io.process.md @@ -3,6 +3,7 @@ This module provides a way to run external processes and shell commands from Lyng scripts. It is designed to be multiplatform and uses coroutines for non-blocking execution. > **Note:** `lyngio` is a separate library module. It must be explicitly added as a dependency to your host application and initialized in your Lyng scopes. +> The reference `lyng`/`jlyng` CLI installs this module in its import manager, so CLI scripts can use `import lyng.io.process` directly. --- @@ -44,24 +45,241 @@ suspend fun bootstrapProcess() { --- -#### Using from Lyng scripts +#### Shell-script shorthand + +Most CLI scripts should start with the shorthand API: + +```lyng +import lyng.io.process + +val branch = sh("git branch --show-current").out.trim() +println("Branch: " + branch) +``` + +`sh(command)` starts `command` through the platform shell and returns an active `CommandRun`. +The process is already running when the handle is returned. The handle gives you both convenient capture properties and streaming line flows: + +```lyng +val run = sh("git status --short") + +println(run.out) // capture all stdout as String +println(run.err) // capture all stderr as String +println(run.code) // known after out/err/wait/check, otherwise null +println(run.ok) // true, false, or null +``` + +Use `.check()` when a non-zero exit code should fail the script: + +```lyng +sh("lyng fmt --check examples/*.lyng").check() +sh("git diff --check").check() +``` + +Use `.wait()` when the exit code is data and should not raise: + +```lyng +val code = sh("git diff --quiet").wait() +if (code == 0) { + println("clean") +} else { + println("changed") +} +``` + +Use `.lines` for output that may be large. It streams stdout instead of buffering the entire output: + +```lyng +for (file in sh("git ls-files").lines) { + if (file.endsWith(".lyng")) { + println(file) + } +} +``` + +You can combine it with normal iterable helpers: + +```lyng +val count = sh("git ls-files").lines.count { + it.endsWith(".lyng") +} + +println("Lyng files: " + count) +``` + +Use `exec(executable, args)` when arguments come from data, filenames, or user input: + +```lyng +val file = ARGV[0] as String +exec("git", ["add", file]).check() +``` + +`exec(...)` bypasses the shell, so arguments are passed as argv entries rather than parsed by shell quoting rules. + +```lyng +val status = exec("git", ["status", "--short"]).out +println(status) +``` + +--- + +#### Capture vs streaming + +`CommandRun` is active: it owns a running process. Choose the consumption style intentionally. + +- `.out` captures stdout into memory, waits for the process to finish, and also drains stderr concurrently. +- `.err` captures stderr into memory, waits for the process to finish, and also drains stdout concurrently. +- `.check()` captures both streams, waits, and fails if the exit code is non-zero. +- `.lines` streams stdout line by line and does not buffer the whole output. +- `.errorLines` streams stderr line by line and does not buffer the whole error output. +- `.wait()` only waits and returns the exit code. + +Treat a `CommandRun` as a one-shot stream owner. Pick one stdout consumption path (`out` or `lines`) and one stderr consumption path (`err` or `errorLines`). Do not collect the same stream twice; it is backed by the process pipe. + +For small output, capture is simplest: + +```lyng +val version = sh("git describe --tags --always").out.trim() +``` + +For large output, stream: + +```lyng +for (line in sh("find . -type f").lines) { + if (line.endsWith(".lyng")) { + println(line) + } +} +``` + +For commands that may write a lot to both stdout and stderr, consume both streams. One practical pattern is to collect stderr in a background task while processing stdout: + +```lyng +val r = sh("some-command-that-writes-a-lot") + +val errors: List = [] +val stderrTask = launch { + for (line in r.errorLines) { + errors.add(line) + } +} + +for (line in r.lines) { + println("OUT: " + line) +} + +val code = r.wait() +stderrTask.await() + +if (code != 0) { + println(errors.joinToString("\n")) + exit(code) +} +``` + +As with ordinary shell programming, avoid using `.out` for unbounded output. It is meant for command results that comfortably fit in memory. + +--- + +#### Common script patterns + +Capture one small value: + +```lyng +val currentCommit = exec("git", ["rev-parse", "--short", "HEAD"]).out.trim() +``` + +Run a command for its side effects and fail on error: + +```lyng +exec("mkdir", ["-p", "build/reports"]).check() +sh("lyng fmt --check src/**/*.lyng").check() +``` + +Filter a long output stream: + +```lyng +for (path in sh("git ls-files").lines) { + if (path.endsWith(".lyng")) { + println(path) + } +} +``` + +Branch on a command status: + +```lyng +if (exec("git", ["diff", "--quiet"]).wait() != 0) { + println("working tree has changes") +} +``` + +Keep user-controlled text out of the shell: + +```lyng +val file = ARGV[0] as String +exec("git", ["log", "--oneline", "--", file]).check() +``` + +--- + +#### `sh` vs `exec` + +Use `sh(...)` when you intentionally want shell syntax: + +```lyng +sh("git status --short | wc -l").out.trim() +sh("find . -name '*.lyng' | sort").check() +``` + +Use `exec(...)` when you already have structured arguments: + +```lyng +exec("git", ["commit", "-m", message]).check() +exec("cp", [sourcePath, targetPath]).check() +``` + +This distinction matters for safety. Shell commands are parsed by `/bin/sh -c` on Unix-like systems or the platform shell on supported platforms. If you interpolate untrusted text into a shell string, it can change the command being run. Prefer `exec(...)` for user input and filenames. + +--- + +#### Low-level process API + +The shorthand API is built on top of `Process.execute(...)` and `Process.shell(...)`. Use the low-level API when you need direct process control, including explicit signals or forceful termination: ```lyng import lyng.io.process // Execute a process with arguments val p = Process.execute("ls", ["-l", "/tmp"]) -for (line in p.stdout) { +for (line in p.stdout()) { println("OUT: " + line) } val exitCode = p.waitFor() println("Process exited with: " + exitCode) // Run a shell command -val sh = Process.shell("echo 'Hello from shell' | wc -w") -for (line in sh.stdout) { +val shellProcess = Process.shell("echo 'Hello from shell' | wc -w") +for (line in shellProcess.stdout()) { println("Word count: " + line.trim()) } +``` + +Signals and termination: + +```lyng +val server = Process.shell("python3 -m http.server 8080") +delay(1000) +server.signal("SIGTERM") +val code = server.waitFor() +println("server exited: " + code) +``` + +--- + +#### Platform information + +```lyng +import lyng.io.process // Platform information val details = Platform.details() @@ -77,15 +295,57 @@ if (Platform.isSupported()) { --- +#### Executable script example + +```lyng +#!/usr/bin/env lyng + +import lyng.io.process + +if (ARGV.size == 0) { + println("usage: changed-files.lyng ") + exit(2) +} + +val ext = ARGV[0] as String +var matches = 0 + +for (file in sh("git status --short").lines) { + if (file.endsWith(ext)) { + println(file) + matches++ + } +} + +println("matched: " + matches) +``` + +--- + #### API Reference ##### `Process` (static methods) - `execute(executable: String, args: List): RunningProcess` — Start an external process. - `shell(command: String): RunningProcess` — Run a command through the system shell (e.g., `/bin/sh` or `cmd.exe`). +##### Shorthand functions +- `sh(command: String): CommandRun` — Run a command through the system shell and return an active handle. +- `exec(executable: String, args: List = []): CommandRun` — Run an executable with argv-style arguments and return an active handle. + +##### `CommandRun` +- `command: String` — Original command display text. +- `lines: Flow` — Streaming stdout lines. Use for large output. +- `errorLines: Flow` — Streaming stderr lines. Use for large error output. +- `out: String` — Captured stdout. Captures both stdout and stderr concurrently and waits for completion. +- `err: String` — Captured stderr. Captures both stdout and stderr concurrently and waits for completion. +- `wait(): Int` — Wait for the command to exit and return the exit code. +- `check(): CommandRun` — Capture output, wait, and fail if the exit code is non-zero. +- `code: Int?` — Known exit code after `wait`, `check`, `out`, or `err`; otherwise `null`. +- `ok: Bool?` — `true` for exit code 0, `false` for non-zero, or `null` before the exit code is known. + ##### `RunningProcess` (instance methods) -- `stdout: Flow` — Standard output stream as a Lyng Flow of lines. -- `stderr: Flow` — Standard error stream as a Lyng Flow of lines. +- `stdout(): Flow` — Standard output stream as a Lyng Flow of lines. +- `stderr(): Flow` — Standard error stream as a Lyng Flow of lines. - `waitFor(): Int` — Wait for the process to exit and return the exit code. - `signal(name: String)` — Send a signal to the process (e.g., `"SIGINT"`, `"SIGTERM"`, `"SIGKILL"`). - `destroy()` — Forcefully terminate the process. @@ -110,6 +370,7 @@ Example of a restricted policy in Kotlin: ```kotlin import net.sergeych.lyngio.fs.security.AccessDecision +import net.sergeych.lyngio.fs.security.AccessContext import net.sergeych.lyngio.fs.security.Decision import net.sergeych.lyngio.process.security.ProcessAccessOp import net.sergeych.lyngio.process.security.ProcessAccessPolicy diff --git a/docs/lyng_cli.md b/docs/lyng_cli.md index ba87054..02769ee 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. +- Import `lyng.io.process` for shell-script style process execution (`sh`, `exec`, and `CommandRun`). - Resolve local file imports from the executed script's directory tree. - Format Lyng source files via the built-in `fmt` subcommand. - Register synchronous process-exit handlers with `atExit(...)`. @@ -81,6 +82,15 @@ lyng -- -my-script.lyng arg1 arg2 lyng -x "println(\"Hello\")" more args ``` +The CLI installs `lyng.io.process`, so scripts can use the shell/process shorthand after importing it: + +```lyng +import lyng.io.process + +val branch = sh("git branch --show-current").out.trim() +exec("git", ["status", "--short"]).check() +``` + - Print version/help: ``` diff --git a/docs/lyngio.md b/docs/lyngio.md index 70f6084..29eb628 100644 --- a/docs/lyngio.md +++ b/docs/lyngio.md @@ -14,7 +14,7 @@ - **[lyng.io.db](lyng.io.db.md):** Portable SQL database access. Provides `Database`, `SqlTransaction`, `ResultSet`, SQLite support through `lyng.io.db.sqlite`, and JVM JDBC support through `lyng.io.db.jdbc`. - **[lyng.io.fs](lyng.io.fs.md):** Async filesystem access. Provides the `Path` class for file/directory operations, streaming, and globbing. -- **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides `Process`, `RunningProcess`, and `Platform` information. +- **[lyng.io.process](lyng.io.process.md):** External process execution and shell commands. Provides shell-script sugar (`sh`, `exec`, `CommandRun`), low-level `Process`/`RunningProcess`, and `Platform` information. - **[lyng.io.console](lyng.io.console.md):** Rich console/TTY access. Provides `Console` capability detection, geometry, output, and iterable events. - **[lyng.io.http](lyng.io.http.md):** HTTP/HTTPS client access. Provides `Http`, `HttpRequest`, `HttpResponse`, and `HttpHeaders`. - **[lyng.io.http.server](lyng.io.http.server.md):** Minimal HTTP/1.1 and WebSocket server. Provides `HttpServer`, `Router`, `ServerRequest`, `RequestContext`, and `ServerWebSocket`. diff --git a/lyng/src/commonMain/kotlin/Common.kt b/lyng/src/commonMain/kotlin/Common.kt index 7067e4a..46bd029 100644 --- a/lyng/src/commonMain/kotlin/Common.kt +++ b/lyng/src/commonMain/kotlin/Common.kt @@ -49,6 +49,7 @@ import net.sergeych.lyng.io.html.createHtmlModule import net.sergeych.lyng.io.http.createHttpModule import net.sergeych.lyng.io.http.server.createHttpServerModule import net.sergeych.lyng.io.net.createNetModule +import net.sergeych.lyng.io.process.createProcessModule import net.sergeych.lyng.io.ws.createWsModule import net.sergeych.lyng.obj.* import net.sergeych.lyng.pacman.ImportManager @@ -57,6 +58,7 @@ 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.net.shutdownSystemNetEngine +import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy import net.sergeych.lyngio.ws.security.PermitAllWsAccessPolicy import net.sergeych.mp_tools.globalDefer import okio.* @@ -150,6 +152,7 @@ private fun ImportManager.invalidateCliModuleCaches() { invalidatePackageCache("lyng.io.html") invalidatePackageCache("lyng.io.http") invalidatePackageCache("lyng.io.http.server") + invalidatePackageCache("lyng.io.process") invalidatePackageCache("lyng.io.ws") invalidatePackageCache("lyng.io.net") } @@ -242,6 +245,7 @@ private fun installCliModules(manager: ImportManager) { createHtmlModule(manager) createHttpModule(PermitAllHttpAccessPolicy, manager) createHttpServerModule(PermitAllNetAccessPolicy, manager) + createProcessModule(PermitAllProcessAccessPolicy, manager) createWsModule(PermitAllWsAccessPolicy, manager) createNetModule(PermitAllNetAccessPolicy, manager) } diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/process/LyngProcessModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/process/LyngProcessModule.kt index c559536..e2ac380 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/process/LyngProcessModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/process/LyngProcessModule.kt @@ -17,10 +17,16 @@ package net.sergeych.lyng.io.process +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.Scope import net.sergeych.lyng.ScopeFacade +import net.sergeych.lyng.Source import net.sergeych.lyng.miniast.* import net.sergeych.lyng.obj.* import net.sergeych.lyng.pacman.ImportManager @@ -28,6 +34,24 @@ import net.sergeych.lyng.requireScope import net.sergeych.lyngio.process.* import net.sergeych.lyngio.process.security.ProcessAccessDeniedException import net.sergeych.lyngio.process.security.ProcessAccessPolicy +import net.sergeych.lyngio.stdlib_included.processLyng +import kotlin.Boolean +import kotlin.Exception +import kotlin.Int +import kotlin.String +import kotlin.also +import kotlin.apply +import kotlin.collections.joinToString +import kotlin.collections.listOf +import kotlin.collections.map +import kotlin.collections.mutableListOf +import kotlin.collections.mutableMapOf +import kotlin.collections.toTypedArray +import kotlin.let +import kotlin.takeIf +import kotlin.text.isNotBlank +import kotlin.text.uppercase +import kotlin.to /** * Install Lyng module `lyng.io.process` into the given scope's ImportManager. @@ -47,6 +71,8 @@ fun createProcessModule(policy: ProcessAccessPolicy, manager: ImportManager): Bo } private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAccessPolicy) { + module.eval(Source("lyng.io.process", processLyng)) + val runner = try { SecuredLyngProcessRunner(getSystemProcessRunner(), policy) } catch (e: Exception) { @@ -54,6 +80,7 @@ private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAcces } val runningProcessType = object : ObjClass("RunningProcess") {} + val commandRunType = object : ObjClass("CommandRun") {} runningProcessType.apply { addFnDoc( @@ -149,6 +176,87 @@ private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAcces } } + commandRunType.apply { + addPropertyDoc( + name = "command", + doc = "Original shell command or argv-style command display text.", + type = type("lyng.String"), + moduleName = module.packageName, + getter = { ObjString(thisAs().command) } + ) + addPropertyDoc( + name = "out", + doc = "Captured standard output as a string. Captures both stdout and stderr concurrently.", + type = type("lyng.String"), + moduleName = module.packageName, + getter = { ObjString(thisAs().captureAll().stdoutText) } + ) + addPropertyDoc( + name = "err", + doc = "Captured standard error as a string. Captures both stdout and stderr concurrently.", + type = type("lyng.String"), + moduleName = module.packageName, + getter = { ObjString(thisAs().captureAll().stderrText) } + ) + addPropertyDoc( + name = "lines", + doc = "Streaming standard output lines. Use this for large output instead of `out`.", + type = type("lyng.Flow"), + moduleName = module.packageName, + getter = { thisAs().process.stdout.toLyngFlow(this) } + ) + addPropertyDoc( + name = "errorLines", + doc = "Streaming standard error lines. Use this for large stderr output instead of `err`.", + type = type("lyng.Flow"), + moduleName = module.packageName, + getter = { thisAs().process.stderr.toLyngFlow(this) } + ) + addPropertyDoc( + name = "code", + doc = "Exit code after `wait`, `check`, `out`, or `err`; otherwise null.", + type = type("lyng.Int?"), + moduleName = module.packageName, + getter = { + val code = thisAs().knownExitCode() + code?.toObj() ?: ObjNull + } + ) + addPropertyDoc( + name = "ok", + doc = "True if the known exit code is zero, false if non-zero, or null before the process is known to have exited.", + type = type("lyng.Bool?"), + moduleName = module.packageName, + getter = { + val code = thisAs().knownExitCode() + code?.let { (it == 0).toObj() } ?: ObjNull + } + ) + addFnDoc( + name = "wait", + doc = "Wait for the process to exit and return its exit code.", + returns = type("lyng.Int"), + moduleName = module.packageName + ) { + thisAs().waitFor().toObj() + } + addFnDoc( + name = "check", + doc = "Capture output, wait for completion, and fail if the exit code is non-zero.", + returns = type("CommandRun"), + moduleName = module.packageName + ) { + val command = thisAs() + val captured = command.captureAll() + if (captured.exitCode != 0) { + val detail = captured.stderrText.takeIf { it.isNotBlank() } ?: captured.stdoutText + val suffix = detail.takeIf { it.isNotBlank() }?.let { ": $it" } ?: "" + raiseError("command failed with exit code ${captured.exitCode}: ${command.command}$suffix") + } + command + } + } + val platformType = object : ObjClass("Platform") {} platformType.apply { @@ -197,6 +305,45 @@ private suspend fun buildProcessModule(module: ModuleScope, policy: ProcessAcces type = type("RunningProcess"), moduleName = module.packageName ) + module.addConstDoc( + name = "CommandRun", + value = commandRunType, + doc = "Shell-script friendly handle for a running command.", + type = type("CommandRun"), + moduleName = module.packageName + ) + + module.addFnDoc( + "sh", + doc = "Run a command via the system shell and return an active command handle.", + params = listOf(ParamDoc("command", type("lyng.String"))), + returns = type("CommandRun"), + moduleName = module.packageName + ) { + if (runner == null) raiseError("Processes are not supported on this platform") + processGuard { + val command = requireOnlyArg().value + ObjCommandRun(commandRunType, command, runner.shell(command)) + } + } + module.addFnDoc( + "exec", + doc = "Run an executable with argv-style arguments and return an active command handle.", + params = listOf(ParamDoc("executable", type("lyng.String")), ParamDoc("args", type("lyng.List"))), + returns = type("CommandRun"), + moduleName = module.packageName + ) { + if (runner == null) raiseError("Processes are not supported on this platform") + processGuard { + if (args.list.size > 2) { + raiseError("Expected at most 2 arguments, got ${args.list.size}") + } + val executable = requiredArg(0).value + val rawArgs = if (args.list.size >= 2) requiredArg(1) else ObjList(mutableListOf()) + val argv = rawArgs.list.map { (it as? ObjString)?.value ?: it.toString() } + ObjCommandRun(commandRunType, listOf(executable, *argv.toTypedArray()).joinToString(" "), runner.execute(executable, argv)) + } + } } class ObjRunningProcess( @@ -206,6 +353,49 @@ class ObjRunningProcess( override fun toString(): String = "RunningProcess($process)" } +private data class CapturedCommandOutput( + val exitCode: Int, + val stdoutText: String, + val stderrText: String +) + +private class ObjCommandRun( + override val objClass: ObjClass, + val command: String, + val process: LyngProcess +) : Obj() { + private val captureMutex = Mutex() + private var captured: CapturedCommandOutput? = null + private var exitCode: Int? = null + + fun knownExitCode(): Int? = captured?.exitCode ?: exitCode + + suspend fun waitFor(): Int { + captured?.let { return it.exitCode } + exitCode?.let { return it } + return process.waitFor().also { exitCode = it } + } + + suspend fun captureAll(): CapturedCommandOutput = captureMutex.withLock { + captured?.let { return@withLock it } + coroutineScope { + val stdout = async { process.stdout.toList().joinToString("\n") } + val stderr = async { process.stderr.toList().joinToString("\n") } + val code = async { process.waitFor() } + CapturedCommandOutput( + exitCode = code.await(), + stdoutText = stdout.await(), + stderrText = stderr.await() + ).also { + captured = it + exitCode = it.exitCode + } + } + } + + override fun toString(): String = "CommandRun($command)" +} + private suspend inline fun ScopeFacade.processGuard(crossinline block: suspend () -> Obj): Obj { return try { block() diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/process/LyngProcessModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/process/LyngProcessModuleTest.kt index 979ecba..377f720 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/process/LyngProcessModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/process/LyngProcessModuleTest.kt @@ -19,9 +19,11 @@ package net.sergeych.lyng.io.process import kotlinx.coroutines.runBlocking import net.sergeych.lyng.Compiler +import net.sergeych.lyng.ExecutionError import net.sergeych.lyng.Script import net.sergeych.lyngio.process.security.PermitAllProcessAccessPolicy import kotlin.test.Test +import kotlin.test.assertFailsWith import kotlin.test.assertTrue class LyngProcessModuleTest { @@ -84,4 +86,78 @@ class LyngProcessModuleTest { val result = script.execute(scope) assertTrue(result.inspect(scope).contains("name"), "Result should contain 'name', but was: ${result.inspect(scope)}") } + + @Test + fun testShellSugarCapturesOutput() = runBlocking { + val scope = Script.newScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) + + val code = """ + import lyng.io.process + + val r = sh("echo shell-sugar") + assert(r.out.trim() == "shell-sugar") + assert(r.code == 0) + assert(r.ok == true) + true + """.trimIndent() + + val script = Compiler.compile(code) + val result = script.execute(scope) + assertTrue(result.inspect(scope).contains("true")) + } + + @Test + fun testShellSugarStreamsLines() = runBlocking { + val scope = Script.newScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) + + val code = """ + import lyng.io.process + + val lines: List = [] + for (line in sh("echo one && echo two").lines) { + lines.add(line) + } + lines.joinToString(",") + """.trimIndent() + + val script = Compiler.compile(code) + val result = script.execute(scope) + assertTrue(result.inspect(scope).contains("one,two")) + } + + @Test + fun testExecSugarCapturesOutput() = runBlocking { + val scope = Script.newScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) + + val code = """ + import lyng.io.process + + exec("echo", ["exec-sugar"]).out.trim() + """.trimIndent() + + val script = Compiler.compile(code) + val result = script.execute(scope) + assertTrue(result.inspect(scope).contains("exec-sugar")) + } + + @Test + fun testShellSugarCheckFailsOnNonZeroExit() = runBlocking { + val scope = Script.newScope() + createProcessModule(PermitAllProcessAccessPolicy, scope) + + val code = """ + import lyng.io.process + + sh("echo check-failed && exit 7").check() + """.trimIndent() + + val script = Compiler.compile(code) + assertFailsWith { + script.execute(scope) + } + Unit + } } diff --git a/lyngio/stdlib/lyng/io/process.lyng b/lyngio/stdlib/lyng/io/process.lyng new file mode 100644 index 0000000..1f15f73 --- /dev/null +++ b/lyngio/stdlib/lyng/io/process.lyng @@ -0,0 +1,59 @@ +package lyng.io.process + +/* Handle to a running process. This is the low-level process API. */ +extern class RunningProcess { + /* Standard output as a flow of process output lines. */ + fun stdout(): Flow + /* Standard error as a flow of process error lines. */ + fun stderr(): Flow + /* Send a signal such as "SIGINT", "SIGTERM", or "SIGKILL". */ + fun signal(signal: String): void + /* Wait for process completion and return its exit code. */ + fun waitFor(): Int + /* Forcefully terminate the process. */ + fun destroy(): void +} + +/* Shell-script friendly active handle for a running command. */ +extern class CommandRun { + /* Original shell command or argv-style command display text. */ + val command: String + /* Capture standard output as a string, wait for completion, and drain stderr concurrently. Use `lines` for large output. */ + val out: String + /* Capture standard error as a string, wait for completion, and drain stdout concurrently. Use `errorLines` for large output. */ + val err: String + /* Streaming standard output lines. Treat the process stdout stream as one-shot. */ + val lines: Flow + /* Streaming standard error lines. Treat the process stderr stream as one-shot. */ + val errorLines: Flow + /* Exit code after `wait`, `check`, `out`, or `err`; otherwise null. */ + val code: Int? + /* True for exit code 0, false for non-zero, or null before the exit code is known. */ + val ok: Bool? + /* Wait for the command to exit and return the exit code. */ + fun wait(): Int + /* Capture output, wait for completion, and fail if the exit code is non-zero. */ + fun check(): CommandRun +} + +/* Process execution and control. */ +extern object Process { + /* Execute a process with arguments without using a shell. */ + fun execute(executable: String, args: List): RunningProcess + /* Execute a command via the system shell. */ + fun shell(command: String): RunningProcess +} + +/* Platform information for process support. */ +extern object Platform { + /* Get platform core details. */ + fun details(): Map + /* Check if processes are supported on this platform. */ + fun isSupported(): Bool +} + +/* Run a command via the system shell and return an active command handle. */ +extern fun sh(command: String): CommandRun + +/* Run an executable with argv-style arguments and return an active command handle. */ +extern fun exec(executable: String, args: List = []): CommandRun