Add lyng.io.process module for process execution utilities and shell scripting sugar (e.g., sh, exec, CommandRun).

This commit is contained in:
sergeych committed 2026-05-10 15:04:32 +03:00
1 parent 3e74019d9d
commit 234d1ef02b
8 files changed
+611 -7

No files matched your search

@@ -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<ObjCommandRun>().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<ObjCommandRun>().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<ObjCommandRun>().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<ObjCommandRun>().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<ObjCommandRun>().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<ObjCommandRun>().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<ObjCommandRun>().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<ObjCommandRun>().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<ObjCommandRun>()
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<ObjString>().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<ObjString>(0).value
val rawArgs = if (args.list.size >= 2) requiredArg<ObjList>(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()
@@ -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<String> = []
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<ExecutionError> {
script.execute(scope)
}
Unit
}
}
+59
View File
@@ -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<String>
/* Standard error as a flow of process error lines. */
fun stderr(): Flow<String>
/* 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<String>
/* Streaming standard error lines. Treat the process stderr stream as one-shot. */
val errorLines: Flow<String>
/* 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<String>): 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<String,Object?>
/* 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<String> = []): CommandRun