///|
/// Minimal shell executor for actrun.
/// Uses moonix @sh parser for AST, implements evaluation in-process.
/// No external bash/sh binary needed.
///|
pub struct ShellEnv {
vars : Map[String, String]
mut cwd : String
stdout_buf : Array[String]
stderr_buf : Array[String]
mut exit_code : Int
mut exit_called : Bool
// File write function — allows MemFs or real FS
write_file : (String, String, Bool) -> Bool // (path, content, append) -> ok
read_file : (String) -> String? // path -> content?
file_exists : (String) -> Bool
is_dir : (String) -> Bool
mkdir : (String) -> Bool
remove : (String) -> Bool
}
///|
pub fn new_shell_env(
vars : Map[String, String],
cwd? : String = "/",
write_file? : (String, String, Bool) -> Bool = fn(_, _, _) { false },
read_file? : (String) -> String? = fn(_) { None },
file_exists? : (String) -> Bool = fn(_) { false },
is_dir? : (String) -> Bool = fn(_) { false },
mkdir? : (String) -> Bool = fn(_) { false },
remove? : (String) -> Bool = fn(_) { false },
) -> ShellEnv {
{
vars,
cwd,
stdout_buf: [],
stderr_buf: [],
exit_code: 0,
exit_called: false,
write_file,
read_file,
mkdir,
remove,
file_exists,
is_dir,
}
}
///|
pub fn shell_exec(env : ShellEnv, script : String) -> Int {
let list = @sh.parse_list(script) catch {
err => {
env.stderr_buf.push("parse error: " + err.to_string() + "\n")
return 127
}
}
exec_command_list(env, list)
}
///|
pub fn shell_stdout(env : ShellEnv) -> String {
env.stdout_buf.join("")
}
///|
pub fn shell_stderr(env : ShellEnv) -> String {
env.stderr_buf.join("")
}
///|
#warnings("-deprecated")
fn shell_parse_int(value : StringView) -> Int raise {
@strconv.parse_int(value)
}
///|
fn exec_command_list(env : ShellEnv, list : @sh.CommandList) -> Int {
let mut code = exec_pipeline(env, list.first)
env.exit_code = code
for item in list.rest {
if env.exit_called {
break
}
let (op, pipeline) = item
match op {
@sh.Seq => {
code = exec_pipeline(env, pipeline)
env.exit_code = code
}
@sh.And =>
if code == 0 {
code = exec_pipeline(env, pipeline)
env.exit_code = code
}
@sh.Or =>
if code != 0 {
code = exec_pipeline(env, pipeline)
env.exit_code = code
}
@sh.Background => {
// Background not supported in embedded shell, run sequentially
code = exec_pipeline(env, pipeline)
env.exit_code = code
}
}
}
code
}
///|
fn exec_pipeline(env : ShellEnv, pipeline : @sh.Pipeline) -> Int {
if pipeline.commands.length() == 1 {
let code = exec_command(env, pipeline.commands[0])
return if pipeline.bang { if code == 0 { 1 } else { 0 } } else { code }
}
// Multi-command pipeline: capture stdout of each, feed to next
let mut pipe_data = ""
let mut code = 0
for cmd in pipeline.commands {
let saved_stdout = env.stdout_buf.copy()
env.stdout_buf.clear()
// TODO: feed pipe_data as stdin to next command
code = exec_command(env, cmd)
pipe_data = env.stdout_buf.join("")
env.stdout_buf.clear()
for s in saved_stdout {
env.stdout_buf.push(s)
}
}
// Output final pipe stage result
env.stdout_buf.push(pipe_data)
if pipeline.bang {
if code == 0 {
1
} else {
0
}
} else {
code
}
}
///|
fn exec_command(env : ShellEnv, cmd : @sh.Command) -> Int {
if env.exit_called {
return env.exit_code
}
match cmd {
@sh.Simple(simple) => exec_simple(env, simple)
@sh.List(list) => exec_command_list(env, list)
@sh.IfCmd(clause) => exec_if(env, clause)
@sh.WhileCmd(clause) => exec_while(env, clause)
@sh.ForCmd(clause) => exec_for(env, clause)
@sh.Subshell(list) | @sh.BraceGroup(list) => exec_command_list(env, list)
@sh.Pipeline(pipeline) => exec_pipeline(env, pipeline)
@sh.CaseCmd(_) | @sh.FuncDef(_) => 0
}
}
///|
fn exec_simple(env : ShellEnv, cmd : @sh.SimpleCommand) -> Int {
// Handle variable assignments
for assign in cmd.assigns {
let (name, word) = assign
env.vars[name] = expand_word(env, word)
}
if cmd.words.is_empty() {
return 0
}
let expanded : Array[String] = []
for word in cmd.words {
expanded.push(expand_word(env, word))
}
let name = expanded[0]
let args = shell_array_slice(expanded, 1)
// Handle redirects: capture output path for > and >>
let mut redirect_path = ""
let mut redirect_append = false
for redir in cmd.redirects {
match redir.kind {
@sh.Output => {
redirect_path = expand_word(env, redir.target)
redirect_append = false
}
@sh.Append => {
redirect_path = expand_word(env, redir.target)
redirect_append = true
}
_ => ()
}
}
let saved_stdout = if redirect_path.length() > 0 {
let saved = env.stdout_buf.copy()
env.stdout_buf.clear()
saved
} else {
[]
}
let code = exec_builtin(env, name, args)
if redirect_path.length() > 0 {
let output = env.stdout_buf.join("")
env.stdout_buf.clear()
for s in saved_stdout {
env.stdout_buf.push(s)
}
let resolved = resolve_path(env.cwd, redirect_path)
ignore((env.write_file)(resolved, output, redirect_append))
}
code
}
///|
fn exec_if(env : ShellEnv, clause : @sh.IfClause) -> Int {
if exec_command_list(env, clause.condition) == 0 {
return exec_command_list(env, clause.then_body)
}
for elif in clause.elif_parts {
let (cond, body) = elif
if exec_command_list(env, cond) == 0 {
return exec_command_list(env, body)
}
}
match clause.else_body {
Some(body) => exec_command_list(env, body)
None => 0
}
}
///|
fn exec_while(env : ShellEnv, clause : @sh.WhileClause) -> Int {
let mut code = 0
if clause.is_until {
while exec_command_list(env, clause.condition) != 0 && !env.exit_called {
code = exec_command_list(env, clause.body)
}
} else {
while exec_command_list(env, clause.condition) == 0 && !env.exit_called {
code = exec_command_list(env, clause.body)
}
}
code
}
///|
fn exec_for(env : ShellEnv, clause : @sh.ForClause) -> Int {
let words = match clause.words {
Some(ws) => {
let result : Array[String] = []
for w in ws {
result.push(expand_word(env, w))
}
result
}
None => [] // TODO: iterate over positional params
}
let mut code = 0
for word in words {
if env.exit_called {
break
}
env.vars[clause.varname] = word
code = exec_command_list(env, clause.body)
}
code
}
///|
fn exec_builtin(env : ShellEnv, name : String, args : Array[String]) -> Int {
match name {
"echo" => builtin_echo(env, args)
"printf" => builtin_printf(env, args)
"cat" => builtin_cat(env, args)
"mkdir" => builtin_mkdir(env, args)
"cd" => builtin_cd(env, args)
"pwd" => builtin_pwd(env)
"test" | "[" => builtin_test(env, args)
"true" => 0
"false" => 1
"exit" => {
env.exit_called = true
env.exit_code = if args.length() > 0 {
shell_parse_int(args[0]) catch {
_ => 1
}
} else {
env.exit_code
}
env.exit_code
}
"export" => {
for arg in args {
match shell_find_char(arg, '=') {
Some(eq) => {
let key = shell_text_slice(arg, 0, eq)
let val = shell_text_slice(arg, eq + 1, arg.length())
env.vars[key] = val
}
None => () // export existing var
}
}
0
}
"set" => 0 // TODO: handle -e, -o pipefail
":" => 0 // no-op
"rm" => builtin_rm(env, args)
"cp" => builtin_cp(env, args)
"chmod" => 0 // no-op in embedded shell (no permission model)
_ => {
env.stderr_buf.push(name + ": command not found\n")
127
}
}
}
///|
fn builtin_echo(env : ShellEnv, args : Array[String]) -> Int {
let mut newline = true
let mut start = 0
if args.length() > 0 && args[0] == "-n" {
newline = false
start = 1
}
let parts : Array[String] = []
for i in start.. Int {
if args.length() == 0 {
return 0
}
let fmt = args[0]
if args.length() == 1 {
env.stdout_buf.push(shell_printf_expand(fmt, []))
} else {
env.stdout_buf.push(shell_printf_expand(fmt, shell_array_slice(args, 1)))
}
0
}
///|
fn shell_printf_expand(fmt : String, args : Array[String]) -> String {
let buf = StringBuilder::new()
let mut idx = 0
let mut arg_idx = 0
while idx < fmt.length() {
let ch = shell_char_at(fmt, idx)
if ch == '%' && idx + 1 < fmt.length() {
let next = shell_char_at(fmt, idx + 1)
match next {
's' => {
buf.write_string(
if arg_idx < args.length() {
args[arg_idx]
} else {
""
},
)
arg_idx += 1
idx += 2
}
'd' => {
buf.write_string(
if arg_idx < args.length() {
args[arg_idx]
} else {
"0"
},
)
arg_idx += 1
idx += 2
}
'%' => {
buf.write_char('%')
idx += 2
}
_ => {
buf.write_char('%')
idx += 1
}
}
} else if ch == '\\' && idx + 1 < fmt.length() {
let next = shell_char_at(fmt, idx + 1)
match next {
'n' => {
buf.write_char('\n')
idx += 2
}
't' => {
buf.write_char('\t')
idx += 2
}
'\\' => {
buf.write_char('\\')
idx += 2
}
_ => {
buf.write_char('\\')
idx += 1
}
}
} else {
buf.write_char(ch)
idx += 1
}
}
buf.to_string()
}
///|
fn builtin_cat(env : ShellEnv, args : Array[String]) -> Int {
for arg in args {
let path = resolve_path(env.cwd, arg)
match (env.read_file)(path) {
Some(content) => env.stdout_buf.push(content)
None => {
env.stderr_buf.push("cat: " + arg + ": No such file or directory\n")
return 1
}
}
}
0
}
///|
fn builtin_mkdir(env : ShellEnv, args : Array[String]) -> Int {
for arg in args {
if arg == "-p" {
continue
}
let path = resolve_path(env.cwd, arg)
ignore((env.mkdir)(path))
}
0
}
///|
fn builtin_cd(env : ShellEnv, args : Array[String]) -> Int {
if args.length() == 0 {
return 0
}
let target = resolve_path(env.cwd, args[0])
if (env.is_dir)(target) {
env.cwd = target
0
} else {
env.stderr_buf.push("cd: " + args[0] + ": No such file or directory\n")
1
}
}
///|
fn builtin_pwd(env : ShellEnv) -> Int {
env.stdout_buf.push(env.cwd + "\n")
0
}
///|
fn builtin_rm(env : ShellEnv, args : Array[String]) -> Int {
for arg in args {
if arg.has_prefix("-") {
continue
}
let path = resolve_path(env.cwd, arg)
ignore((env.remove)(path))
}
0
}
///|
fn builtin_cp(env : ShellEnv, args : Array[String]) -> Int {
let files : Array[String] = []
for arg in args {
if !arg.has_prefix("-") {
files.push(arg)
}
}
if files.length() < 2 {
return 1
}
let src = resolve_path(env.cwd, files[0])
let dst = resolve_path(env.cwd, files[1])
match (env.read_file)(src) {
Some(content) => {
ignore((env.write_file)(dst, content, false))
0
}
None => 1
}
}
///|
fn builtin_test(env : ShellEnv, args : Array[String]) -> Int {
ignore(env)
let args : Array[String] = if args.length() > 0 &&
args[args.length() - 1] == "]" {
let a : Array[String] = []
for i in 0..<(args.length() - 1) {
a.push(args[i])
}
a
} else {
args
}
if args.length() == 0 {
return 1
}
if args.length() == 1 {
// test STRING — true if non-empty
return if args[0].length() > 0 { 0 } else { 1 }
}
if args.length() == 2 {
match args[0] {
"-z" => return if args[1].length() == 0 { 0 } else { 1 }
"-n" => return if args[1].length() > 0 { 0 } else { 1 }
"!" =>
return if builtin_test(env, shell_array_slice(args, 1)) != 0 {
0
} else {
1
}
_ => return 1
}
}
if args.length() == 3 {
match args[1] {
"=" | "==" => return if args[0] == args[2] { 0 } else { 1 }
"!=" => return if args[0] != args[2] { 0 } else { 1 }
_ => return 1
}
}
1
}
///|
fn expand_word(env : ShellEnv, word : @sh.Word) -> String {
let buf = StringBuilder::new()
for part in word.parts {
match part {
@sh.Literal(s) => buf.write_string(s)
@sh.SingleQuoted(s) => buf.write_string(s)
@sh.DoubleQuoted(parts) =>
for p in parts {
match p {
@sh.Literal(s) => buf.write_string(s)
@sh.Variable(name) =>
buf.write_string(env.vars.get(name).unwrap_or(""))
@sh.SpecialVar(ch) =>
match ch {
'?' => buf.write_string(env.exit_code.to_string())
_ => ()
}
_ => ()
}
}
@sh.Variable(name) => buf.write_string(env.vars.get(name).unwrap_or(""))
@sh.SpecialVar(ch) =>
match ch {
'?' => buf.write_string(env.exit_code.to_string())
_ => ()
}
_ => ()
}
}
buf.to_string()
}
///|
fn resolve_path(cwd : String, path : String) -> String {
if path.has_prefix("/") {
path
} else if cwd == "/" {
"/" + path
} else {
cwd + "/" + path
}
}
///|
fn shell_find_char(s : String, target : Char) -> Int? {
let target_code = target.to_int().to_uint16()
let mut idx = 0
while idx < s.length() {
if s.unsafe_get(idx) == target_code {
return Some(idx)
}
idx += 1
}
None
}
///|
fn shell_text_slice(s : String, start : Int, end_ : Int) -> String {
String::unsafe_substring(s, start~, end=end_)
}
///|
fn shell_array_slice(arr : Array[String], start : Int) -> Array[String] {
let result : Array[String] = []
let mut idx = start
while idx < arr.length() {
result.push(arr[idx])
idx += 1
}
result
}
///|
fn shell_char_at(s : String, idx : Int) -> Char {
Int::unsafe_to_char(s.unsafe_get(idx).to_int())
}