mxsh 0.2.0

Embeddable POSIX-style shell parser and runtime
Documentation
#![cfg(all(feature = "embed", feature = "test-support", feature = "unix-runtime"))]

use std::io;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};

use mxsh::ShellBuilder;
use mxsh::advanced::{PlannedSimpleCommandKind, Planner};
use mxsh::ast::{AndOrList, Command, Pipeline, Program, SimpleCommand};
use mxsh::embed::{NamedFileDescriptor, StdioConfig};
use mxsh::policy::{StartupSources, VariableAttributes};
use mxsh::runtime::Runtime;
use mxsh::runtime::fd::FileDescriptor;
use mxsh::runtime::process::{
    ExternalCommand, ProcessEvent, ProcessHandle, RuntimeSignal, SpawnMode, SpawnStdio,
    SpawnedProcess, WaitMode,
};
use mxsh::runtime::testing::{DeterministicRuntime, InMemoryRuntime, StringStdioOut};

fn first_command(script: &str) -> Command {
    let program = Program::parse(script).expect("script should parse");
    let first = program.body().first().expect("script should not be empty");
    let AndOrList::Pipeline(pipeline) = first.and_or_list() else {
        panic!("expected a simple pipeline");
    };
    pipeline
        .commands()
        .first()
        .expect("pipeline should contain a command")
        .clone()
}

fn first_simple_command(script: &str) -> SimpleCommand {
    match first_command(script) {
        Command::Simple(command) => command,
        other => panic!("expected simple command, got {other:?}"),
    }
}

fn first_pipeline(script: &str) -> Pipeline {
    let program = Program::parse(script).expect("script should parse");
    let first = program.body().first().expect("script should not be empty");
    let AndOrList::Pipeline(pipeline) = first.and_or_list() else {
        panic!("expected a pipeline");
    };
    pipeline.clone()
}

#[derive(Clone, Default)]
struct ForkCountingRuntime {
    forks: Arc<AtomicUsize>,
}

impl ForkCountingRuntime {
    fn fork_count(&self) -> usize {
        self.forks.load(Ordering::SeqCst)
    }
}

impl Runtime for ForkCountingRuntime {
    type ForegroundGuard = ();

    fn fork(&self) -> Result<Self, io::Error> {
        self.forks.fetch_add(1, Ordering::SeqCst);
        Ok(self.clone())
    }

    fn spawn_external_command(
        &mut self,
        _command: &ExternalCommand,
        _stdio: SpawnStdio,
        _close_fds: &[FileDescriptor],
        _mode: SpawnMode,
    ) -> Result<SpawnedProcess, io::Error> {
        Err(io::Error::new(
            io::ErrorKind::Unsupported,
            "external spawning is not used in this test runtime",
        ))
    }

    fn wait_process(
        &mut self,
        _process: ProcessHandle,
        _mode: WaitMode,
    ) -> Result<ProcessEvent, io::Error> {
        Ok(ProcessEvent::Exited(0))
    }

    fn signal_process_group(
        &mut self,
        _process: ProcessHandle,
        _signal: RuntimeSignal,
    ) -> Result<(), io::Error> {
        Ok(())
    }

    fn claim_foreground(
        &mut self,
        _process: ProcessHandle,
        _tty: FileDescriptor,
    ) -> Result<Self::ForegroundGuard, io::Error> {
        Ok(())
    }

    fn release_foreground(&mut self, _guard: Self::ForegroundGuard) -> Result<(), io::Error> {
        Ok(())
    }

    fn exec_replace(
        &self,
        _program: &str,
        _argv: &[String],
        _env: &[(String, String)],
        _cwd: &Path,
    ) -> Result<(), io::Error> {
        Err(io::Error::new(
            io::ErrorKind::Unsupported,
            "exec replacement is not used in this test runtime",
        ))
    }
}

