use super::*;
use crate::ast::{Position, Range};
use crate::builtin::{BuiltinProperties, RegisteredBuiltin};
use crate::sys;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
macro_rules! word_string {
($($tt:tt)*) => {
crate::ast::Word::String(crate::ast::StringWord { $($tt)* })
};
}
macro_rules! word_parameter {
($($tt:tt)*) => {
crate::ast::Word::Parameter(crate::ast::ParameterExpansion {
$($tt)*
range: Range::default(),
})
};
}
macro_rules! word_command {
($($tt:tt)*) => {
crate::ast::Word::Command(crate::ast::CommandSubstitution { $($tt)* })
};
}
macro_rules! word_list {
($($tt:tt)*) => {
crate::ast::Word::List(crate::ast::WordList { $($tt)* })
};
}
macro_rules! simple_command {
($($tt:tt)*) => {
SimpleCommand {
$($tt)*
range: Range::default(),
}
};
}
macro_rules! program {
(body: $body:expr $(,)?) => {
crate::ast::Program {
body: $body,
range: Range::default(),
}
};
}
#[derive(Clone, Default)]
struct ExecCaptureRuntime {
cwd: Arc<Mutex<Option<PathBuf>>>,
}
impl Runtime for ExecCaptureRuntime {
type ForegroundGuard = ();
fn fork(&self) -> Result<Self, io::Error> {
Ok(self.clone())
}
fn spawn_external_command(
&mut self,
_command: &sys::ExternalCommand,
_stdio: sys::SpawnStdio,
_close_fds: &[sys::FileDescriptor],
_mode: sys::SpawnMode,
) -> Result<sys::SpawnedProcess, io::Error> {
Err(io::Error::new(
io::ErrorKind::Unsupported,
"spawn unavailable in exec capture runtime",
))
}
fn wait_process(
&mut self,
_process: sys::ProcessHandle,
_mode: sys::WaitMode,
) -> Result<sys::ProcessEvent, io::Error> {
Ok(sys::ProcessEvent::Exited(0))
}
fn signal_process_group(
&mut self,
_process: sys::ProcessHandle,
_signal: sys::RuntimeSignal,
) -> Result<(), io::Error> {
Ok(())
}
fn claim_foreground(
&mut self,
_process: sys::ProcessHandle,
_tty: sys::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> {
*self.cwd.lock().unwrap() = Some(cwd.to_path_buf());
Err(io::Error::new(
io::ErrorKind::Unsupported,
"exec unavailable in exec capture runtime",
))
}
}
fn trap_test_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}
fn run_and_capture(input: &str) -> (i32, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, input);
state.last_status = status;
(status, state)
}
fn run_and_capture_with_stdin(input: &str, stdin: &str) -> (i32, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdin = sys::StringStdioIn::new(stdin);
let mut state = ShellState::new();
state.populate_env();
state.stdin_fd = stdin.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, input);
state.last_status = status;
stdin.join();
(status, state)
}
fn run_and_capture_output(input: &str) -> (i32, String, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, input);
state.last_status = status;
let output = stdout.collect();
(status, output, state)
}
fn run_and_capture_output_and_error(input: &str) -> (i32, String, String, ShellState) {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
state.stderr_fd = stderr.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, input);
state.last_status = status;
let output = stdout.collect();
let error = stderr.collect();
(status, output, error, state)
}
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 simple_assignment() {
let (_, state) = run_and_capture("FOO=bar");
assert_eq!(state.env_get("FOO"), Some("bar"));
}
#[test]
fn parameter_expansion() {
let (_, state) = run_and_capture("X=hello; Y=${X}world");
assert_eq!(state.env_get("Y"), Some("helloworld"));
}
#[test]
fn parameter_default() {
let (_, state) = run_and_capture("Y=${UNSET:-default}");
assert_eq!(state.env_get("Y"), Some("default"));
}
#[test]
fn if_true() {
let (status, _) = run_and_capture("if true; then true; fi");
assert_eq!(status, 0);
}
#[test]
fn if_false_else() {
let (status, _) = run_and_capture("if false; then true; else false; fi");
assert_eq!(status, 1);
}
#[test]
fn for_loop() {
let (_, state) = run_and_capture("R=; for x in a b c; do R=${R}${x}; done");
assert_eq!(state.env_get("R"), Some("abc"));
}
#[test]
fn while_loop() {
let (_, state) = run_and_capture("I=0; while [ $I -lt 3 ]; do I=$((I+1)); done");
assert_eq!(state.env_get("I"), Some("3"));
}
#[test]
fn arithmetic_expansion() {
let (_, state) = run_and_capture("X=$((2 + 3 * 4))");
assert_eq!(state.env_get("X"), Some("14"));
}
#[test]
fn function_definition_and_call() {
let (_, state) = run_and_capture("greet() { R=hello; }; greet");
assert_eq!(state.env_get("R"), Some("hello"));
}
#[test]
fn and_or_list() {
let (status, _) = run_and_capture("true && true");
assert_eq!(status, 0);
let (status, _) = run_and_capture("true && false");
assert_eq!(status, 1);
let (status, _) = run_and_capture("false || true");
assert_eq!(status, 0);
}
#[test]
fn case_clause() {
let (_, state) = run_and_capture("X=hello; case $X in hello) R=matched;; *) R=nope;; esac");
assert_eq!(state.env_get("R"), Some("matched"));
}
#[test]
fn case_wildcard() {
let (_, state) = run_and_capture("X=world; case $X in hello) R=no;; *) R=yes;; esac");
assert_eq!(state.env_get("R"), Some("yes"));
}
#[test]
fn break_in_loop() {
let (_, state) =
run_and_capture("I=0; while true; do I=$((I+1)); if [ $I -ge 3 ]; then break; fi; done");
assert_eq!(state.env_get("I"), Some("3"));
}
#[test]
fn pattern_match_basic() {
assert!(shell_expand::pattern_match("hello", "hello"));
assert!(!shell_expand::pattern_match("hello", "world"));
assert!(shell_expand::pattern_match("*", "anything"));
assert!(shell_expand::pattern_match("h*o", "hello"));
assert!(shell_expand::pattern_match("h?llo", "hello"));
assert!(!shell_expand::pattern_match("h?llo", "hllo"));
}
#[test]
fn strip_suffix_test() {
assert_eq!(
shell_expand::strip_suffix("hello.tar.gz", ".gz", false),
"hello.tar"
);
assert_eq!(
shell_expand::strip_suffix("hello.tar.gz", ".*", true),
"hello"
);
assert_eq!(
shell_expand::strip_suffix("hello.tar.gz", ".*", false),
"hello.tar"
);
}
#[test]
fn strip_prefix_test() {
assert_eq!(
shell_expand::strip_prefix("/usr/local/bin", "*/", false),
"usr/local/bin"
);
assert_eq!(
shell_expand::strip_prefix("/usr/local/bin", "*/", true),
"bin"
);
}
#[test]
fn tilde_expansion() {
let mut state = ShellState::new();
state.env_set("HOME", "/home/test".to_string(), 0);
assert_eq!(
shell_expand::expand_tilde(&state, "~/foo"),
"/home/test/foo"
);
assert_eq!(shell_expand::expand_tilde(&state, "~"), "/home/test");
assert_eq!(shell_expand::expand_tilde(&state, "/no/tilde"), "/no/tilde");
}
#[test]
fn in_memory_subshell_does_not_leak_state() {
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(&mut state, &mut runtime, "X=outer; ( X=inner );");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("outer"));
}
#[test]
fn in_memory_pipeline_uses_stage_io() {
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
let payload = if argv.len() > 1 {
argv[1..].join(" ")
} else {
String::new()
};
let _ = stdio.stdout_fd.write_line(&payload);
0
});
runtime.register_command("upper", |_argv, _env, _cwd, stdio| {
let input = stdio.stdin_fd.dup().expect("dup upper stdin").read_all();
let _ = stdio.stdout_fd.write_str(&input.to_ascii_uppercase());
0
});
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
let status = run_string(&mut state, &mut runtime, "emit hello world | upper");
assert_eq!(status, 0);
assert_eq!(output.collect(), "HELLO WORLD\n");
}
#[test]
fn in_memory_pipeline_restores_state_between_stages() {
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"f() { X=inner; }; X=outer; f | :;",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("outer"));
}
#[test]
fn in_memory_pipeline_command_substitutions_do_not_clobber_parent_last_status() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("noop", |_argv, _env, _cwd, _stdio| 0);
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
let payload = if argv.len() > 1 {
argv[1..].join(" ")
} else {
String::new()
};
let _ = stdio.stdout_fd.write_line(&payload);
0
});
runtime.register_command("cat", |_argv, _env, _cwd, stdio| {
let input = stdio.stdin_fd.dup().expect("dup cat stdin").read_all();
let _ = stdio.stdout_fd.write_str(&input);
0
});
let status = run_string(
&mut state,
&mut runtime,
"false; noop \"$(emit warmup)\" | emit \"$(emit $? | cat)\"",
);
assert_eq!(status, 0);
assert_eq!(output.collect(), "1\n");
assert_eq!(state.last_status, 0);
}
#[test]
fn in_memory_pipeline_assignment_prefix_reaches_external_stage_env() {
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("env", |_argv, env, _cwd, stdio| {
for (name, value) in env {
let _ = stdio.stdout_fd.write_line(&format!("{name}={value}"));
}
0
});
runtime.register_command("grep", |argv, _env, _cwd, stdio| {
let needle = argv.get(1).map(String::as_str).unwrap_or_default();
let input = stdio.stdin_fd.dup().expect("dup grep stdin").read_all();
let mut matched = false;
for line in input.lines() {
let is_match = match needle {
"^A=1$" => line == "A=1",
_ => line == needle,
};
if is_match {
matched = true;
let _ = stdio.stdout_fd.write_line(line);
}
}
i32::from(!matched)
});
let status = run_string(&mut state, &mut runtime, "A=1 env | grep '^A=1$'");
assert_eq!(status, 0);
assert_eq!(output.collect(), "A=1\n");
assert_eq!(state.env_get("A"), None);
}
#[test]
fn execution_builder_classifies_pipeline_before_execution() {
let mut parser = crate::parser::Parser::from_string("emit hi | f | upper\n");
let program = parser.parse_program().expect("script should parse");
let AndOrList::Pipeline(pipeline) = &program.body[0].and_or_list else {
panic!("expected parsed pipeline");
};
let mut state = ShellState::new();
state.populate_env();
state.functions.insert(
"f".to_string(),
AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: ":".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: Vec::new(),
assignments: Vec::new(),
}),
);
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("emit", |_argv, _env, _cwd, _stdio| 0);
runtime.register_command("upper", |_argv, _env, _cwd, _stdio| 0);
let pipeline = build_pipeline_execution(&mut state, &mut runtime, pipeline);
assert!(matches!(
pipeline.stages(),
[
ExecPipelineStage::PreparedExternal(_),
ExecPipelineStage::SimpleCommand(_),
ExecPipelineStage::PreparedExternal(_)
]
));
}
#[test]
fn execution_builder_uses_live_state_between_command_lists() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, "f() { echo live; }\nf | cat\n");
assert_eq!(status, 0);
assert_eq!(stdout.collect(), "live\n");
}
#[test]
fn execution_builder_keeps_brace_group_body_live_between_command_lists() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"{ f() { echo brace; }\nf | cat\n}\n",
);
assert_eq!(status, 0);
assert_eq!(stdout.collect(), "brace\n");
}
#[test]
fn execution_builder_keeps_if_body_live_between_command_lists() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"if true; then f() { echo branch; }\nf | cat\nfi\n",
);
assert_eq!(status, 0);
assert_eq!(stdout.collect(), "branch\n");
}
#[test]
fn execution_builder_short_circuits_without_classifying_skipped_branch() {
let (status, output, error, _) =
run_and_capture_output_and_error("true || definitelymissingcommand\n");
assert_eq!(status, 0);
assert_eq!(output, "");
assert_eq!(error, "");
}
#[test]
fn exec_command_with_stdio_reports_dup_failure() {
let mut state = ShellState::new();
state.populate_env();
let stderr = sys::StringStdioOut::new();
state.stderr_fd = stderr.fd();
let invalid_fd = sys::FileDescriptor::new(i32::MAX);
let command = AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: ":".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: Vec::new(),
assignments: Vec::new(),
});
let mut runtime = sys::InMemoryRuntime::new();
let stderr_fd = state.stderr_fd;
let status = super::command::run_command_with_stdio(
&mut state,
&mut runtime,
&command,
invalid_fd,
sys::FileDescriptor::INVALID,
stderr_fd,
);
assert_eq!(status, 1);
assert!(stderr.collect().contains("failed to duplicate stdin"));
}
#[test]
fn subshell_reports_dup_failure() {
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stderr_fd = stderr.fd();
state.stdin_fd = sys::FileDescriptor::new(i32::MAX);
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(&mut state, &mut runtime, "( : )");
assert_eq!(status, 1);
assert!(
stderr
.collect()
.contains("failed to duplicate subshell stdio")
);
}
#[test]
fn execution_builder_skips_function_definition_when_and_or_is_short_circuited() {
let (status, _, _, state) =
run_and_capture_output_and_error("true || f() { echo should_not_run; }\nf\n");
assert_eq!(status, 127);
assert!(!state.functions.contains_key("f"));
}
#[test]
fn execution_builder_builds_and_executes_function_definition_on_taken_and_or_branch() {
let (status, output, _, state) =
run_and_capture_output_and_error("false || f() { echo from_branch; }\nf\n");
assert_eq!(status, 0);
assert_eq!(output, "from_branch\n");
assert!(state.functions.contains_key("f"));
}
#[test]
fn execution_builder_builds_right_branch_after_left_side_effects() {
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
let payload = if argv.len() > 1 {
argv[1..].join(" ")
} else {
String::new()
};
let _ = stdio.stdout_fd.write_line(&payload);
0
});
runtime.register_command("upper", |_argv, _env, _cwd, stdio| {
let input = stdio.stdin_fd.dup().expect("dup upper stdin").read_all();
let _ = stdio.stdout_fd.write_str(&input.to_ascii_uppercase());
0
});
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let status = run_string(&mut state, &mut runtime, "f() { upper; } && emit hi | f\n");
assert_eq!(status, 0);
assert_eq!(stdout.collect(), "HI\n");
}
#[test]
fn execution_builder_keeps_function_body_lazy_until_invocation() {
let (status, output, error, state) =
run_and_capture_output_and_error("f() { definitelymissingcommand | cat; }\necho ok\n");
assert_eq!(status, 0);
assert_eq!(output, "ok\n");
assert_eq!(error, "");
assert!(state.functions.contains_key("f"));
}
#[test]
fn here_document_with_expansion() {
let mut state = ShellState::new();
state.populate_env();
state.env_set("X", "world".to_string(), 0);
let script = "cat <<EOF\nhello $X\nEOF\n";
let mut parser = crate::parser::Parser::from_string(script);
let prog = parser.parse_program().unwrap();
let command = &prog.body[0].and_or_list;
let redir = match command {
AndOrList::Pipeline(Pipeline { commands, .. }) => match &commands[0] {
AstCommand::Simple(sc) => &sc.io_redirects[0],
other => panic!("expected simple command, got {other:?}"),
},
other => panic!("expected pipeline, got {other:?}"),
};
assert!(redir.here_document_expand);
let mut result = String::new();
let mut runtime = sys::InMemoryRuntime::new();
for w in &redir.here_document {
result.push_str(&shell_expand::expand_word_nosplit(
&mut state,
&mut runtime,
w,
));
result.push('\n');
}
assert_eq!(
result, "hello world\n",
"here-doc should expand variables: got {result:?}"
);
}
#[test]
fn here_document_quoted_delimiter() {
let mut state = ShellState::new();
state.populate_env();
state.env_set("X", "world".to_string(), 0);
let mut parser = crate::parser::Parser::from_string("cat <<'EOF'\nhello $X\nEOF\n");
let prog = parser.parse_program().unwrap();
let command = &prog.body[0].and_or_list;
let redir = match command {
AndOrList::Pipeline(Pipeline { commands, .. }) => match &commands[0] {
AstCommand::Simple(sc) => &sc.io_redirects[0],
other => panic!("expected simple command, got {other:?}"),
},
other => panic!("expected pipeline, got {other:?}"),
};
assert!(!redir.here_document_expand);
let mut result = String::new();
let mut runtime = sys::InMemoryRuntime::new();
for w in &redir.here_document {
result.push_str(&shell_expand::expand_word_nosplit(
&mut state,
&mut runtime,
w,
));
result.push('\n');
}
assert_eq!(
result, "hello $X\n",
"quoted here-doc should NOT expand variables: got {result:?}"
);
}
#[test]
fn here_document_backslash_quoted_delimiter() {
let mut state = ShellState::new();
state.populate_env();
state.env_set("X", "world".to_string(), 0);
let mut parser = crate::parser::Parser::from_string("cat <<\\EOF\nhello $X\nEOF\n");
let prog = parser.parse_program().unwrap();
let command = &prog.body[0].and_or_list;
let redir = match command {
AndOrList::Pipeline(Pipeline { commands, .. }) => match &commands[0] {
AstCommand::Simple(sc) => &sc.io_redirects[0],
other => panic!("expected simple command, got {other:?}"),
},
other => panic!("expected pipeline, got {other:?}"),
};
assert!(!redir.here_document_expand);
let mut result = String::new();
let mut runtime = sys::InMemoryRuntime::new();
for w in &redir.here_document {
result.push_str(&shell_expand::expand_word_nosplit(
&mut state,
&mut runtime,
w,
));
result.push('\n');
}
assert_eq!(
result, "hello $X\n",
"backslash-quoted here-doc should NOT expand variables: got {result:?}"
);
}
#[test]
fn here_document_parameter_syntax_delimiter_runs_end_to_end() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, "cat <<$EOF\nhello\n$EOF\n");
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output, "hello\n");
}
#[test]
fn programmatic_literal_heredoc_uses_explicit_mode_at_runtime() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
state.env_set("X", "world".to_string(), 0);
let mut runtime = sys::UnixRuntime::new();
let prog = program! {
body: vec![crate::ast::CommandList {
and_or_list: crate::ast::AndOrList::Pipeline(crate::ast::Pipeline {
commands: vec![AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: "cat".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: vec![crate::ast::IoRedirect {
io_number: None,
op: crate::ast::IoRedirectOp::DLess,
name: word_string! {
value: "EOF".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
},
here_document: vec![word_list! {
children: vec![
word_string! {
value: "hello ".to_string(),
single_quoted: false,
split_fields: false,
source: None,
range: Range::default(),
},
word_parameter! {
name: "X".to_string(),
op: crate::ast::ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
},
],
double_quoted: false,
range: Range::default(),
}],
here_document_expand: false,
range: Range::default(),
}],
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
}),
ampersand: false,
range: Default::default(),
}],
};
let status = run_program(&mut state, &mut runtime, &prog);
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output, "hello $X\n");
}
#[test]
fn here_document_restores_original_stdin_fd() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdin = sys::StringStdioIn::new("unused\n");
let stdout = sys::StringStdioOut::new();
let original_stdin = stdin.fd();
let mut state = ShellState::new();
state.populate_env();
state.stdin_fd = original_stdin;
state.stdout_fd = stdout.fd();
state.env_set("X", "world".to_string(), 0);
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, "cat <<'EOF'\nhello $X\nEOF\n");
let output = stdout.collect();
stdin.join();
assert_eq!(status, 0);
assert_eq!(output, "hello $X\n");
assert_eq!(state.stdin_fd, original_stdin);
}
#[test]
fn output_redirection_restores_original_stdout_fd() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let original_stdout = stdout.fd();
let path = temp_path("redirect-stdout");
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = original_stdout;
let mut runtime = sys::UnixRuntime::new();
let script = format!("echo file > {}\necho shell\n", path.display());
let status = run_string(&mut state, &mut runtime, &script);
let restored_stdout = state.stdout_fd;
if restored_stdout != original_stdout {
restored_stdout.close();
}
let output = stdout.collect();
let redirected = std::fs::read_to_string(&path).expect("read redirected output");
assert_eq!(status, 0);
assert_eq!(restored_stdout, original_stdout);
assert_eq!(output, "shell\n");
assert_eq!(redirected, "file\n");
let _ = std::fs::remove_file(path);
}
#[test]
fn programmatic_literal_heredoc_preserves_backquoted_command_substitution_at_runtime() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let prog = program! {
body: vec![crate::ast::CommandList {
and_or_list: crate::ast::AndOrList::Pipeline(crate::ast::Pipeline {
commands: vec![AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: "cat".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: vec![crate::ast::IoRedirect {
io_number: None,
op: crate::ast::IoRedirectOp::DLess,
name: word_string! {
value: "EOF".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
},
here_document: vec![word_command! {
program: program! {
body: vec![crate::ast::CommandList {
and_or_list: crate::ast::AndOrList::Pipeline(
crate::ast::Pipeline {
commands: vec![AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: "date".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: Vec::new(),
io_redirects: Vec::new(),
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
},
),
ampersand: false,
range: Default::default(),
}],
},
source: None,
back_quoted: true,
range: Range::default(),
}],
here_document_expand: false,
range: Range::default(),
}],
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
}),
ampersand: false,
range: Default::default(),
}],
};
let status = run_program(&mut state, &mut runtime, &prog);
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output, "`date`\n");
}
#[test]
fn special_parameter_bang_uses_last_background_pid() {
let mut state = ShellState::new();
state.last_bg_pid = Some(4242);
assert_eq!(
shell_expand::get_parameter_value(&state, "!"),
Some("4242".to_string())
);
}
#[test]
fn machine_payload_restores_shell_state() {
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("emit", |argv, _env, _cwd, stdio| {
if let Some(text) = argv.get(1) {
let _ = stdio.stdout_fd.write_line(text);
}
0
});
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
state.env_set("MSG", "hello".to_string(), 0);
let payload = BackgroundMachinePayload {
state: state.background_snapshot(),
and_or_list: AndOrList::Pipeline(Pipeline {
commands: vec![AstCommand::Simple(simple_command! {
name: Some(word_string! {
value: "emit".to_string(),
single_quoted: false,
split_fields: true,
source: None,
range: Range::default(),
}),
arguments: vec![word_parameter! {
name: "MSG".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
}],
io_redirects: Vec::new(),
assignments: Vec::new(),
})],
bang: false,
range: Default::default(),
}),
command_id: "00000000-0000-4000-8000-000000000000".to_string(),
};
let payload_text = serde_json::to_string(&payload).expect("payload should serialize");
let status = exec::run_machine_payload(&mut state, &mut runtime, &payload_text);
assert_eq!(status, 0);
assert_eq!(output.collect(), "hello\n");
}
#[test]
fn exit_trap_runs_before_shell_termination() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let argv = vec![
"mxsh".to_string(),
"-c".to_string(),
"trap 'TRAP_RAN=1' EXIT".to_string(),
];
let mut state = ShellState::new();
let mut runtime = sys::UnixRuntime::new();
let code = run_with_state(&argv, &mut state, &mut runtime);
assert_eq!(code, 0);
assert_eq!(state.env_get("TRAP_RAN"), Some("1"));
}
#[test]
fn login_shell_argv_detects_dash_prefixed_argv0() {
assert!(is_login_shell_argv(&["-mxsh".to_string()]));
assert!(!is_login_shell_argv(&["mxsh".to_string()]));
assert!(!is_login_shell_argv(&[]));
}
#[test]
fn source_profile_requires_interactive_login_shell() {
let mut state = ShellState::new();
state.interactive = true;
assert!(should_source_profile(&state, &["-mxsh".to_string()]));
assert!(!should_source_profile(&state, &["mxsh".to_string()]));
state.options |= OPT_NOEXEC;
assert!(!should_source_profile(&state, &["-mxsh".to_string()]));
state.options = 0;
state.interactive = false;
assert!(!should_source_profile(&state, &["-mxsh".to_string()]));
}
#[test]
fn builtin_builtin_executes_builtin() {
let (status, _) = run_and_capture("builtin false");
assert_eq!(status, 1);
}
#[test]
fn ulimit_query_succeeds() {
let (status, _) = run_and_capture("ulimit -n");
assert_eq!(status, 0);
}
#[test]
fn alias_defined_on_same_line_not_expanded_later_on_that_line() {
let (status, _) = run_and_capture("alias nope=false; nope");
assert_eq!(status, 127);
}
#[test]
fn alias_expands_on_following_line() {
let (status, _) = run_and_capture("alias nope=false\nnope\n");
assert_eq!(status, 1);
}
#[test]
fn redirect_open_failure_skips_command_and_sets_status() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"echo hi </definitely_missing_file_xyz\n",
);
let output = stdout.collect();
assert_eq!(status, 1);
assert_eq!(output, "");
}
#[test]
fn bad_fd_dup_skips_command_and_sets_status() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(&mut state, &mut runtime, "echo hi 1>&999\n");
let output = stdout.collect();
assert_eq!(status, 1);
assert_eq!(output, "");
}
#[test]
fn multi_digit_fd_redirection_does_not_become_argument() {
let dir = std::env::temp_dir();
let path = dir.join(format!("mxsh-multi-fd-{}", std::process::id()));
let script = format!("echo hi 10>{}\n", path.display());
let (status, _) = run_and_capture(&script);
assert_eq!(status, 0);
let content = std::fs::read_to_string(&path).expect("redirect target should exist");
assert_eq!(content, "");
let _ = std::fs::remove_file(path);
}
#[test]
fn alias_pipeline_executes_pipeline() {
let stdout = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, "alias a='echo left | cat'\na\n");
let output = stdout.collect();
assert_eq!(status, 0);
assert_eq!(output.trim(), "left");
}
#[test]
fn alias_in_command_substitution_preserves_closing_delimiter() {
let (status, _out, err, state) = run_and_capture_output_and_error(
"alias myalias='echo )'\nvar=\"$(myalias arg-two)\"\nAFTER=1\n",
);
assert_eq!(status, 0);
assert_eq!(state.exit_code, -1);
assert_eq!(state.env_get("AFTER"), Some("1"));
assert!(
state.env_get("var").is_some_and(|value| !value.is_empty()),
"expected command substitution output to be assigned"
);
assert_eq!(err, "");
}
#[test]
fn unterminated_backquote_in_double_quotes_is_non_fatal() {
let stdout = sys::StringStdioOut::new();
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
state.stderr_fd = stderr.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"printf '%s\\n' \"cmd: `echo \"`\"\n",
);
let output = stdout.collect();
let errors = stderr.collect();
assert_eq!(status, 0);
assert_eq!(output, "cmd: \n");
assert!(errors.contains("unexpected EOF while looking for matching `\"'"));
assert_eq!(state.exit_code, -1);
}
#[test]
fn unterminated_backquote_in_single_quotes_is_non_fatal() {
let stdout = sys::StringStdioOut::new();
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = stdout.fd();
state.stderr_fd = stderr.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"printf '%s\\n' \"cmd: `echo '`\"\n",
);
let output = stdout.collect();
let errors = stderr.collect();
assert_eq!(status, 0);
assert_eq!(output, "cmd: \n");
assert!(errors.contains("unexpected EOF while looking for matching `''"));
assert_eq!(state.exit_code, -1);
}
#[test]
fn command_substitution_uses_forked_runtime() {
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
runtime.register_command("caps", |argv, _, _, stdio| {
let text = argv.get(1).cloned().unwrap_or_default().to_uppercase();
let _ = stdio.stdout_fd.write_line(&text);
0
});
let status = run_string(&mut state, &mut runtime, "VALUE=$(caps hello)\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("VALUE"), Some("HELLO"));
}
#[test]
fn command_substitution_strips_trailing_newlines() {
let (_, state) = run_and_capture("VALUE=$(printf 'hello\\n\\n')\n");
assert_eq!(state.env_get("VALUE"), Some("hello"));
}
#[test]
fn builtin_printf_formats_percent_s_and_escapes() {
let (status, output, _state) = run_and_capture_output("printf 'item:%s\\n' 'hello world'\n");
assert_eq!(status, 0);
assert_eq!(output, "item:hello world\n");
}
#[test]
fn builtin_printf_reuses_format_for_extra_arguments() {
let (status, output, _state) = run_and_capture_output("printf '[%s]\\n' alpha beta\n");
assert_eq!(status, 0);
assert_eq!(output, "[alpha]\n[beta]\n");
}
#[test]
fn builtin_printf_falls_back_for_unsupported_formats() {
let (status, output, _state) = run_and_capture_output("printf '%d\\n' 7\n");
assert_eq!(status, 0);
assert_eq!(output, "7\n");
}
#[test]
fn assignment_uses_command_substitution_exit_status() {
let (status, state) = run_and_capture("A=$(! echo)\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("A"), Some(""));
assert_eq!(state.last_status, 1);
}
#[test]
fn special_builtin_keeps_prefix_assignment() {
let (_, state) = run_and_capture("X=1 export X; : \"$X\"");
assert_eq!(state.env_get("X"), Some("1"));
}
#[test]
fn function_prefix_assignment_is_visible_and_persistent() {
let (_, state) = run_and_capture("f() { INSIDE=$X; }\nX=1 f\nAFTER=$X\n");
assert_eq!(state.env_get("INSIDE"), Some("1"));
assert_eq!(state.env_get("AFTER"), Some("1"));
}
#[test]
fn nounset_stops_script_on_unset_parameter() {
let (status, state) = run_and_capture("set -u\n: \"$UNSETVAR\"\nAFTER=1\n");
assert_eq!(status, 0);
assert_eq!(state.exit_code, 1);
assert_eq!(state.env_get("AFTER"), None);
}
#[test]
fn signal_trap_runs_pending_action() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
shell_traps::clear_all_pending_traps();
let mut state = ShellState::new();
state.populate_env();
let mut runtime = sys::InMemoryRuntime::new();
let status = run_string(&mut state, &mut runtime, "trap 'TRAP_SIG=1' INT\n");
assert_eq!(status, 0);
shell_traps::trap_signal_handler(libc::SIGINT);
let status = run_string(&mut state, &mut runtime, ":\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("TRAP_SIG"), Some("1"));
shell_traps::clear_all_pending_traps();
}
#[test]
fn unrelated_shell_does_not_consume_pending_signal_trap() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
shell_traps::clear_all_pending_traps();
let mut trapped_state = ShellState::new();
trapped_state.populate_env();
let mut trapped_runtime = sys::InMemoryRuntime::new();
let status = run_string(
&mut trapped_state,
&mut trapped_runtime,
"trap 'TRAP_SIG=1' INT\n",
);
assert_eq!(status, 0);
let mut other_state = ShellState::new();
other_state.populate_env();
let mut other_runtime = sys::InMemoryRuntime::new();
shell_traps::trap_signal_handler(libc::SIGINT);
let status = run_string(&mut other_state, &mut other_runtime, ":\n");
assert_eq!(status, 0);
assert_eq!(other_state.env_get("TRAP_SIG"), None);
let status = run_string(&mut trapped_state, &mut trapped_runtime, ":\n");
assert_eq!(status, 0);
assert_eq!(trapped_state.env_get("TRAP_SIG"), Some("1"));
shell_traps::clear_all_pending_traps();
}
#[test]
fn parameter_assign_default() {
let (_, state) = run_and_capture("Y=${UNSET:=fallback}");
assert_eq!(state.env_get("Y"), Some("fallback"));
assert_eq!(
state.env_get("UNSET"),
Some("fallback"),
":= must assign the default to the variable"
);
}
#[test]
fn parameter_assign_default_already_set() {
let (_, state) = run_and_capture("EXISTING=original; Y=${EXISTING:=fallback}");
assert_eq!(state.env_get("Y"), Some("original"));
assert_eq!(state.env_get("EXISTING"), Some("original"));
}
#[test]
fn parameter_assign_default_respects_readonly() {
let (status, _out, err, state) =
run_and_capture_output_and_error("readonly X\nY=${X:=fallback}\nAFTER=1\n");
assert_eq!(status, 1);
assert_eq!(state.exit_code, 1);
assert_eq!(state.env_get("X"), Some(""));
assert_eq!(state.env_get("Y"), None);
assert_eq!(state.env_get("AFTER"), None);
assert!(err.contains("X: readonly variable"));
}
#[test]
fn parameter_error_if_unset() {
let (_, state) = run_and_capture("Y=${UNSET:?oops}; AFTER=1");
assert_eq!(
state.env_get("AFTER"),
None,
":? on unset var must abort the script"
);
assert_eq!(state.exit_code, 1);
}
#[test]
fn parameter_error_if_unset_but_set() {
let (_, state) = run_and_capture("EXISTING=ok; Y=${EXISTING:?oops}");
assert_eq!(state.env_get("Y"), Some("ok"));
}
#[test]
fn parameter_alternative_when_set() {
let (_, state) = run_and_capture("EXISTING=ok; Y=${EXISTING:+alt}");
assert_eq!(state.env_get("Y"), Some("alt"));
}
#[test]
fn parameter_alternative_when_unset() {
let (_, state) = run_and_capture("Y=${UNSET:+alt}");
assert_eq!(state.env_get("Y"), Some(""));
}
#[test]
fn parameter_length() {
let (_, state) = run_and_capture("X=hello; Y=${#X}");
assert_eq!(state.env_get("Y"), Some("5"));
}
#[test]
fn parameter_length_empty() {
let (_, state) = run_and_capture("X=; Y=${#X}");
assert_eq!(state.env_get("Y"), Some("0"));
}
#[test]
fn parameter_length_unset() {
let (_, state) = run_and_capture("Y=${#UNSET}");
assert_eq!(state.env_get("Y"), Some("0"));
}
#[test]
fn pattern_match_bracket_class() {
assert!(shell_expand::pattern_match("[a-z]", "m"));
assert!(!shell_expand::pattern_match("[a-z]", "M"));
assert!(shell_expand::pattern_match("[0-9]", "5"));
assert!(!shell_expand::pattern_match("[0-9]", "x"));
}
#[test]
fn pattern_match_negated_bracket() {
assert!(shell_expand::pattern_match("[!a-z]", "5"));
assert!(!shell_expand::pattern_match("[!a-z]", "m"));
}
#[test]
fn pattern_match_bracket_with_star() {
assert!(shell_expand::pattern_match(
"file[0-9]*.txt",
"file3abc.txt"
));
assert!(!shell_expand::pattern_match(
"file[0-9]*.txt",
"fileXabc.txt"
));
}
#[test]
fn pattern_match_backslash_escape() {
assert!(shell_expand::pattern_match("hello\\*", "hello*"));
assert!(!shell_expand::pattern_match("hello\\*", "helloX"));
}
#[test]
fn continue_in_loop() {
let (_, state) = run_and_capture(
"R=; for x in a b c; do if [ $x = b ]; then continue; fi; R=${R}${x}; done",
);
assert_eq!(state.env_get("R"), Some("ac"));
}
#[test]
fn break_nested_levels() {
let (_, state) = run_and_capture(
"R=; for i in 1 2; do for j in a b; do R=${R}${i}${j}; break 2; done; done",
);
assert_eq!(
state.env_get("R"),
Some("1a"),
"break 2 must exit both loops"
);
}
#[test]
fn continue_nested_levels() {
let (_, state) = run_and_capture(
"R=; for i in 1 2; do for j in a b; do R=${R}${i}${j}; continue 2; done; done",
);
assert_eq!(
state.env_get("R"),
Some("1a2a"),
"continue 2 must skip to next outer iteration"
);
}
#[test]
fn return_from_function() {
let (_, state) = run_and_capture("f() { R=before; return 42; R=after; }; f; S=$?");
assert_eq!(state.env_get("R"), Some("before"));
assert_eq!(state.env_get("S"), Some("42"));
}
#[test]
fn break_outside_loop_fails_without_aborting_following_commands() {
let (status, state) = run_and_capture("break\nAFTER=1\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("AFTER"), Some("1"));
}
#[test]
fn continue_outside_loop_fails_without_aborting_following_commands() {
let (status, state) = run_and_capture("continue\nAFTER=1\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("AFTER"), Some("1"));
}
#[test]
fn return_outside_function_fails_without_aborting_following_commands() {
let (status, state) = run_and_capture("return 7\nAFTER=1\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("AFTER"), Some("1"));
assert_eq!(state.exit_code, -1);
}
#[test]
fn break_zero_is_rejected() {
let (status, state) = run_and_capture("for x in 1; do break 0; done\nAFTER=1\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("AFTER"), Some("1"));
}
#[test]
fn continue_zero_is_rejected() {
let (status, state) = run_and_capture("for x in 1; do continue 0; done\nAFTER=1\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("AFTER"), Some("1"));
}
#[test]
fn until_loop() {
let (_, state) = run_and_capture("I=0; until [ $I -ge 3 ]; do I=$((I+1)); done");
assert_eq!(state.env_get("I"), Some("3"));
}
#[test]
fn arithmetic_ternary() {
let (_, state) = run_and_capture("X=$((1 ? 10 : 20))");
assert_eq!(state.env_get("X"), Some("10"));
let (_, state) = run_and_capture("X=$((0 ? 10 : 20))");
assert_eq!(state.env_get("X"), Some("20"));
}
#[test]
fn arithmetic_assignment() {
let (_, state) = run_and_capture("X=$((Y=42))");
assert_eq!(state.env_get("X"), Some("42"));
assert_eq!(state.env_get("Y"), Some("42"));
}
#[test]
fn arithmetic_division_by_zero() {
let (status, _out, err, state) = run_and_capture_output_and_error("X=$((10 / 0))");
assert_eq!(status, 2);
assert_eq!(state.env_get("X"), None);
assert!(err.contains("arithmetic expansion: division by zero"));
}
#[test]
fn arithmetic_modulo_by_zero() {
let (status, _out, err, state) = run_and_capture_output_and_error("X=$((10 % 0))");
assert_eq!(status, 2);
assert_eq!(state.env_get("X"), None);
assert!(err.contains("arithmetic expansion: division by zero"));
}
#[test]
fn arithmetic_invalid_shift_count() {
let (status, _out, err, state) = run_and_capture_output_and_error("X=$((1 << -1))");
assert_eq!(status, 2);
assert_eq!(state.env_get("X"), None);
assert!(err.contains("arithmetic expansion: invalid shift count"));
}
#[test]
fn arithmetic_bitwise_ops() {
let (_, state) = run_and_capture("X=$((0xFF & 0x0F))");
assert_eq!(state.env_get("X"), Some("15"));
let (_, state) = run_and_capture("X=$((0xF0 | 0x0F))");
assert_eq!(state.env_get("X"), Some("255"));
let (_, state) = run_and_capture("X=$((0xFF ^ 0x0F))");
assert_eq!(state.env_get("X"), Some("240"));
}
#[test]
fn arithmetic_shift_ops() {
let (_, state) = run_and_capture("X=$((1 << 4))");
assert_eq!(state.env_get("X"), Some("16"));
let (_, state) = run_and_capture("X=$((16 >> 2))");
assert_eq!(state.env_get("X"), Some("4"));
}
#[test]
fn arithmetic_comparison_ops() {
let (_, state) = run_and_capture("X=$((3 < 5))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((5 < 3))");
assert_eq!(state.env_get("X"), Some("0"));
let (_, state) = run_and_capture("X=$((3 <= 3))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((3 == 3))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((3 != 4))");
assert_eq!(state.env_get("X"), Some("1"));
}
#[test]
fn arithmetic_logical_ops() {
let (_, state) = run_and_capture("X=$((1 && 1))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((1 && 0))");
assert_eq!(state.env_get("X"), Some("0"));
let (_, state) = run_and_capture("X=$((0 || 1))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((0 || 0))");
assert_eq!(state.env_get("X"), Some("0"));
}
#[test]
fn arithmetic_unary_ops() {
let (_, state) = run_and_capture("X=$((-5))");
assert_eq!(state.env_get("X"), Some("-5"));
let (_, state) = run_and_capture("X=$((~0))");
assert_eq!(state.env_get("X"), Some("-1"));
let (_, state) = run_and_capture("X=$((!0))");
assert_eq!(state.env_get("X"), Some("1"));
let (_, state) = run_and_capture("X=$((!1))");
assert_eq!(state.env_get("X"), Some("0"));
}
#[test]
fn arithmetic_variable_reference() {
let (_, state) = run_and_capture("A=10; X=$((A + 5))");
assert_eq!(state.env_get("X"), Some("15"));
}
#[test]
fn builtin_export_sets_export_flag() {
let (_, state) = run_and_capture("X=hello; export X");
let exported: Vec<_> = state
.exported_env()
.into_iter()
.filter(|(k, _)| k == "X")
.collect();
assert_eq!(exported.len(), 1);
assert_eq!(exported[0].1, "hello");
}
#[test]
fn builtin_export_with_assignment() {
let (_, state) = run_and_capture("export X=world");
let exported: Vec<_> = state
.exported_env()
.into_iter()
.filter(|(k, _)| k == "X")
.collect();
assert_eq!(exported.len(), 1);
assert_eq!(exported[0].1, "world");
}
#[test]
fn builtin_readonly_prevents_modification() {
let (status, state) = run_and_capture("X=first; readonly X; X=second");
assert_eq!(status, 1, "readonly assignment should fail");
assert_eq!(
state.env_get("X"),
Some("first"),
"readonly should prevent modification"
);
}
#[test]
fn readonly_prefix_assignment_fails_before_command_runs() {
let (_, state) = run_and_capture("readonly X=1; X=2 true; STATUS=$?; AFTER=1");
assert_eq!(
state.env_get("STATUS"),
Some("1"),
"readonly prefix assignment should fail"
);
assert_eq!(state.env_get("X"), Some("1"));
assert_eq!(state.env_get("AFTER"), Some("1"));
}
#[test]
fn builtin_unset_removes_variable() {
let (_, state) = run_and_capture("X=hello; unset X");
assert_eq!(state.env_get("X"), None);
}
#[test]
fn builtin_unset_function() {
let (status, _) = run_and_capture("f() { :; }; unset -f f; f");
assert_eq!(status, 127, "calling unset function should fail");
}
#[test]
fn builtin_unset_readonly_kept() {
let (_, state) = run_and_capture("X=keep; readonly X; unset X");
assert_eq!(
state.env_get("X"),
Some("keep"),
"unset should not remove readonly"
);
}
#[test]
fn builtin_shift_removes_positional() {
let (_, state) = run_and_capture("set -- a b c; shift; R=$1");
assert_eq!(state.env_get("R"), Some("b"));
}
#[test]
fn builtin_shift_too_many() {
let (status, _) = run_and_capture("set -- a; shift 5");
assert_eq!(status, 2, "shifting more than available should fail");
}
#[test]
fn builtin_read_trims_leading_ifs_whitespace() {
let (status, state) = run_and_capture_with_stdin("read X Y", " a b\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a"));
assert_eq!(state.env_get("Y"), Some("b"));
}
#[test]
fn builtin_read_last_variable_absorbs_remaining_fields_without_padding() {
let (status, state) = run_and_capture_with_stdin("read X Y", "a b c\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a"));
assert_eq!(state.env_get("Y"), Some("b c"));
}
#[test]
fn builtin_read_preserves_empty_field_for_non_whitespace_ifs() {
let (status, state) = run_and_capture_with_stdin("IFS=,; read X Y Z", "a,,b\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a"));
assert_eq!(state.env_get("Y"), Some(""));
assert_eq!(state.env_get("Z"), Some("b"));
}
#[test]
fn builtin_read_joins_lines_ending_in_backslash() {
let (status, state) = run_and_capture_with_stdin("read X", "a\\\nb\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("ab"));
}
#[test]
fn builtin_read_eof_after_line_continuation_returns_nonzero() {
let (status, state) = run_and_capture_with_stdin("read X", "a\\");
assert_eq!(status, 1);
assert_eq!(state.env_get("X"), Some("a"));
}
#[test]
fn builtin_read_immediate_eof_assigns_empty_and_returns_nonzero() {
let (status, state) = run_and_capture_with_stdin("read X Y", "");
assert_eq!(status, 1);
assert_eq!(state.env_get("X"), Some(""));
assert_eq!(state.env_get("Y"), Some(""));
}
#[test]
fn builtin_read_newline_after_continuation_without_followup_returns_nonzero() {
let (status, state) = run_and_capture_with_stdin("read X", "\\\n");
assert_eq!(status, 1);
assert_eq!(state.env_get("X"), Some(""));
}
#[test]
fn builtin_read_unescapes_backslash_space_without_raw_mode() {
let (status, state) = run_and_capture_with_stdin("read X", "a\\ b\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a b"));
}
#[test]
fn builtin_read_trims_trailing_escaped_whitespace_from_last_field() {
let (status, state) = run_and_capture_with_stdin("read X", "a \\ \n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a"));
}
#[test]
fn builtin_read_unescapes_backslash_character_without_raw_mode() {
let (status, state) = run_and_capture_with_stdin("read X", "a\\c\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("ac"));
}
#[test]
fn builtin_read_keeps_escaped_whitespace_in_same_field() {
let (status, state) = run_and_capture_with_stdin("read X Y", "a\\ b c\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a b"));
assert_eq!(state.env_get("Y"), Some("c"));
}
#[test]
fn builtin_read_keeps_escaped_non_whitespace_ifs_in_same_field() {
let (status, state) = run_and_capture_with_stdin("IFS=,; read X Y", "a\\,b,c\n");
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("a,b"));
assert_eq!(state.env_get("Y"), Some("c"));
}
#[test]
fn builtin_read_trims_trailing_non_whitespace_ifs_from_last_field() {
let (status, state) = run_and_capture_with_stdin("IFS=,; read X", "a,");
assert_eq!(status, 1);
assert_eq!(state.env_get("X"), Some("a"));
}
#[test]
fn builtin_read_fails_for_readonly_variable() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let stdin = sys::StringStdioIn::new("updated\n");
let stderr = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdin_fd = stdin.fd();
state.stderr_fd = stderr.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(
&mut state,
&mut runtime,
"readonly X=orig\nread X\nAFTER=1\n",
);
state.last_status = status;
let error = stderr.collect();
stdin.join();
assert_eq!(status, 0);
assert_eq!(state.env_get("X"), Some("orig"));
assert_eq!(state.env_get("AFTER"), Some("1"));
assert!(error.contains("read: X: readonly variable"));
}
#[test]
fn read_model_empty_ifs_preserves_entire_line() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: " a b".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: "",
raw_mode: true,
var_count: 2,
},
),
shell_read::ReadResult {
status: 0,
fields: vec![" a b".to_string(), String::new()],
}
);
}
#[test]
fn read_model_trims_ifs_whitespace_and_collapses_runs() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: " a b c ".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: " \t\n",
raw_mode: true,
var_count: 2,
},
),
shell_read::ReadResult {
status: 0,
fields: vec!["a".to_string(), "b c".to_string()],
}
);
}
#[test]
fn read_model_non_whitespace_ifs_can_yield_empty_field() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: "a,,b".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: ",",
raw_mode: true,
var_count: 3,
},
),
shell_read::ReadResult {
status: 0,
fields: vec!["a".to_string(), String::new(), "b".to_string()],
}
);
}
#[test]
fn read_model_nonraw_keeps_escaped_non_whitespace_ifs_in_same_field() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: "a\\,b,c".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: ",",
raw_mode: false,
var_count: 2,
},
),
shell_read::ReadResult {
status: 0,
fields: vec!["a,b".to_string(), "c".to_string()],
}
);
}
#[test]
fn read_model_nonraw_trims_escaped_whitespace_from_last_field() {
assert_eq!(
shell_read::read_model(
shell_read::ReadInput {
line: "a \\ ".to_string(),
terminated_by_newline: true,
eof_after_continuation: false,
eof_after_newline_continuation: false,
},
shell_read::ReadConfig {
ifs: " \t\n",
raw_mode: false,
var_count: 1,
},
),
shell_read::ReadResult {
status: 0,
fields: vec!["a".to_string()],
}
);
}
#[test]
fn builtin_getopts_consumes_clustered_short_options() {
let (status, state) = run_and_capture(
"set -- -ab\n\
getopts ab OPT\n\
FIRST=\"$OPT:$OPTIND\"\n\
getopts ab OPT\n\
SECOND=\"$OPT:$OPTIND\"",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("FIRST"), Some("a:1"));
assert_eq!(state.env_get("SECOND"), Some("b:2"));
}
#[test]
fn builtin_getopts_accepts_inline_and_separate_option_arguments() {
let (status, state) = run_and_capture(
"set -- -fvalue -f next\n\
getopts f: OPT\n\
FIRST=\"$OPT:$OPTARG:$OPTIND\"\n\
getopts f: OPT\n\
SECOND=\"$OPT:$OPTARG:$OPTIND\"",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("FIRST"), Some("f:value:2"));
assert_eq!(state.env_get("SECOND"), Some("f:next:4"));
}
#[test]
fn builtin_getopts_resets_cluster_cursor_when_optind_is_reset() {
let (status, state) = run_and_capture(
"set -- -ab\n\
getopts ab OPT\n\
OPTIND=1\n\
getopts ab OPT\n\
RESULT=\"$OPT:$OPTIND\"",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("a:1"));
}
#[test]
fn builtin_getopts_treats_zero_optind_as_one() {
let (status, state) =
run_and_capture("set -- -ab\nOPTIND=0\ngetopts ab OPT\nRESULT=\"$OPT:$OPTIND:$?\"");
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("a:1:0"));
}
#[test]
fn builtin_getopts_normalizes_optind_when_past_end() {
let (status, state) =
run_and_capture("set -- -a\nOPTIND=999\ngetopts ab OPT\nRESULT=\"$OPT:$OPTIND:$?\"");
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("?:2:1"));
}
#[test]
fn builtin_getopts_silent_mode_captures_unknown_option_without_stderr() {
let (status, _, error, state) =
run_and_capture_output_and_error("set -- -z\ngetopts :ab OPT\nRESULT=\"$OPT:$OPTARG:$?\"");
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("?:z:0"));
assert!(
error.is_empty(),
"expected silent getopts error, got {error:?}"
);
}
#[test]
fn builtin_getopts_silent_mode_captures_missing_argument_without_stderr() {
let (status, _, error, state) =
run_and_capture_output_and_error("set -- -f\ngetopts :f: OPT\nRESULT=\"$OPT:$OPTARG:$?\"");
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("::f:0"));
assert!(
error.is_empty(),
"expected silent getopts error, got {error:?}"
);
}
#[test]
fn builtin_getopts_missing_argument_matches_standard_status() {
let (status, _, error, state) = run_and_capture_output_and_error(
"set -- -f\ngetopts f: OPT\nRESULT=\"$OPT:${OPTARG-unset}:$?\"",
);
assert_eq!(status, 0);
assert_eq!(state.env_get("RESULT"), Some("?:unset:0"));
assert!(error.contains("getopts: option requires argument -- f"));
}
#[test]
fn builtin_eval_executes_string() {
let (_, state) = run_and_capture("X=hello; eval 'Y=$X'");
assert_eq!(state.env_get("Y"), Some("hello"));
}
#[test]
fn builtin_dot_searches_path_for_readable_non_executable_file() {
let nonce = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system time must be after unix epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("mxsh-dot-{}-{nonce}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).expect("temp dir must be creatable");
let path = dir.join("dotfile");
std::fs::write(&path, "DOT_RESULT=loaded\n").expect("dot script must be writable");
let mut perms = std::fs::metadata(&path)
.expect("dot script metadata")
.permissions();
perms.set_mode(0o644);
std::fs::set_permissions(&path, perms).expect("dot script permissions must be writable");
let script = format!("PATH={}; . dotfile", dir.display());
let (_, state) = run_and_capture(&script);
assert_eq!(state.env_get("DOT_RESULT"), Some("loaded"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn builtin_set_positional_params() {
let (_, state) = run_and_capture("set -- x y z; R=$2");
assert_eq!(state.env_get("R"), Some("y"));
}
#[test]
fn builtin_set_option_errexit() {
let (_, state) = run_and_capture("set -e; false; AFTER=1");
assert_eq!(
state.env_get("AFTER"),
None,
"set -e should stop on failure"
);
}
#[test]
fn pwd_assignment_is_not_blocked_by_shell_startup() {
let (_, state) = run_and_capture("PWD=/tmp");
assert_eq!(state.env_get("PWD"), Some("/tmp"));
}
#[test]
fn cd_updates_pwd_and_oldpwd_without_making_pwd_readonly() {
let original = std::env::current_dir().expect("current dir");
let tmp = std::env::temp_dir();
let script = format!("cd {}\nPWD=/override\n", tmp.display());
let (_, state) = run_and_capture(&script);
assert_eq!(
state.env_get("OLDPWD"),
Some(original.to_string_lossy().as_ref())
);
assert_eq!(state.env_get("PWD"), Some("/override"));
}
#[test]
fn exec_uses_shell_cwd() {
let tmp = std::env::temp_dir();
let canonical_tmp = std::fs::canonicalize(&tmp).expect("temp dir should canonicalize");
let mut state = ShellState::new();
state.populate_env();
let mut runtime = ExecCaptureRuntime::default();
let script = format!("cd {}\nexec fakecmd\n", tmp.display());
let status = run_string(&mut state, &mut runtime, &script);
assert_eq!(status, 126);
assert_eq!(
runtime.cwd.lock().unwrap().clone(),
Some(canonical_tmp),
"exec should receive the shell cwd after cd"
);
}
#[test]
fn special_parameter_hash() {
let mut state = ShellState::new();
state.frame = vec!["mxsh".into(), "a".into(), "b".into()];
assert_eq!(
shell_expand::get_parameter_value(&state, "#"),
Some("2".to_string())
);
}
#[test]
fn special_parameter_question() {
let mut state = ShellState::new();
state.last_status = 42;
assert_eq!(
shell_expand::get_parameter_value(&state, "?"),
Some("42".to_string())
);
}
#[test]
fn special_parameter_zero() {
let mut state = ShellState::new();
state.frame = vec!["myshell".into()];
assert_eq!(
shell_expand::get_parameter_value(&state, "0"),
Some("myshell".to_string())
);
}
#[test]
fn special_parameter_at_star() {
let mut state = ShellState::new();
state.frame = vec!["sh".into(), "a".into(), "b".into(), "c".into()];
state.env_set("IFS", " \t\n".to_string(), 0);
assert_eq!(
shell_expand::get_parameter_value(&state, "@"),
Some("a b c".to_string())
);
assert_eq!(
shell_expand::get_parameter_value(&state, "*"),
Some("a b c".to_string())
);
assert_eq!(shell_expand::quoted_star_value(&state), "a b c");
}
#[test]
fn special_parameter_at_star_custom_ifs() {
let mut state = ShellState::new();
state.frame = vec!["sh".into(), "a".into(), "b".into(), "c".into()];
state.env_set("IFS", ":".to_string(), 0);
assert_eq!(
shell_expand::get_parameter_value(&state, "@"),
Some("a:b:c".to_string())
);
assert_eq!(
shell_expand::get_parameter_value(&state, "*"),
Some("a:b:c".to_string())
);
assert_eq!(shell_expand::quoted_star_value(&state), "a:b:c");
}
#[test]
fn special_parameter_at_star_empty_ifs() {
let mut state = ShellState::new();
state.frame = vec!["sh".into(), "a".into(), "b".into(), "c".into()];
state.env_set("IFS", "".to_string(), 0);
assert_eq!(
shell_expand::get_parameter_value(&state, "@"),
Some("abc".to_string())
);
assert_eq!(
shell_expand::get_parameter_value(&state, "*"),
Some("abc".to_string())
);
assert_eq!(shell_expand::quoted_star_value(&state), "abc");
}
#[test]
fn quoted_dollar_at_empty_expands_to_no_fields() {
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
state.frame = vec!["sh".into()];
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_list! {
children: vec![word_parameter! {
name: "@".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
}],
double_quoted: true,
range: Range::default(),
}],
);
assert_eq!(argv, vec!["printf".to_string()]);
}
#[test]
fn mixed_quoted_and_unquoted_segments_split_fields() {
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
state.frame = vec!["sh".into(), "a b".into()];
state.env_set("IFS", " \t\n".to_string(), 0);
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_list! {
children: vec![
word_list! {
children: vec![word_string! {
value: "p".to_string(),
single_quoted: false,
split_fields: true,
source: Some("p".to_string()),
range: Range::default(),
}],
double_quoted: true,
range: Range::default(),
},
word_parameter! {
name: "1".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
},
],
double_quoted: false,
range: Range::default(),
}],
);
assert_eq!(
argv,
vec!["printf".to_string(), "pa".to_string(), "b".to_string()]
);
}
#[test]
fn mixed_quoted_prefix_and_unquoted_dollar_at_preserve_fields() {
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
state.frame = vec!["sh".into(), "a".into(), "b".into()];
state.env_set("IFS", " \t\n".to_string(), 0);
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_list! {
children: vec![
word_list! {
children: vec![word_string! {
value: "p".to_string(),
single_quoted: false,
split_fields: true,
source: Some("p".to_string()),
range: Range::default(),
}],
double_quoted: true,
range: Range::default(),
},
word_parameter! {
name: "@".to_string(),
op: ParameterOp::None,
colon: false,
arg: None,
dollar_pos: Position::default(),
brace_end: None,
},
],
double_quoted: false,
range: Range::default(),
}],
);
assert_eq!(
argv,
vec!["printf".to_string(), "pa".to_string(), "b".to_string()]
);
}
#[test]
fn escaped_tilde_does_not_expand() {
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
state.env_set("HOME", "/tmp/home".to_string(), 0);
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_string! {
value: "~".to_string(),
single_quoted: false,
split_fields: true,
source: Some("\\~".to_string()),
range: Range::default(),
}],
);
assert_eq!(argv, vec!["printf".to_string(), "~".to_string()]);
}
#[test]
fn escaped_glob_does_not_expand() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let dir = temp_path("escaped-glob");
std::fs::create_dir_all(&dir).expect("create temp dir");
std::fs::write(dir.join("alpha"), b"alpha").expect("write alpha");
std::fs::write(dir.join("beta"), b"beta").expect("write beta");
let original = std::env::current_dir().expect("current dir");
struct CurrentDirGuard(PathBuf);
impl Drop for CurrentDirGuard {
fn drop(&mut self) {
let _ = std::env::set_current_dir(&self.0);
}
}
let restore = CurrentDirGuard(original);
std::env::set_current_dir(&dir).expect("set current dir");
let mut state = ShellState::new();
let mut runtime = sys::InMemoryRuntime::new();
let argv = shell_expand::build_argv(
&mut state,
&mut runtime,
"printf".to_string(),
&[word_string! {
value: "*".to_string(),
single_quoted: false,
split_fields: true,
source: Some("\\*".to_string()),
range: Range::default(),
}],
);
assert_eq!(argv, vec!["printf".to_string(), "*".to_string()]);
drop(restore);
std::fs::remove_dir_all(&dir).expect("remove temp dir");
}
#[test]
fn special_parameter_dash() {
let mut state = ShellState::new();
state.options = OPT_ERREXIT | OPT_XTRACE;
let val = shell_expand::get_parameter_value(&state, "-").unwrap();
assert!(val.contains('e'), "$- must contain 'e' for errexit");
assert!(val.contains('x'), "$- must contain 'x' for xtrace");
}
#[test]
fn positional_parameter_numeric() {
let mut state = ShellState::new();
state.frame = vec!["sh".into(), "first".into(), "second".into()];
assert_eq!(
shell_expand::get_parameter_value(&state, "1"),
Some("first".to_string())
);
assert_eq!(
shell_expand::get_parameter_value(&state, "2"),
Some("second".to_string())
);
assert_eq!(shell_expand::get_parameter_value(&state, "3"), None);
}
#[test]
fn format_options_empty() {
assert_eq!(format_options(0), "");
}
#[test]
fn format_options_multiple() {
let s = format_options(OPT_ALLEXPORT | OPT_ERREXIT);
assert!(s.contains('a'));
assert!(s.contains('e'));
}
#[test]
fn split_fields_default_ifs() {
let mut state = ShellState::new();
state.env_set("IFS", " \t\n".to_string(), 0);
let fields = shell_expand::split_fields(&state, "hello world\tfoo");
assert_eq!(fields, vec!["hello", "world", "foo"]);
}
#[test]
fn split_fields_custom_ifs() {
let mut state = ShellState::new();
state.env_set("IFS", ":".to_string(), 0);
let fields = shell_expand::split_fields(&state, "a:b:c");
assert_eq!(fields, vec!["a", "b", "c"]);
}
#[test]
fn split_fields_empty_ifs() {
let mut state = ShellState::new();
state.env_set("IFS", "".to_string(), 0);
let fields = shell_expand::split_fields(&state, "hello world");
assert_eq!(fields, vec!["hello world"]);
}
#[test]
fn eval_arithm_binop_basic() {
assert_eq!(eval_arithm_binop(ArithmBinOp::Add, 3, 4), Ok(7));
assert_eq!(eval_arithm_binop(ArithmBinOp::Sub, 10, 3), Ok(7));
assert_eq!(eval_arithm_binop(ArithmBinOp::Mul, 3, 4), Ok(12));
assert_eq!(eval_arithm_binop(ArithmBinOp::Div, 10, 3), Ok(3));
assert_eq!(eval_arithm_binop(ArithmBinOp::Mod, 10, 3), Ok(1));
}
#[test]
fn eval_arithm_binop_division_by_zero() {
assert_eq!(
eval_arithm_binop(ArithmBinOp::Div, 10, 0),
Err("division by zero")
);
assert_eq!(
eval_arithm_binop(ArithmBinOp::Mod, 10, 0),
Err("division by zero")
);
}
#[test]
fn eval_arithm_binop_bitwise() {
assert_eq!(eval_arithm_binop(ArithmBinOp::BitAnd, 0xFF, 0x0F), Ok(0x0F));
assert_eq!(eval_arithm_binop(ArithmBinOp::BitOr, 0xF0, 0x0F), Ok(0xFF));
assert_eq!(eval_arithm_binop(ArithmBinOp::BitXor, 0xFF, 0x0F), Ok(0xF0));
assert_eq!(eval_arithm_binop(ArithmBinOp::Shl, 1, 4), Ok(16));
assert_eq!(eval_arithm_binop(ArithmBinOp::Shr, 16, 2), Ok(4));
assert_eq!(
eval_arithm_binop(ArithmBinOp::Shl, 1, -1),
Err("invalid shift count")
);
}
#[test]
fn eval_arithm_binop_comparison() {
assert_eq!(eval_arithm_binop(ArithmBinOp::LessThan, 3, 5), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::LessThan, 5, 3), Ok(0));
assert_eq!(eval_arithm_binop(ArithmBinOp::LessEq, 3, 3), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::GreaterThan, 5, 3), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::GreaterEq, 3, 3), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::Equal, 5, 5), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::NotEqual, 5, 3), Ok(1));
}
#[test]
fn eval_arithm_binop_logical() {
assert_eq!(eval_arithm_binop(ArithmBinOp::LogAnd, 1, 1), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::LogAnd, 1, 0), Ok(0));
assert_eq!(eval_arithm_binop(ArithmBinOp::LogOr, 0, 1), Ok(1));
assert_eq!(eval_arithm_binop(ArithmBinOp::LogOr, 0, 0), Ok(0));
}
#[test]
fn eval_assign_op_basic() {
assert_eq!(eval_assign_op(ArithmAssignOp::Equal, 5, 10), Ok(10));
assert_eq!(eval_assign_op(ArithmAssignOp::AddEq, 5, 3), Ok(8));
assert_eq!(eval_assign_op(ArithmAssignOp::SubEq, 5, 3), Ok(2));
assert_eq!(eval_assign_op(ArithmAssignOp::MulEq, 5, 3), Ok(15));
assert_eq!(eval_assign_op(ArithmAssignOp::DivEq, 10, 3), Ok(3));
assert_eq!(eval_assign_op(ArithmAssignOp::ModEq, 10, 3), Ok(1));
}
#[test]
fn eval_assign_op_division_by_zero() {
assert_eq!(
eval_assign_op(ArithmAssignOp::DivEq, 10, 0),
Err("division by zero")
);
assert_eq!(
eval_assign_op(ArithmAssignOp::ModEq, 10, 0),
Err("division by zero")
);
}
#[test]
fn eval_assign_op_bitwise() {
assert_eq!(eval_assign_op(ArithmAssignOp::AndEq, 0xFF, 0x0F), Ok(0x0F));
assert_eq!(eval_assign_op(ArithmAssignOp::OrEq, 0xF0, 0x0F), Ok(0xFF));
assert_eq!(eval_assign_op(ArithmAssignOp::XorEq, 0xFF, 0x0F), Ok(0xF0));
assert_eq!(eval_assign_op(ArithmAssignOp::ShlEq, 1, 4), Ok(16));
assert_eq!(eval_assign_op(ArithmAssignOp::ShrEq, 16, 2), Ok(4));
assert_eq!(
eval_assign_op(ArithmAssignOp::ShlEq, 1, -1),
Err("invalid shift count")
);
}
#[test]
fn match_bracket_class_range() {
let (matched, consumed) = shell_expand::match_bracket_class(b"[a-z]rest", b'm').unwrap();
assert!(matched);
assert_eq!(consumed, 5);
}
#[test]
fn match_bracket_class_no_match() {
let (matched, _) = shell_expand::match_bracket_class(b"[a-z]", b'5').unwrap();
assert!(!matched);
}
#[test]
fn match_bracket_class_negated() {
let (matched, _) = shell_expand::match_bracket_class(b"[!a-z]", b'5').unwrap();
assert!(matched);
}
#[test]
fn match_bracket_class_unclosed_returns_none() {
assert!(shell_expand::match_bracket_class(b"[a-z", b'm').is_none());
}
#[test]
fn parse_trap_signal_numeric() {
assert_eq!(shell_traps::parse_trap_signal("0"), Some(TRAP_EXIT));
assert_eq!(shell_traps::parse_trap_signal("2"), Some(2));
}
#[test]
fn parse_trap_signal_named() {
assert_eq!(shell_traps::parse_trap_signal("EXIT"), Some(TRAP_EXIT));
assert_eq!(shell_traps::parse_trap_signal("INT"), Some(libc::SIGINT));
assert_eq!(shell_traps::parse_trap_signal("HUP"), Some(libc::SIGHUP));
assert_eq!(shell_traps::parse_trap_signal("TERM"), Some(libc::SIGTERM));
}
#[test]
fn parse_trap_signal_with_sig_prefix() {
assert_eq!(shell_traps::parse_trap_signal("SIGINT"), Some(libc::SIGINT));
assert_eq!(
shell_traps::parse_trap_signal("SIGTERM"),
Some(libc::SIGTERM)
);
}
#[test]
fn parse_trap_signal_unknown() {
assert_eq!(shell_traps::parse_trap_signal("BOGUS"), None);
}
#[test]
fn trap_signal_name_known() {
assert_eq!(shell_traps::trap_signal_name(TRAP_EXIT), "EXIT");
assert_eq!(shell_traps::trap_signal_name(libc::SIGINT), "INT");
assert_eq!(shell_traps::trap_signal_name(libc::SIGTERM), "TERM");
}
#[test]
fn trap_signal_name_unknown() {
assert_eq!(shell_traps::trap_signal_name(999), "?");
}
#[test]
fn parse_builtin_known() {
let state = ShellState::new();
assert!(matches!(
shell_builtins::lookup_builtin(&state, ":"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::Colon,
properties,
}) if properties == BuiltinProperties::special()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "true"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::True,
properties,
}) if properties == BuiltinProperties::regular()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "false"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::False,
properties,
}) if properties == BuiltinProperties::regular()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "cd"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::Cd,
properties,
}) if properties == BuiltinProperties::regular()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "export"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::Export,
properties,
}) if properties == BuiltinProperties::special()
));
assert!(matches!(
shell_builtins::lookup_builtin(&state, "break"),
Some(RegisteredBuiltin::Standard {
kind: shell_builtins::Builtin::Break,
properties,
}) if properties == BuiltinProperties::special()
));
}
#[test]
fn parse_builtin_unknown() {
let state = ShellState::new();
assert!(shell_builtins::lookup_builtin(&state, "notabuiltin").is_none());
}
#[test]
fn is_builtin_known() {
let state = ShellState::new();
assert!(shell_builtins::is_builtin_in(&state, "cd"));
assert!(shell_builtins::is_builtin_in(&state, "export"));
assert!(shell_builtins::is_builtin_in(&state, "true"));
}
#[test]
fn is_builtin_unknown() {
let state = ShellState::new();
assert!(!shell_builtins::is_builtin_in(&state, "ls"));
assert!(!shell_builtins::is_builtin_in(&state, "cat"));
}
#[test]
fn is_special_builtin_true() {
let state = ShellState::new();
assert!(shell_builtins::is_special_builtin_in(&state, ":"));
assert!(shell_builtins::is_special_builtin_in(&state, "."));
assert!(shell_builtins::is_special_builtin_in(&state, "export"));
assert!(shell_builtins::is_special_builtin_in(&state, "set"));
assert!(shell_builtins::is_special_builtin_in(&state, "trap"));
}
#[test]
fn is_special_builtin_false() {
let state = ShellState::new();
assert!(!shell_builtins::is_special_builtin_in(&state, "cd"));
assert!(!shell_builtins::is_special_builtin_in(&state, "alias"));
assert!(!shell_builtins::is_special_builtin_in(&state, "command"));
}
#[test]
fn env_set_and_get() {
let mut state = ShellState::new();
state.env_set("FOO", "bar".to_string(), 0);
assert_eq!(state.env_get("FOO"), Some("bar"));
}
#[test]
fn env_set_readonly_ignored() {
let mut state = ShellState::new();
state.env_set("FOO", "first".to_string(), VAR_READONLY);
state.env_set("FOO", "second".to_string(), 0);
assert_eq!(state.env_get("FOO"), Some("first"));
}
#[test]
fn env_unset_respects_readonly() {
let mut state = ShellState::new();
state.env_set("FOO", "keep".to_string(), VAR_READONLY);
state.env_unset("FOO");
assert_eq!(state.env_get("FOO"), Some("keep"));
}
#[test]
fn exported_env_filters_correctly() {
let mut state = ShellState::new();
state.env_set("EXPORTED", "yes".to_string(), VAR_EXPORT);
state.env_set("LOCAL", "no".to_string(), 0);
let exported = state.exported_env();
assert!(exported.iter().any(|(k, _)| k == "EXPORTED"));
assert!(!exported.iter().any(|(k, _)| k == "LOCAL"));
}
#[test]
fn has_option_works() {
let mut state = ShellState::new();
assert!(!state.has_option(OPT_ERREXIT));
state.options |= OPT_ERREXIT;
assert!(state.has_option(OPT_ERREXIT));
}
#[test]
fn special_parameter_names() {
assert!(shell_expand::is_special_parameter_name("@"));
assert!(shell_expand::is_special_parameter_name("*"));
assert!(shell_expand::is_special_parameter_name("#"));
assert!(shell_expand::is_special_parameter_name("?"));
assert!(shell_expand::is_special_parameter_name("-"));
assert!(shell_expand::is_special_parameter_name("$"));
assert!(shell_expand::is_special_parameter_name("!"));
assert!(shell_expand::is_special_parameter_name("0"));
assert!(shell_expand::is_special_parameter_name("1"));
assert!(shell_expand::is_special_parameter_name("99"));
}
#[test]
fn non_special_parameter_names() {
assert!(!shell_expand::is_special_parameter_name("FOO"));
assert!(!shell_expand::is_special_parameter_name("HOME"));
}
#[test]
fn expand_tilde_no_tilde() {
let state = ShellState::new();
assert_eq!(
shell_expand::expand_tilde(&state, "/usr/local"),
"/usr/local"
);
}
#[test]
fn errexit_stops_on_failure() {
let (_, state) = run_and_capture("set -e; false; AFTER=1");
assert_eq!(
state.env_get("AFTER"),
None,
"errexit should stop execution"
);
}
#[test]
fn set_parse_error_does_not_commit_partial_options() {
let (status, state) = run_and_capture("set -uZ");
assert_eq!(status, 1);
assert!(!state.has_option(OPT_NOUNSET));
}
#[test]
fn return_invalid_numeric_argument_reports_error() {
let (status, output, _state) = run_and_capture_output("f() { return abc; }; f; echo status:$?");
assert_eq!(status, 0);
assert_eq!(output, "status:2\n");
}
#[test]
fn exit_invalid_numeric_argument_exits_with_error() {
let _guard = trap_test_lock()
.lock()
.unwrap_or_else(|err| err.into_inner());
let code = run(&["mxsh".to_string(), "-c".to_string(), "exit abc".to_string()]);
assert_eq!(code, 2);
}
#[test]
fn monitor_mode_disable_restores_child_signal_defaults() {
let script = "set -m; set +m; python3 -c 'import signal; print(signal.getsignal(signal.SIGTTOU) == signal.SIG_IGN)'";
let (status, output, _state) = run_and_capture_output(script);
assert_eq!(status, 0);
assert_eq!(output.trim(), "False");
}
#[test]
fn subshell_does_not_leak_to_parent() {
let (_, state) = run_and_capture("X=outer; (X=inner); R=$X");
assert_eq!(state.env_get("R"), Some("outer"));
}
#[test]
fn command_substitution_does_not_leak_to_parent() {
let (_, state) = run_and_capture("X=outer; Y=$(X=inner; echo ok); R=$X");
assert_eq!(state.env_get("X"), Some("outer"));
assert_eq!(state.env_get("R"), Some("outer"));
}
#[test]
fn command_substitution_does_not_leak_into_pipeline_output() {
let output = sys::StringStdioOut::new();
let mut state = ShellState::new();
state.populate_env();
state.stdout_fd = output.fd();
let mut runtime = sys::UnixRuntime::new();
let status = run_string(&mut state, &mut runtime, ": \"$(cd /; pwd)\" | cat");
assert_eq!(status, 0);
assert_eq!(output.collect().trim(), "");
}
#[test]
fn allexport_marks_new_assignments() {
let (_, state) = run_and_capture("set -a; NEWVAR=exported_val");
let exported = state.exported_env();
assert!(
exported
.iter()
.any(|(k, v)| k == "NEWVAR" && v == "exported_val"),
"set -a should auto-export new assignments"
);
}
#[test]
fn pipeline_bang_negation() {
let (status, _) = run_and_capture("! false");
assert_eq!(status, 0, "! false should return 0");
let (status, _) = run_and_capture("! true");
assert_eq!(status, 1, "! true should return 1");
}
#[test]
fn function_receives_arguments() {
let (_, state) = run_and_capture("f() { R=$1; S=$2; }; f hello world");
assert_eq!(state.env_get("R"), Some("hello"));
assert_eq!(state.env_get("S"), Some("world"));
}
#[test]
fn function_restores_positional_params() {
let (_, state) = run_and_capture("set -- outer; f() { :; }; f inner; R=$1");
assert_eq!(
state.env_get("R"),
Some("outer"),
"positional params must be restored after function call"
);
}
#[test]
fn exit_with_code() {
let (_, state) = run_and_capture("exit 42");
assert_eq!(state.exit_code, 42);
}
#[test]
fn exit_without_code_uses_last_status() {
let (_, state) = run_and_capture("false; exit");
assert_eq!(state.exit_code, 1);
}
#[test]
fn prefix_assignment_is_temporary_for_regular_builtin() {
let (_, state) = run_and_capture("X=keep; X=temp command true; R=$X");
assert_eq!(
state.env_get("R"),
Some("keep"),
"prefix assignment on non-special builtin must be temporary"
);
}
#[test]
fn colon_builtin_succeeds() {
let (status, _) = run_and_capture(":");
assert_eq!(status, 0);
}
#[test]
fn true_builtin_succeeds() {
let (status, _) = run_and_capture("true");
assert_eq!(status, 0);
}
#[test]
fn false_builtin_fails() {
let (status, _) = run_and_capture("false");
assert_eq!(status, 1);
}
#[test]
fn non_executable_command_returns_126() {
let path = std::env::temp_dir().join(format!("mxsh-noexec-{}", std::process::id()));
std::fs::write(&path, "echo hi\n").expect("temp script must be writable");
let script = format!("{}\n", path.display());
let (status, _) = run_and_capture(&script);
let _ = std::fs::remove_file(&path);
assert_eq!(status, 126);
}
#[test]
fn directory_command_returns_126() {
let (status, _) = run_and_capture("/bin\n");
assert_eq!(status, 126);
}
#[test]
fn missing_command_reports_127_and_command_not_found() {
let (status, _out, err, state) = run_and_capture_output_and_error("definitelymissingcommand\n");
assert_eq!(status, 127);
assert_eq!(state.last_status, 127);
assert!(err.contains("definitelymissingcommand: command not found"));
}
#[test]
fn non_executable_command_reports_126_and_permission_denied() {
let path = std::env::temp_dir().join(format!("mxsh-noexec-stderr-{}", std::process::id()));
std::fs::write(&path, "echo hi\n").expect("temp script must be writable");
let script = format!("{}\n", path.display());
let (status, _out, err, state) = run_and_capture_output_and_error(&script);
let _ = std::fs::remove_file(&path);
assert_eq!(status, 126);
assert_eq!(state.last_status, 126);
assert!(err.contains(&format!("{}: Permission denied", path.display())));
}
#[test]
fn contains_glob_chars_detection() {
assert!(shell_expand::contains_glob_chars("*.txt"));
assert!(shell_expand::contains_glob_chars("file?.rs"));
assert!(shell_expand::contains_glob_chars("[abc]"));
assert!(!shell_expand::contains_glob_chars("plain.txt"));
assert!(!shell_expand::contains_glob_chars("no_meta_here"));
}
#[test]
fn builtin_echo_no_args() {
let (status, output, _) = run_and_capture_output("echo\n");
assert_eq!(status, 0);
assert_eq!(output, "\n");
}
#[test]
fn builtin_echo_simple_args() {
let (status, output, _) = run_and_capture_output("echo hello world\n");
assert_eq!(status, 0);
assert_eq!(output, "hello world\n");
}
#[test]
fn builtin_echo_dash_n() {
let (status, output, _) = run_and_capture_output("echo -n hello\n");
assert_eq!(status, 0);
assert_eq!(output, "hello");
}
#[test]
fn builtin_echo_is_builtin() {
let state = ShellState::new();
assert!(shell_builtins::is_builtin_in(&state, "echo"));
}
#[test]
fn arithm_non_numeric_variable_is_error() {
let (status, output, error, _) = run_and_capture_output_and_error("A=alpha\necho $((A + 0))\n");
assert_ne!(status, 0, "non-numeric variable in arithmetic must fail");
assert_eq!(output, "", "no stdout on arithmetic error");
assert!(
!error.is_empty(),
"must produce stderr for non-numeric arithmetic variable"
);
}
#[test]
fn arithm_unset_variable_is_zero() {
let (status, output, _) = run_and_capture_output("unset NOSUCH\necho $((NOSUCH + 5))\n");
assert_eq!(status, 0);
assert_eq!(output, "5\n");
}
#[test]
fn arithm_numeric_variable_works() {
let (status, output, _) = run_and_capture_output("N=7\necho $((N + 3))\n");
assert_eq!(status, 0);
assert_eq!(output, "10\n");
}