mxsh 0.2.0

Embeddable POSIX-style shell parser and runtime
Documentation
#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
use std::io;

#[cfg(any(
    feature = "frontend",
    all(test, feature = "test-support", feature = "unix-runtime")
))]
use crate::args::ArgError;
#[cfg(all(test, feature = "test-support", feature = "unix-runtime"))]
use crate::args::process_args;
#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
use crate::parser::{ParseError, Parser};
#[cfg(any(
    feature = "frontend",
    all(test, feature = "test-support", feature = "unix-runtime")
))]
use crate::policy::ShellIdentity;
#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
use crate::sys::Runtime;

#[cfg(all(test, feature = "test-support"))]
use super::driver as shell_driver;
#[cfg(all(test, feature = "test-support", feature = "unix-runtime"))]
use super::shell_traps;
#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
use super::{OPT_IGNOREEOF, OPT_NOTIFY, append_history_line, run_string, shell_jobs, shell_out};
#[cfg(all(test, feature = "test-support", feature = "unix-runtime"))]
use super::{OPT_NOEXEC, machine as shell_machine};
#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
use super::{ShellState, shell_errln};
#[cfg(any(
    feature = "frontend",
    all(test, feature = "test-support", feature = "unix-runtime")
))]
pub(crate) fn is_login_shell_argv(argv: &[String]) -> bool {
    argv.first().is_some_and(|arg0| arg0.starts_with('-'))
}

#[cfg(all(test, feature = "test-support", feature = "unix-runtime"))]
pub(super) fn should_source_profile(state: &ShellState, argv: &[String]) -> bool {
    (state.interactive && !state.has_option(OPT_NOEXEC) && is_login_shell_argv(argv))
        || (state.interactive
            && !state.has_option(OPT_NOEXEC)
            && state.definition.startup_policy.should_source_profile())
}

#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
fn parse_error_needs_more_input(buffer: &str, err: &ParseError) -> bool {
    err.range.end.offset >= buffer.len()
}

#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
fn interactive_prompt(state: &ShellState, continuation: bool) -> String {
    if continuation {
        state.env_get("PS2").unwrap_or("> ").to_string()
    } else {
        state.env_get("PS1").unwrap_or("$ ").to_string()
    }
}

#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
fn interactive_buffer_needs_more_input(state: &ShellState, buffer: &str) -> bool {
    let mut parser = Parser::from_string(buffer);
    crate::parser::configure_parser_for_language(&mut parser, &state.definition.language);
    let aliases = state.aliases_snapshot();
    parser.set_alias_func(crate::parser::AliasFn::new(move |name| {
        aliases.get(name).cloned()
    }));
    match parser.parse_program() {
        Ok(_) => false,
        Err(err) => parse_error_needs_more_input(buffer, &err),
    }
}

#[cfg(any(feature = "frontend", all(test, feature = "test-support")))]
/// Run the interactive REPL.
pub fn run_interactive<R: Runtime>(state: &mut ShellState, runtime: &mut R) -> io::Result<i32> {
    let mut pending = String::new();
    loop {
        if pending.is_empty() && state.has_option(OPT_NOTIFY) {
            shell_jobs::maybe_notify_jobs(state, runtime);
        }
        let prompt = interactive_prompt(state, !pending.is_empty());
        shell_out(state, &prompt)?;

        let Some(input) = state.stdin_fd.read_line()? else {
            if pending.is_empty() && state.has_option(OPT_IGNOREEOF) {
                shell_errln(state, "Use \"exit\" to leave the shell.");
                continue;
            }
            if !pending.is_empty() {
                let code = run_string(state, runtime, &pending);
                state.set_last_status(code);
            }
            break;
        };
        if !pending.is_empty() {
            pending.push('\n');
        }
        pending.push_str(&input);
        if interactive_buffer_needs_more_input(state, &pending) {
            continue;
        }
        append_history_line(state, &pending);
        let code = run_string(state, runtime, &pending);
        state.set_last_status(code);
        pending.clear();
        if state.exit_code >= 0 {
            break;
        }
    }

    Ok(if state.exit_code >= 0 {
        state.exit_code
    } else {
        state.last_status
    })
}

/// Top-level shell entry point using the default UnixRuntime.
#[cfg(all(test, feature = "test-support", feature = "unix-runtime"))]
pub fn run(argv: &[String]) -> i32 {
    let mut runtime = crate::sys::UnixRuntime::new();
    let mut state = ShellState::new();
    run_with_state(argv, &mut state, &mut runtime)
}

#[cfg(all(test, feature = "test-support", feature = "unix-runtime"))]
pub(super) fn run_with_state<R: Runtime>(
    argv: &[String],
    state: &mut ShellState,
    runtime: &mut R,
) -> i32 {
    state.manage_signals = true;
    shell_traps::clear_pending_traps(state);
    let init = match process_args(state, argv) {
        Ok(init) => init,
        Err(err) => {
            report_arg_error(err, &ShellIdentity::default());
            return 1;
        }
    };

    if init.machine_mode {
        let payload_text =
            match shell_machine::load_machine_payload_text(&init, &ShellIdentity::default()) {
                Ok(payload_text) => payload_text,
                Err(err) => {
                    eprintln!("{err}");
                    return 1;
                }
            };
        let status = super::exec::run_machine_payload(state, runtime, &payload_text);
        return finalize_shell_run(state, runtime, status);
    }

    shell_driver::initialize_shell_session(state, runtime);

    let status = if state.interactive {
        run_interactive(state, runtime).unwrap_or(1)
    } else {
        shell_driver::run_non_interactive(state, runtime, &init)
    };

    finalize_shell_run(state, runtime, status)
}