#[test]
fn one_config_creates_independent_sessions() {
    let blueprint = ShellBuilder::new()
        .register_builtin("remember", |ctx, args| {
            let seen = args.first().cloned().unwrap_or_default();
            ctx.env_set("SEEN", seen, VariableAttributes::empty());
            0
        })
        .env("BASE", "present", VariableAttributes::empty())
        .blueprint()
        .expect("blueprint should build");

    let mut left = blueprint.new_session();
    let mut right = blueprint.new_session();
    let mut runtime = InMemoryRuntime::new();

    assert_eq!(left.run(&mut runtime, "remember left; LEFT=1").status, 0);
    assert_eq!(right.run(&mut runtime, "remember right").status, 0);

    assert_eq!(left.env_get("BASE"), Some("present"));
    assert_eq!(right.env_get("BASE"), Some("present"));
    assert_eq!(left.env_get("LEFT"), Some("1"));
    assert_eq!(right.env_get("LEFT"), None);
    assert_eq!(left.env_get("SEEN"), Some("left"));
    assert_eq!(right.env_get("SEEN"), Some("right"));
}

#[test]
fn config_seeds_each_new_session() {
    let blueprint = ShellBuilder::new()
        .positional_parameters(["one", "two"])
        .shell_name("toysh")
        .alias("ll", "ls -l")
        .function("wave", first_command("{ echo wave; }"))
        .inherited_fd(7, FileDescriptor::STDERR)
        .named_inherited_fd(NamedFileDescriptor::new("trace", 9, FileDescriptor::STDOUT))
        .blueprint()
        .expect("blueprint should build");

    let mut left = blueprint.new_session();
    let right = blueprint.new_session();
    let mut runtime = InMemoryRuntime::new();

    assert_eq!(
        left.frame(),
        ["toysh".to_string(), "one".to_string(), "two".to_string()]
    );
    assert_eq!(left.alias("ll"), Some("ls -l"));
    assert!(matches!(
        left.function("wave"),
        Some(Command::BraceGroup(_))
    ));
    let left_fds = left.inherited_file_descriptors();
    assert!(left_fds.contains(&(7, FileDescriptor::STDERR)));
    assert!(left_fds.contains(&(9, FileDescriptor::STDOUT)));

    assert_eq!(left.run(&mut runtime, "shift").status, 0);

    assert_eq!(
        right.frame(),
        ["toysh".to_string(), "one".to_string(), "two".to_string()]
    );
    assert_eq!(right.alias("ll"), Some("ls -l"));
    assert!(matches!(
        right.function("wave"),
        Some(Command::BraceGroup(_))
    ));
    let right_fds = right.inherited_file_descriptors();
    assert!(right_fds.contains(&(7, FileDescriptor::STDERR)));
    assert!(right_fds.contains(&(9, FileDescriptor::STDOUT)));
}

#[test]
fn config_seeds_startup_sources_for_each_new_session() {
    let blueprint = ShellBuilder::new()
        .startup_sources(StartupSources::new().with_env_file_var("TOYSH_ENV"))
        .blueprint()
        .expect("blueprint should build");

    let left = blueprint.new_session();
    let right = blueprint.new_session();

    assert_eq!(left.startup_sources().env_file_var(), Some("TOYSH_ENV"));
    assert_eq!(right.startup_sources().env_file_var(), Some("TOYSH_ENV"));
}

#[test]
fn detached_session_checkpoint_uses_explicit_ownership_boundaries() {
    let stdout = StringStdioOut::new();
    let mut session = ShellBuilder::new()
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = DeterministicRuntime::new();
    runtime.push_spawn(Some(1234), [], [ProcessEvent::Exited(0)]);

    let setup = session.run(
        &mut runtime,
        "X=parent; alias hi='echo alias'; f(){ echo func; }; trap 'echo cleanup' EXIT; job &",
    );
    assert_eq!(setup.status, 0);
    assert!(session.last_background_pid().is_some());

    let checkpoint = session.detached_checkpoint();
    let mut fork = checkpoint.new_session();
    let forked = fork.run(
        &mut runtime,
        "echo fork-x=$X; hi; f; wait \"$!\" 2>/dev/null; echo fork-wait=$?; exit 0",
    );
    assert_eq!(forked.status, 0);
    assert_eq!(forked.exit_code, Some(0));

    let parent = session.run(&mut runtime, "wait \"$!\"; echo parent-wait=$?; exit 0");
    assert_eq!(parent.status, 0);
    assert_eq!(parent.exit_code, Some(0));
    assert_eq!(
        stdout.collect(),
        "fork-x=parent\nalias\nfunc\nfork-wait=127\nparent-wait=0\ncleanup\n"
    );
}

#[test]
fn config_language_policy_applies_to_each_fresh_session() {
    let blueprint = ShellBuilder::new()
        .alias("ll", "echo aliased")
        .alias_expansion_enabled(false)
        .blueprint()
        .expect("blueprint should build");
    let mut left = blueprint.new_session();
    let mut right = blueprint.new_session();
    let mut runtime = InMemoryRuntime::new();

    assert_eq!(left.run(&mut runtime, "ll").status, 127);
    assert_eq!(right.run(&mut runtime, "ll").status, 127);

    let fresh = blueprint.new_session();
    assert!(!fresh.language().alias_expansion_enabled());
    assert!(!left.language().alias_expansion_enabled());
    assert!(!right.language().alias_expansion_enabled());
}

#[test]
fn public_parse_resolve_plan_and_execute_pipeline_works() {
    let stdout = StringStdioOut::new();
    let mut session = ShellBuilder::default()
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();

    let command = first_simple_command("echo hello");
    let plan = Planner::new(&mut session, &mut runtime)
        .prepare_simple_command(&command)
        .expect("command should resolve");
    assert_eq!(plan.argv(), ["echo".to_string(), "hello".to_string()]);
    assert!(matches!(
        plan.kind(),
        PlannedSimpleCommandKind::Builtin { .. }
    ));

    let program = Program::parse("echo hello").expect("program should parse");
    let plan = Planner::new(&mut session, &mut runtime).prepare(&program);
    let result = Planner::new(&mut session, &mut runtime).execute_plan(&plan);

    assert_eq!(result.status, 0);
    assert_eq!(session.last_status(), 0);
    assert_eq!(stdout.collect(), "hello\n");
}

#[test]
fn public_pipeline_plan_and_execute_works() {
    let stdout = StringStdioOut::new();
    let mut session = ShellBuilder::default()
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    runtime.register_command("emit", |argv, _env, _cwd, stdio| {
        let _ = stdio.stdout_fd.write_line(&argv[1..].join(" "));
        0
    });
    runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
        let input = stdio
            .stdin_fd
            .dup()
            .expect("dup cat stdin")
            .read_to_string()
            .unwrap_or_default();
        let _ = stdio.stdout_fd.write_str(&input);
        0
    });

    let pipeline = first_pipeline("emit hello | cat");
    let plan = Planner::new(&mut session, &mut runtime).prepare_pipeline(&pipeline);
    let result = Planner::new(&mut session, &mut runtime).execute_pipeline(&plan);

    assert_eq!(result.status, 0);
    assert_eq!(session.last_status(), 0);
    assert_eq!(stdout.collect(), "hello\n");
}

#[test]
fn pipeline_stage_expansion_side_effects_do_not_leak_to_parent_state() {
    let stdout = StringStdioOut::new();
    let mut session = ShellBuilder::default()
        .stdio(StdioConfig {
            stdout: stdout.fd(),
            ..StdioConfig::default()
        })
        .new_session()
        .expect("session should build");
    let mut runtime = InMemoryRuntime::new();
    runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
        let input = stdio
            .stdin_fd
            .dup()
            .expect("dup cat stdin")
            .read_to_string()
            .unwrap_or_default();
        let _ = stdio.stdout_fd.write_str(&input);
        0
    });

    let result = session.run(
        &mut runtime,
        "unset X; echo ${X:=foo} | cat; printf '<%s>\\n' \"$X\"",
    );

    assert_eq!(result.status, 0);
    assert_eq!(session.env_get("X"), None);
    assert_eq!(stdout.collect(), "foo\n<>\n");
}

#[test]
fn command_substitution_and_subshell_use_runtime_forking() {
    let mut session = ShellBuilder::default()
        .new_session()
        .expect("session should build");
    let mut runtime = ForkCountingRuntime::default();

    let result = session.run(&mut runtime, "VALUE=$(echo hi); (:)");

    assert_eq!(result.status, 0);
    assert_eq!(session.env_get("VALUE"), Some("hi"));
    assert_eq!(runtime.fork_count(), 2);
}