#[cfg(any(
    feature = "frontend",
    all(test, feature = "test-support", feature = "unix-runtime")
))]
pub(crate) fn report_arg_error(err: ArgError, identity: &ShellIdentity) {
    match err {
        ArgError::Usage => {
            let shell_name = identity.name();
            eprintln!("usage: {shell_name} [(-|+)abCefhmnuvx] [-o option] [args...]");
            eprintln!("       {shell_name} [(-|+)abCefhmnuvx] [+o option] [args...]");
            eprintln!("       {shell_name} --machine -c <payload>");
            eprintln!("       {shell_name} -- [args...]");
        }
        other => eprintln!("{}: {other}", identity.name()),
    }
}

#[cfg(any(
    feature = "frontend",
    all(test, feature = "test-support", feature = "unix-runtime")
))]
pub(crate) fn finalize_shell_run<R: Runtime>(
    state: &mut ShellState,
    runtime: &mut R,
    status: i32,
) -> i32 {
    super::finalize_shell_state(state, runtime, status)
}

#[cfg(all(test, feature = "test-support"))]
mod tests {
    use std::fs;
    use std::path::PathBuf;
    use std::sync::atomic::{AtomicUsize, Ordering};

    use crate::sys::{DeterministicRuntime, StringStdioIn, StringStdioOut};

    use super::*;

    fn temp_path(label: &str) -> PathBuf {
        static NEXT_ID: AtomicUsize = AtomicUsize::new(1);
        std::env::temp_dir().join(format!(
            "mxsh-{label}-{}-{}",
            std::process::id(),
            NEXT_ID.fetch_add(1, Ordering::Relaxed)
        ))
    }

    #[test]
    fn interactive_multiline_if_uses_continuation_prompt() {
        let input = StringStdioIn::new("if true\nthen echo hi\nfi\nexit\n");
        let output = StringStdioOut::new();
        let mut state = ShellState::new();
        state.populate_env();
        state.interactive = true;
        state.stdin_fd = input.fd();
        state.stdout_fd = output.fd();
        let mut runtime = DeterministicRuntime::new();

        let status = run_interactive(&mut state, &mut runtime).expect("interactive run succeeds");
        input.join();
        let out = output.collect();

        assert_eq!(status, 0);
        assert!(out.contains("hi\n"), "expected command output, got {out:?}");
        assert!(
            out.contains("> "),
            "expected continuation prompt in output, got {out:?}"
        );
    }

    #[test]
    fn interactive_notify_reports_completed_job_before_next_prompt() {
        let input = StringStdioIn::new("sleep 1 &\nexit\n");
        let output = StringStdioOut::new();
        let mut state = ShellState::new();
        state.populate_env();
        state.interactive = true;
        state.options |= OPT_NOTIFY;
        state.stdin_fd = input.fd();
        state.stdout_fd = output.fd();
        state.stderr_fd = output.fd();
        let mut runtime = DeterministicRuntime::new();
        runtime.push_spawn(
            Some(4242),
            [
                crate::sys::ProcessEvent::Running,
                crate::sys::ProcessEvent::Exited(0),
            ],
            [],
        );

        let status = run_interactive(&mut state, &mut runtime).expect("interactive run succeeds");
        input.join();
        let out = output.collect();

        assert_eq!(status, 0);
        let notify_idx = out
            .find("[1] Done(0) 4242")
            .expect("expected job notification");
        let prompt_idx = out[notify_idx..]
            .find("$ ")
            .map(|idx| notify_idx + idx)
            .expect("expected prompt after notification");
        assert!(
            notify_idx < prompt_idx,
            "expected notification before next prompt, got {out:?}"
        );
    }

    #[test]
    fn interactive_env_file_is_sourced_and_can_define_helper_function() {
        let env_path = temp_path("interactive-env");
        fs::write(&env_path, "helper() { echo from_env; }\n").expect("write ENV file");
        let input = StringStdioIn::new("helper\nexit\n");
        let output = StringStdioOut::new();
        let mut state = ShellState::new();
        state.interactive = true;
        std::sync::Arc::make_mut(&mut state.definition).startup_policy =
            crate::policy::StartupPolicy::InteractiveEnvHook;
        state.env_set("ENV", env_path.display().to_string(), 0);
        state.stdin_fd = input.fd();
        state.stdout_fd = output.fd();
        let mut runtime = DeterministicRuntime::new();

        shell_driver::initialize_shell_session(&mut state, &mut runtime);
        let status = run_interactive(&mut state, &mut runtime).expect("interactive run succeeds");
        input.join();
        let out = output.collect();

        assert_eq!(status, 0);
        assert!(
            out.contains("from_env\n"),
            "expected helper output, got {out:?}"
        );
        let _ = fs::remove_file(env_path);
    }
}