diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..d72e9f4 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,6 @@ +[env] +# we need to use one thread for tests +# so that they arent stepping on eachother's toes +# plus it matches the single-threaded behavior of the program +# more closely anyway +RUST_TEST_THREADS = "1" diff --git a/Cargo.lock b/Cargo.lock index bd9c756..3772c94 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,7 +47,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -58,7 +58,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -158,18 +158,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" -[[package]] -name = "console" -version = "0.15.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" -dependencies = [ - "encode_unicode", - "libc", - "once_cell", - "windows-sys 0.59.0", -] - [[package]] name = "cpufeatures" version = "0.3.0" @@ -191,12 +179,6 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" -[[package]] -name = "encode_unicode" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" - [[package]] name = "env_filter" version = "1.0.0" @@ -233,7 +215,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -307,18 +289,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "insta" -version = "1.46.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" -dependencies = [ - "console", - "once_cell", - "similar", - "tempfile", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -533,7 +503,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -599,7 +569,6 @@ dependencies = [ "clap", "env_logger", "glob", - "insta", "itertools", "log", "nix", @@ -615,12 +584,6 @@ dependencies = [ "yansi", ] -[[package]] -name = "similar" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" - [[package]] name = "strsim" version = "0.11.1" @@ -648,7 +611,7 @@ dependencies = [ "getrandom", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys", ] [[package]] @@ -749,15 +712,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -767,70 +721,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" -dependencies = [ - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" - -[[package]] -name = "windows_i686_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" - -[[package]] -name = "windows_i686_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" - -[[package]] -name = "windows_i686_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.52.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" - [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index dbf5717..ea10038 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,7 +41,6 @@ vte = "0.15" yansi = "1.0.1" [dev-dependencies] -insta = "1.42.2" pretty_assertions = "1.4.1" tempfile = "3.24.0" diff --git a/src/builtin/alias.rs b/src/builtin/alias.rs index aa585ee..1062bd2 100644 --- a/src/builtin/alias.rs +++ b/src/builtin/alias.rs @@ -38,21 +38,30 @@ pub fn alias(node: Node) -> ShResult<()> { write(stdout, alias_output.as_bytes())?; // Write it } else { for (arg, span) in argv { - if arg == "command" || arg == "builtin" { + + let Some((name, body)) = arg.split_once('=') else { + let Some(alias) = read_logic(|l| l.get_alias(&arg)) else { + return Err(ShErr::at( + ShErrKind::SyntaxErr, + span, + "alias: Expected an assignment in alias args", + )); + }; + + let alias_output = format!("{arg}='{alias}'"); + + let stdout = borrow_fd(STDOUT_FILENO); + write(stdout, alias_output.as_bytes())?; // Write it + state::set_status(0); + return Ok(()); + }; + if name == "command" || name == "builtin" { return Err(ShErr::at( ShErrKind::ExecFail, span, - format!("alias: Cannot assign alias to reserved name '{arg}'"), + format!("alias: Cannot assign alias to reserved name '{}'", name.fg(next_color())), )); } - - let Some((name, body)) = arg.split_once('=') else { - return Err(ShErr::at( - ShErrKind::SyntaxErr, - span, - "alias: Expected an assignment in alias args", - )); - }; write_logic(|l| l.insert_alias(name, body, span.clone())); } } @@ -60,6 +69,7 @@ pub fn alias(node: Node) -> ShResult<()> { Ok(()) } +/// Remove one or more aliases by name pub fn unalias(node: Node) -> ShResult<()> { let NdRule::Command { assignments: _, @@ -103,3 +113,164 @@ pub fn unalias(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use crate::state::{self, read_logic}; + use crate::testutil::{TestGuard, test_input}; + use pretty_assertions::assert_eq; + + #[test] + fn alias_set_and_expand() { + let guard = TestGuard::new(); + test_input("alias ll='ls -la'").unwrap(); + + let alias = read_logic(|l| l.get_alias("ll")); + assert!(alias.is_some()); + assert_eq!(alias.unwrap().body, "ls -la"); + + test_input("alias ll").unwrap(); + let out = guard.read_output(); + assert!(out.contains("ll")); + assert!(out.contains("ls -la")); + } + + #[test] + fn alias_multiple() { + let _guard = TestGuard::new(); + test_input("alias a='echo a' b='echo b'").unwrap(); + + assert_eq!(read_logic(|l| l.get_alias("a")).unwrap().body, "echo a"); + assert_eq!(read_logic(|l| l.get_alias("b")).unwrap().body, "echo b"); + } + + #[test] + fn alias_overwrite() { + let _guard = TestGuard::new(); + test_input("alias x='first'").unwrap(); + test_input("alias x='second'").unwrap(); + + assert_eq!(read_logic(|l| l.get_alias("x")).unwrap().body, "second"); + } + + #[test] + fn alias_list_sorted() { + let guard = TestGuard::new(); + test_input("alias z='zzz' a='aaa' m='mmm'").unwrap(); + guard.read_output(); + + test_input("alias").unwrap(); + let out = guard.read_output(); + let lines: Vec<&str> = out.lines().collect(); + + assert!(lines.len() >= 3); + let a_pos = lines.iter().position(|l| l.contains("a =")).unwrap(); + let m_pos = lines.iter().position(|l| l.contains("m =")).unwrap(); + let z_pos = lines.iter().position(|l| l.contains("z =")).unwrap(); + assert!(a_pos < m_pos); + assert!(m_pos < z_pos); + } + + #[test] + fn alias_reserved_name_command() { + let _guard = TestGuard::new(); + let result = test_input("alias command='something'"); + assert!(result.is_err()); + } + + #[test] + fn alias_reserved_name_builtin() { + let _guard = TestGuard::new(); + let result = test_input("alias builtin='something'"); + assert!(result.is_err()); + } + + #[test] + fn alias_missing_equals() { + let _guard = TestGuard::new(); + let result = test_input("alias noequals"); + assert!(result.is_err()); + } + + #[test] + fn alias_expansion_in_command() { + let guard = TestGuard::new(); + test_input("alias greet='echo hello'").unwrap(); + guard.read_output(); + + test_input("greet").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\n"); + } + + #[test] + fn alias_expansion_with_args() { + let guard = TestGuard::new(); + test_input("alias e='echo'").unwrap(); + guard.read_output(); + + test_input("e foo bar").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "foo bar\n"); + } + + #[test] + fn unalias_removes() { + let _guard = TestGuard::new(); + test_input("alias tmp='something'").unwrap(); + assert!(read_logic(|l| l.get_alias("tmp")).is_some()); + + test_input("unalias tmp").unwrap(); + assert!(read_logic(|l| l.get_alias("tmp")).is_none()); + } + + #[test] + fn unalias_nonexistent() { + let _guard = TestGuard::new(); + let result = test_input("unalias nosuchalias"); + assert!(result.is_err()); + } + + #[test] + fn unalias_multiple() { + let _guard = TestGuard::new(); + test_input("alias a='1' b='2' c='3'").unwrap(); + test_input("unalias a c").unwrap(); + + assert!(read_logic(|l| l.get_alias("a")).is_none()); + assert!(read_logic(|l| l.get_alias("b")).is_some()); + assert!(read_logic(|l| l.get_alias("c")).is_none()); + } + + #[test] + fn unalias_no_args_lists() { + let guard = TestGuard::new(); + test_input("alias x='hello'").unwrap(); + guard.read_output(); + + test_input("unalias").unwrap(); + let out = guard.read_output(); + assert!(out.contains("x")); + assert!(out.contains("hello")); + } + + #[test] + fn alias_empty_body() { + let _guard = TestGuard::new(); + test_input("alias empty=''").unwrap(); + + let alias = read_logic(|l| l.get_alias("empty")); + assert!(alias.is_some()); + assert_eq!(alias.unwrap().body, ""); + } + + #[test] + fn alias_status_zero() { + let _guard = TestGuard::new(); + test_input("alias ok='true'").unwrap(); + assert_eq!(state::get_status(), 0); + + test_input("unalias ok").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/arrops.rs b/src/builtin/arrops.rs index 24e0650..657b99d 100644 --- a/src/builtin/arrops.rs +++ b/src/builtin/arrops.rs @@ -226,3 +226,266 @@ pub fn get_arr_op_opts(opts: Vec) -> ShResult { } Ok(arr_op_opts) } + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use crate::state::{self, read_vars, write_vars, VarFlags, VarKind}; + use crate::testutil::{TestGuard, test_input}; + + fn set_arr(name: &str, elems: &[&str]) { + let arr = VecDeque::from_iter(elems.iter().map(|s| s.to_string())); + write_vars(|v| v.set_var(name, VarKind::Arr(arr), VarFlags::NONE)).unwrap(); + } + + fn get_arr(name: &str) -> Vec { + read_vars(|v| v.get_arr_elems(name)).unwrap() + } + + // ===================== push ===================== + + #[test] + fn push_to_existing_array() { + let _guard = TestGuard::new(); + set_arr("arr", &["a", "b"]); + + test_input("push arr c").unwrap(); + assert_eq!(get_arr("arr"), vec!["a", "b", "c"]); + } + + #[test] + fn push_creates_array() { + let _guard = TestGuard::new(); + + test_input("push newarr hello").unwrap(); + assert_eq!(get_arr("newarr"), vec!["hello"]); + } + + #[test] + fn push_multiple_values() { + let _guard = TestGuard::new(); + set_arr("arr", &["a"]); + + test_input("push arr b c d").unwrap(); + assert_eq!(get_arr("arr"), vec!["a", "b", "c", "d"]); + } + + #[test] + fn push_no_array_name() { + let _guard = TestGuard::new(); + let result = test_input("push"); + assert!(result.is_err()); + } + + // ===================== fpush ===================== + + #[test] + fn fpush_to_existing_array() { + let _guard = TestGuard::new(); + set_arr("arr", &["b", "c"]); + + test_input("fpush arr a").unwrap(); + assert_eq!(get_arr("arr"), vec!["a", "b", "c"]); + } + + #[test] + fn fpush_multiple_values() { + let _guard = TestGuard::new(); + set_arr("arr", &["c"]); + + test_input("fpush arr a b").unwrap(); + // Each value is pushed to the front in order: c -> a,c -> b,a,c + assert_eq!(get_arr("arr"), vec!["b", "a", "c"]); + } + + #[test] + fn fpush_creates_array() { + let _guard = TestGuard::new(); + + test_input("fpush newarr x").unwrap(); + assert_eq!(get_arr("newarr"), vec!["x"]); + } + + // ===================== pop ===================== + + #[test] + fn pop_removes_last() { + let guard = TestGuard::new(); + set_arr("arr", &["a", "b", "c"]); + + test_input("pop arr").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "c\n"); + assert_eq!(get_arr("arr"), vec!["a", "b"]); + } + + #[test] + fn pop_with_count() { + let guard = TestGuard::new(); + set_arr("arr", &["a", "b", "c", "d"]); + + test_input("pop -c 2 arr").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "d\nc\n"); + assert_eq!(get_arr("arr"), vec!["a", "b"]); + } + + #[test] + fn pop_into_variable() { + let _guard = TestGuard::new(); + set_arr("arr", &["x", "y", "z"]); + + test_input("pop -v result arr").unwrap(); + let val = read_vars(|v| v.get_var("result")); + assert_eq!(val, "z"); + assert_eq!(get_arr("arr"), vec!["x", "y"]); + } + + #[test] + fn pop_empty_array_fails() { + let _guard = TestGuard::new(); + set_arr("arr", &[]); + + test_input("pop arr").unwrap(); + assert_eq!(state::get_status(), 1); + } + + #[test] + fn pop_nonexistent_array() { + let _guard = TestGuard::new(); + + test_input("pop nosucharray").unwrap(); + assert_eq!(state::get_status(), 1); + } + + // ===================== fpop ===================== + + #[test] + fn fpop_removes_first() { + let guard = TestGuard::new(); + set_arr("arr", &["a", "b", "c"]); + + test_input("fpop arr").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "a\n"); + assert_eq!(get_arr("arr"), vec!["b", "c"]); + } + + #[test] + fn fpop_with_count() { + let guard = TestGuard::new(); + set_arr("arr", &["a", "b", "c", "d"]); + + test_input("fpop -c 2 arr").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "a\nb\n"); + assert_eq!(get_arr("arr"), vec!["c", "d"]); + } + + #[test] + fn fpop_into_variable() { + let _guard = TestGuard::new(); + set_arr("arr", &["first", "second"]); + + test_input("fpop -v result arr").unwrap(); + let val = read_vars(|v| v.get_var("result")); + assert_eq!(val, "first"); + assert_eq!(get_arr("arr"), vec!["second"]); + } + + // ===================== rotate ===================== + + #[test] + fn rotate_left_default() { + let _guard = TestGuard::new(); + set_arr("arr", &["a", "b", "c", "d"]); + + test_input("rotate arr").unwrap(); + assert_eq!(get_arr("arr"), vec!["b", "c", "d", "a"]); + } + + #[test] + fn rotate_left_with_count() { + let _guard = TestGuard::new(); + set_arr("arr", &["a", "b", "c", "d"]); + + test_input("rotate -c 2 arr").unwrap(); + assert_eq!(get_arr("arr"), vec!["c", "d", "a", "b"]); + } + + #[test] + fn rotate_right() { + let _guard = TestGuard::new(); + set_arr("arr", &["a", "b", "c", "d"]); + + test_input("rotate -r arr").unwrap(); + assert_eq!(get_arr("arr"), vec!["d", "a", "b", "c"]); + } + + #[test] + fn rotate_right_with_count() { + let _guard = TestGuard::new(); + set_arr("arr", &["a", "b", "c", "d"]); + + test_input("rotate -r -c 2 arr").unwrap(); + assert_eq!(get_arr("arr"), vec!["c", "d", "a", "b"]); + } + + #[test] + fn rotate_count_exceeds_len() { + let _guard = TestGuard::new(); + set_arr("arr", &["a", "b"]); + + // count clamped to arr.len(), so rotate by 2 on len=2 is a no-op + test_input("rotate -c 5 arr").unwrap(); + assert_eq!(get_arr("arr"), vec!["a", "b"]); + } + + #[test] + fn rotate_single_element() { + let _guard = TestGuard::new(); + set_arr("arr", &["only"]); + + test_input("rotate arr").unwrap(); + assert_eq!(get_arr("arr"), vec!["only"]); + } + + // ===================== combined ops ===================== + + #[test] + fn push_then_pop_roundtrip() { + let guard = TestGuard::new(); + set_arr("arr", &["a"]); + + test_input("push arr b").unwrap(); + test_input("pop arr").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "b\n"); + assert_eq!(get_arr("arr"), vec!["a"]); + } + + #[test] + fn fpush_then_fpop_roundtrip() { + let guard = TestGuard::new(); + set_arr("arr", &["a"]); + + test_input("fpush arr z").unwrap(); + test_input("fpop arr").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "z\n"); + assert_eq!(get_arr("arr"), vec!["a"]); + } + + #[test] + fn pop_until_empty() { + let _guard = TestGuard::new(); + set_arr("arr", &["x", "y"]); + + test_input("pop arr").unwrap(); + assert_eq!(state::get_status(), 0); + test_input("pop arr").unwrap(); + assert_eq!(state::get_status(), 0); + test_input("pop arr").unwrap(); + assert_eq!(state::get_status(), 1); + } +} diff --git a/src/builtin/autocmd.rs b/src/builtin/autocmd.rs index 2b7e43d..121c9fb 100644 --- a/src/builtin/autocmd.rs +++ b/src/builtin/autocmd.rs @@ -111,3 +111,202 @@ pub fn autocmd(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use crate::state::{self, AutoCmdKind, read_logic, write_logic}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Registration ===================== + + #[test] + fn register_pre_cmd() { + let _guard = TestGuard::new(); + test_input("autocmd pre-cmd 'echo hello'").unwrap(); + + let cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "echo hello"); + assert!(cmds[0].pattern.is_none()); + } + + #[test] + fn register_post_cmd() { + let _guard = TestGuard::new(); + test_input("autocmd post-cmd 'echo done'").unwrap(); + + let cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].command, "echo done"); + } + + #[test] + fn register_multiple_same_kind() { + let _guard = TestGuard::new(); + test_input("autocmd pre-cmd 'echo first'").unwrap(); + test_input("autocmd pre-cmd 'echo second'").unwrap(); + + let cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].command, "echo first"); + assert_eq!(cmds[1].command, "echo second"); + } + + #[test] + fn register_different_kinds() { + let _guard = TestGuard::new(); + test_input("autocmd pre-cmd 'echo pre'").unwrap(); + test_input("autocmd post-cmd 'echo post'").unwrap(); + + assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 1); + assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(), 1); + } + + // ===================== Pattern ===================== + + #[test] + fn register_with_pattern() { + let _guard = TestGuard::new(); + test_input("autocmd -p '^git' pre-cmd 'echo git cmd'").unwrap(); + + let cmds = read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)); + assert_eq!(cmds.len(), 1); + assert!(cmds[0].pattern.is_some()); + let pat = cmds[0].pattern.as_ref().unwrap(); + assert!(pat.is_match("git status")); + assert!(!pat.is_match("echo git")); + } + + #[test] + fn invalid_regex_pattern() { + let _guard = TestGuard::new(); + let result = test_input("autocmd -p '[invalid' pre-cmd 'echo bad'"); + assert!(result.is_err()); + } + + // ===================== Clear ===================== + + #[test] + fn clear_autocmds() { + let _guard = TestGuard::new(); + test_input("autocmd pre-cmd 'echo a'").unwrap(); + test_input("autocmd pre-cmd 'echo b'").unwrap(); + assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 2); + + test_input("autocmd -c pre-cmd").unwrap(); + assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 0); + } + + #[test] + fn clear_only_affects_specified_kind() { + let _guard = TestGuard::new(); + test_input("autocmd pre-cmd 'echo pre'").unwrap(); + test_input("autocmd post-cmd 'echo post'").unwrap(); + + test_input("autocmd -c pre-cmd").unwrap(); + assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PreCmd)).len(), 0); + assert_eq!(read_logic(|l| l.get_autocmds(AutoCmdKind::PostCmd)).len(), 1); + } + + #[test] + fn clear_empty_is_noop() { + let _guard = TestGuard::new(); + // Clearing when nothing is registered should not error + test_input("autocmd -c pre-cmd").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== Error Cases ===================== + + #[test] + fn missing_kind() { + let _guard = TestGuard::new(); + let result = test_input("autocmd"); + assert!(result.is_err()); + } + + #[test] + fn invalid_kind() { + let _guard = TestGuard::new(); + let result = test_input("autocmd not-a-real-kind 'echo hi'"); + assert!(result.is_err()); + } + + #[test] + fn missing_command() { + let _guard = TestGuard::new(); + let result = test_input("autocmd pre-cmd"); + assert!(result.is_err()); + } + + // ===================== All valid kind strings ===================== + + #[test] + fn all_kinds_parse() { + let _guard = TestGuard::new(); + let kinds = [ + "pre-cmd", "post-cmd", "pre-change-dir", "post-change-dir", + "on-job-finish", "pre-prompt", "post-prompt", + "pre-mode-change", "post-mode-change", + "on-history-open", "on-history-close", "on-history-select", + "on-completion-start", "on-completion-cancel", "on-completion-select", + "on-exit", + ]; + for kind in kinds { + test_input(&format!("autocmd {kind} 'true'")).unwrap(); + } + } + + // ===================== Execution ===================== + + #[test] + fn exec_fires_autocmd() { + let guard = TestGuard::new(); + // Register a post-change-dir autocmd and trigger it via cd + test_input("autocmd post-change-dir 'echo changed'").unwrap(); + guard.read_output(); + + test_input("cd /tmp").unwrap(); + let out = guard.read_output(); + assert!(out.contains("changed")); + } + + #[test] + fn exec_with_pattern_match() { + let guard = TestGuard::new(); + // Pattern that matches "cd" commands + test_input("autocmd -p '/tmp' post-change-dir 'echo matched'").unwrap(); + guard.read_output(); + + test_input("cd /tmp").unwrap(); + let out = guard.read_output(); + assert!(out.contains("matched")); + } + + #[test] + fn exec_with_pattern_no_match() { + let guard = TestGuard::new(); + // Pattern that won't match /tmp + test_input("autocmd -p '^/usr' post-change-dir 'echo nope'").unwrap(); + guard.read_output(); + + test_input("cd /tmp").unwrap(); + let out = guard.read_output(); + assert!(!out.contains("nope")); + } + + #[test] + fn exec_preserves_status() { + let _guard = TestGuard::new(); + // autocmd exec should restore the status code from before it ran + test_input("autocmd post-change-dir 'false'").unwrap(); + + test_input("true").unwrap(); + assert_eq!(state::get_status(), 0); + + test_input("cd /tmp").unwrap(); + // cd itself succeeds, autocmd runs `false` but status should be + // restored to cd's success + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/cd.rs b/src/builtin/cd.rs index d08aa29..a3a8ead 100644 --- a/src/builtin/cd.rs +++ b/src/builtin/cd.rs @@ -75,3 +75,162 @@ pub fn cd(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +pub mod tests { + use std::env; + use std::fs; + + use tempfile::TempDir; + + use crate::state; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Basic Navigation ===================== + + #[test] + fn cd_simple() { + let _g = TestGuard::new(); + let old_dir = env::current_dir().unwrap(); + let temp_dir = TempDir::new().unwrap(); + + test_input(format!("cd {}", temp_dir.path().display())).unwrap(); + + let new_dir = env::current_dir().unwrap(); + assert_ne!(old_dir, new_dir); + + assert_eq!(new_dir.display().to_string(), temp_dir.path().display().to_string()); + } + + #[test] + fn cd_no_args_goes_home() { + let _g = TestGuard::new(); + let temp_dir = TempDir::new().unwrap(); + unsafe { env::set_var("HOME", temp_dir.path()) }; + + test_input("cd").unwrap(); + + let cwd = env::current_dir().unwrap(); + assert_eq!(cwd.display().to_string(), temp_dir.path().display().to_string()); + } + + #[test] + fn cd_relative_path() { + let _g = TestGuard::new(); + let temp_dir = TempDir::new().unwrap(); + let sub = temp_dir.path().join("child"); + fs::create_dir(&sub).unwrap(); + + test_input(format!("cd {}", temp_dir.path().display())).unwrap(); + test_input("cd child").unwrap(); + + let cwd = env::current_dir().unwrap(); + assert_eq!(cwd.display().to_string(), sub.display().to_string()); + } + + // ===================== Environment ===================== + + #[test] + fn cd_sets_pwd_env() { + let _g = TestGuard::new(); + let temp_dir = TempDir::new().unwrap(); + + test_input(format!("cd {}", temp_dir.path().display())).unwrap(); + + let pwd = env::var("PWD").unwrap(); + assert_eq!(pwd, env::current_dir().unwrap().display().to_string()); + } + + #[test] + fn cd_status_zero_on_success() { + let _g = TestGuard::new(); + let temp_dir = TempDir::new().unwrap(); + + test_input(format!("cd {}", temp_dir.path().display())).unwrap(); + + assert_eq!(state::get_status(), 0); + } + + // ===================== Error Cases ===================== + + #[test] + fn cd_nonexistent_dir_fails() { + let _g = TestGuard::new(); + let result = test_input("cd /nonexistent_path_that_does_not_exist_xyz"); + assert!(result.is_err()); + } + + #[test] + fn cd_file_not_directory_fails() { + let _g = TestGuard::new(); + let temp_dir = TempDir::new().unwrap(); + let file_path = temp_dir.path().join("afile.txt"); + fs::write(&file_path, "hello").unwrap(); + + let result = test_input(format!("cd {}", file_path.display())); + assert!(result.is_err()); + } + + // ===================== Multiple cd ===================== + + #[test] + fn cd_multiple_times() { + let _g = TestGuard::new(); + let dir_a = TempDir::new().unwrap(); + let dir_b = TempDir::new().unwrap(); + + test_input(format!("cd {}", dir_a.path().display())).unwrap(); + assert_eq!( + env::current_dir().unwrap().display().to_string(), + dir_a.path().display().to_string() + ); + + test_input(format!("cd {}", dir_b.path().display())).unwrap(); + assert_eq!( + env::current_dir().unwrap().display().to_string(), + dir_b.path().display().to_string() + ); + } + + #[test] + fn cd_nested_subdirectories() { + let _g = TestGuard::new(); + let temp_dir = TempDir::new().unwrap(); + let deep = temp_dir.path().join("a").join("b").join("c"); + fs::create_dir_all(&deep).unwrap(); + + test_input(format!("cd {}", deep.display())).unwrap(); + assert_eq!( + env::current_dir().unwrap().display().to_string(), + deep.display().to_string() + ); + } + + // ===================== Autocmd Integration ===================== + + #[test] + fn cd_fires_post_change_dir_autocmd() { + let guard = TestGuard::new(); + let temp_dir = TempDir::new().unwrap(); + + test_input("autocmd post-change-dir 'echo cd-hook-fired'").unwrap(); + guard.read_output(); + + test_input(format!("cd {}", temp_dir.path().display())).unwrap(); + let out = guard.read_output(); + assert!(out.contains("cd-hook-fired")); + } + + #[test] + fn cd_fires_pre_change_dir_autocmd() { + let guard = TestGuard::new(); + let temp_dir = TempDir::new().unwrap(); + + test_input("autocmd pre-change-dir 'echo pre-cd'").unwrap(); + guard.read_output(); + + test_input(format!("cd {}", temp_dir.path().display())).unwrap(); + let out = guard.read_output(); + assert!(out.contains("pre-cd")); + } +} diff --git a/src/builtin/complete.rs b/src/builtin/complete.rs index a34bef7..3a703a3 100644 --- a/src/builtin/complete.rs +++ b/src/builtin/complete.rs @@ -173,20 +173,24 @@ pub fn complete_builtin(node: Node) -> ShResult<()> { if comp_opts.flags.contains(CompFlags::PRINT) { if argv.is_empty() { - read_meta(|m| { + read_meta(|m| -> ShResult<()> { let specs = m.comp_specs().values(); for spec in specs { - println!("{}", spec.source()); + let stdout = borrow_fd(STDOUT_FILENO); + write(stdout, spec.source().as_bytes())?; } - }) + Ok(()) + })?; } else { - read_meta(|m| { + read_meta(|m| -> ShResult<()> { for (cmd, _) in &argv { if let Some(spec) = m.comp_specs().get(cmd) { - println!("{}", spec.source()); + let stdout = borrow_fd(STDOUT_FILENO); + write(stdout, spec.source().as_bytes())?; } } - }) + Ok(()) + })?; } state::set_status(0); @@ -309,3 +313,318 @@ pub fn get_comp_opts(opts: Vec) -> ShResult { Ok(comp_opts) } + +#[cfg(test)] +mod tests { + use std::fs; + use tempfile::TempDir; + use crate::state::{self, read_meta, write_vars, VarFlags, VarKind}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== complete: Registration ===================== + + #[test] + fn complete_register_wordlist() { + let _g = TestGuard::new(); + test_input("complete -W 'foo bar baz' mycmd").unwrap(); + + let spec = read_meta(|m| m.get_comp_spec("mycmd")); + assert!(spec.is_some()); + } + + #[test] + fn complete_register_files() { + let _g = TestGuard::new(); + test_input("complete -f mycmd").unwrap(); + + let spec = read_meta(|m| m.get_comp_spec("mycmd")); + assert!(spec.is_some()); + } + + #[test] + fn complete_register_dirs() { + let _g = TestGuard::new(); + test_input("complete -d mycmd").unwrap(); + + let spec = read_meta(|m| m.get_comp_spec("mycmd")); + assert!(spec.is_some()); + } + + #[test] + fn complete_register_multiple_commands() { + let _g = TestGuard::new(); + test_input("complete -W 'x y' cmd1 cmd2").unwrap(); + + assert!(read_meta(|m| m.get_comp_spec("cmd1")).is_some()); + assert!(read_meta(|m| m.get_comp_spec("cmd2")).is_some()); + } + + #[test] + fn complete_register_function() { + let _g = TestGuard::new(); + test_input("complete -F _my_comp mycmd").unwrap(); + + let spec = read_meta(|m| m.get_comp_spec("mycmd")); + assert!(spec.is_some()); + } + + #[test] + fn complete_register_combined_flags() { + let _g = TestGuard::new(); + test_input("complete -f -d -v mycmd").unwrap(); + + let spec = read_meta(|m| m.get_comp_spec("mycmd")); + assert!(spec.is_some()); + } + + #[test] + fn complete_overwrite_spec() { + let _g = TestGuard::new(); + test_input("complete -W 'old' mycmd").unwrap(); + test_input("complete -W 'new' mycmd").unwrap(); + + let spec = read_meta(|m| m.get_comp_spec("mycmd")); + assert!(spec.is_some()); + // Verify the source reflects the latest registration + assert!(spec.unwrap().source().contains("new")); + } + + #[test] + fn complete_no_command_fails() { + let _g = TestGuard::new(); + let result = test_input("complete -W 'foo'"); + assert!(result.is_err()); + } + + // ===================== complete -r: Removal ===================== + + #[test] + fn complete_remove_spec() { + let _g = TestGuard::new(); + test_input("complete -W 'foo' mycmd").unwrap(); + assert!(read_meta(|m| m.get_comp_spec("mycmd")).is_some()); + + test_input("complete -r mycmd").unwrap(); + assert!(read_meta(|m| m.get_comp_spec("mycmd")).is_none()); + } + + #[test] + fn complete_remove_multiple() { + let _g = TestGuard::new(); + test_input("complete -W 'a' cmd1").unwrap(); + test_input("complete -W 'b' cmd2").unwrap(); + + test_input("complete -r cmd1 cmd2").unwrap(); + assert!(read_meta(|m| m.get_comp_spec("cmd1")).is_none()); + assert!(read_meta(|m| m.get_comp_spec("cmd2")).is_none()); + } + + #[test] + fn complete_remove_nonexistent_is_ok() { + let _g = TestGuard::new(); + // Removing a spec that doesn't exist should not error + test_input("complete -r nosuchcmd").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== complete -p: Print ===================== + + #[test] + fn complete_print_specific() { + let guard = TestGuard::new(); + test_input("complete -W 'alpha beta' mycmd").unwrap(); + guard.read_output(); + + test_input("complete -p mycmd").unwrap(); + let out = guard.read_output(); + assert!(out.contains("mycmd")); + } + + #[test] + fn complete_print_all() { + let guard = TestGuard::new(); + // Clear any existing specs and register two + test_input("complete -W 'a' cmd1").unwrap(); + test_input("complete -W 'b' cmd2").unwrap(); + guard.read_output(); + + test_input("complete -p").unwrap(); + let out = guard.read_output(); + assert!(out.contains("cmd1")); + assert!(out.contains("cmd2")); + } + + // ===================== complete -o: Option flags ===================== + + #[test] + fn complete_option_default() { + let _g = TestGuard::new(); + test_input("complete -o default -W 'foo' mycmd").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn complete_option_dirnames() { + let _g = TestGuard::new(); + test_input("complete -o dirnames -W 'foo' mycmd").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn complete_option_invalid() { + let _g = TestGuard::new(); + let result = test_input("complete -o bogus -W 'foo' mycmd"); + assert!(result.is_err()); + } + + // ===================== compgen -W: Word list ===================== + + #[test] + fn compgen_wordlist_no_prefix() { + let guard = TestGuard::new(); + test_input("compgen -W 'alpha beta gamma'").unwrap(); + let out = guard.read_output(); + assert!(out.contains("alpha")); + assert!(out.contains("beta")); + assert!(out.contains("gamma")); + } + + #[test] + fn compgen_wordlist_with_prefix() { + let guard = TestGuard::new(); + test_input("compgen -W 'apple banana avocado' a").unwrap(); + let out = guard.read_output(); + assert!(out.contains("apple")); + assert!(out.contains("avocado")); + assert!(!out.contains("banana")); + } + + #[test] + fn compgen_wordlist_no_match() { + let guard = TestGuard::new(); + test_input("compgen -W 'foo bar baz' z").unwrap(); + let out = guard.read_output(); + assert!(out.trim().is_empty()); + } + + #[test] + fn compgen_wordlist_exact_match() { + let guard = TestGuard::new(); + test_input("compgen -W 'hello help helm' hel").unwrap(); + let out = guard.read_output(); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 3); + } + + #[test] + fn compgen_wordlist_single_match() { + let guard = TestGuard::new(); + test_input("compgen -W 'alpha beta gamma' g").unwrap(); + let out = guard.read_output(); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines.len(), 1); + assert_eq!(lines[0], "gamma"); + } + + // ===================== compgen -v: Variables ===================== + + #[test] + fn compgen_variables() { + let guard = TestGuard::new(); + write_vars(|v| v.set_var("TESTCOMPVAR", VarKind::Str("x".into()), VarFlags::NONE)).unwrap(); + + test_input("compgen -v TESTCOMP").unwrap(); + let out = guard.read_output(); + assert!(out.contains("TESTCOMPVAR")); + } + + // ===================== compgen -a: Aliases ===================== + + #[test] + fn compgen_aliases() { + let guard = TestGuard::new(); + test_input("alias testcompalias='echo hi'").unwrap(); + guard.read_output(); + + test_input("compgen -a testcomp").unwrap(); + let out = guard.read_output(); + assert!(out.contains("testcompalias")); + } + + // ===================== compgen -d: Directories ===================== + + #[test] + fn compgen_dirs() { + let guard = TestGuard::new(); + let tmp = TempDir::new().unwrap(); + let sub = tmp.path().join("subdir"); + fs::create_dir(&sub).unwrap(); + + let prefix = format!("{}/", tmp.path().display()); + test_input(format!("compgen -d {prefix}")).unwrap(); + let out = guard.read_output(); + assert!(out.contains("subdir")); + } + + // ===================== compgen -f: Files ===================== + + #[test] + fn compgen_files() { + let guard = TestGuard::new(); + let tmp = TempDir::new().unwrap(); + fs::write(tmp.path().join("testfile.txt"), "").unwrap(); + fs::create_dir(tmp.path().join("testdir")).unwrap(); + + let prefix = format!("{}/test", tmp.path().display()); + test_input(format!("compgen -f {prefix}")).unwrap(); + let out = guard.read_output(); + assert!(out.contains("testfile.txt")); + assert!(out.contains("testdir")); + } + + // ===================== compgen -F: Completion function ===================== + + #[test] + fn compgen_function() { + let guard = TestGuard::new(); + // Define a completion function that sets COMPREPLY + test_input("_mycomp() { COMPREPLY=(opt1 opt2 opt3); }").unwrap(); + guard.read_output(); + + test_input("compgen -F _mycomp").unwrap(); + let out = guard.read_output(); + assert!(out.contains("opt1")); + assert!(out.contains("opt2")); + assert!(out.contains("opt3")); + } + + // ===================== compgen: combined flags ===================== + + #[test] + fn compgen_wordlist_and_aliases() { + let guard = TestGuard::new(); + test_input("alias testcga='true'").unwrap(); + guard.read_output(); + + test_input("compgen -W 'testcgw' -a testcg").unwrap(); + let out = guard.read_output(); + assert!(out.contains("testcgw")); + assert!(out.contains("testcga")); + } + + // ===================== Status ===================== + + #[test] + fn complete_status_zero() { + let _g = TestGuard::new(); + test_input("complete -W 'x' mycmd").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn compgen_status_zero() { + let _g = TestGuard::new(); + test_input("compgen -W 'hello'").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/dirstack.rs b/src/builtin/dirstack.rs index cb90af2..d4ea28f 100644 --- a/src/builtin/dirstack.rs +++ b/src/builtin/dirstack.rs @@ -5,12 +5,21 @@ use nix::{libc::STDOUT_FILENO, unistd::write}; use yansi::Color; use crate::{ - libsh::error::{ShErr, ShErrKind, ShResult, next_color}, + libsh::{error::{ShErr, ShErrKind, ShResult, next_color}, sys::TTY_FILENO}, parse::{NdRule, Node, execute::prepare_argv, lex::Span}, procio::borrow_fd, state::{self, read_meta, write_meta}, }; +pub fn truncate_home_path(path: String) -> String { + if let Ok(home) = env::var("HOME") + && path.starts_with(&home) { + let new = path.strip_prefix(&home).unwrap(); + return format!("~{new}"); + } + path.to_string() +} + enum StackIdx { FromTop(usize), FromBottom(usize), @@ -23,18 +32,7 @@ fn print_dirs() -> ShResult<()> { .into_iter() .chain(dirs_iter) .map(|d| d.to_string_lossy().to_string()) - .map(|d| { - let Ok(home) = env::var("HOME") else { - return d; - }; - - if d.starts_with(&home) { - let new = d.strip_prefix(&home).unwrap(); - format!("~{new}") - } else { - d - } - }) + .map(truncate_home_path) .collect::>() .join(" "); @@ -378,18 +376,7 @@ pub fn dirs(node: Node) -> ShResult<()> { .map(|d| d.to_string_lossy().to_string()); if abbreviate_home { - let Ok(home) = env::var("HOME") else { - return stack.collect(); - }; - stack - .map(|d| { - if d.starts_with(&home) { - let new = d.strip_prefix(&home).unwrap(); - format!("~{new}") - } else { - d - } - }) + stack.map(truncate_home_path) .collect() } else { stack.collect() @@ -438,3 +425,192 @@ pub fn dirs(node: Node) -> ShResult<()> { Ok(()) } + +#[cfg(test)] +pub mod tests { + use std::{env, path::PathBuf}; + use crate::{parse::execute::exec_input, state::{self, read_meta}, testutil::TestGuard}; + use pretty_assertions::{assert_ne,assert_eq}; +use tempfile::TempDir; + + #[test] + fn test_pushd_interactive() { + let g = TestGuard::new(); + let current_dir = env::current_dir().unwrap(); + + exec_input("pushd /tmp".into(), None, true, None).unwrap(); + + let new_dir = env::current_dir().unwrap(); + + assert_ne!(new_dir, current_dir); + assert_eq!(new_dir, PathBuf::from("/tmp")); + + let dir_stack = read_meta(|m| m.dirs().clone()); + assert_eq!(dir_stack.len(), 1); + assert_eq!(dir_stack[0], current_dir); + + let out = g.read_output(); + let path = super::truncate_home_path(current_dir.to_string_lossy().to_string()); + assert_eq!(out, format!("/tmp {path}\n")); + } + + #[test] + fn test_popd_interactive() { + let g = TestGuard::new(); + let current_dir = env::current_dir().unwrap(); + let tempdir = TempDir::new().unwrap(); + let tempdir_raw = tempdir.path().to_path_buf().to_string_lossy().to_string(); + + exec_input(format!("pushd {tempdir_raw}"), None, true, None).unwrap(); + + let dir_stack = read_meta(|m| m.dirs().clone()); + assert_eq!(dir_stack.len(), 1); + assert_eq!(dir_stack[0], current_dir); + + assert_eq!(env::current_dir().unwrap(), tempdir.path()); + g.read_output(); // consume output of pushd + + exec_input("popd".into(), None, true, None).unwrap(); + + assert_eq!(env::current_dir().unwrap(), current_dir); + let out = g.read_output(); + let path = super::truncate_home_path(current_dir.to_string_lossy().to_string()); + assert_eq!(out, format!("{path}\n")); + } + + #[test] + fn test_popd_empty_stack() { + let _g = TestGuard::new(); + + exec_input("popd".into(), None, false, None).unwrap_err(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_pushd_multiple_then_popd() { + let g = TestGuard::new(); + let original = env::current_dir().unwrap(); + let tmp1 = TempDir::new().unwrap(); + let tmp2 = TempDir::new().unwrap(); + let path1 = tmp1.path().to_path_buf(); + let path2 = tmp2.path().to_path_buf(); + + exec_input(format!("pushd {}", path1.display()), None, false, None).unwrap(); + exec_input(format!("pushd {}", path2.display()), None, false, None).unwrap(); + g.read_output(); + + assert_eq!(env::current_dir().unwrap(), path2); + let stack = read_meta(|m| m.dirs().clone()); + assert_eq!(stack.len(), 2); + assert_eq!(stack[0], path1); + assert_eq!(stack[1], original); + + exec_input("popd".into(), None, false, None).unwrap(); + assert_eq!(env::current_dir().unwrap(), path1); + + exec_input("popd".into(), None, false, None).unwrap(); + assert_eq!(env::current_dir().unwrap(), original); + + let stack = read_meta(|m| m.dirs().clone()); + assert_eq!(stack.len(), 0); + } + + #[test] + fn test_pushd_rotate_plus() { + let g = TestGuard::new(); + let original = env::current_dir().unwrap(); + let tmp1 = TempDir::new().unwrap(); + let tmp2 = TempDir::new().unwrap(); + let path1 = tmp1.path().to_path_buf(); + let path2 = tmp2.path().to_path_buf(); + + // Build stack: cwd=original, then pushd path1, pushd path2 + // Stack after: cwd=path2, [path1, original] + exec_input(format!("pushd {}", path1.display()), None, false, None).unwrap(); + exec_input(format!("pushd {}", path2.display()), None, false, None).unwrap(); + g.read_output(); + + // pushd +1 rotates: [path2, path1, original] -> rotate_left(1) -> [path1, original, path2] + // pop front -> cwd=path1, stack=[original, path2] + exec_input("pushd +1".into(), None, false, None).unwrap(); + assert_eq!(env::current_dir().unwrap(), path1); + + let stack = read_meta(|m| m.dirs().clone()); + assert_eq!(stack.len(), 2); + assert_eq!(stack[0], original); + assert_eq!(stack[1], path2); + } + + #[test] + fn test_pushd_no_cd_flag() { + let _g = TestGuard::new(); + let original = env::current_dir().unwrap(); + let tmp = TempDir::new().unwrap(); + let path = tmp.path().to_path_buf(); + + exec_input(format!("pushd -n {}", path.display()), None, false, None).unwrap(); + + // -n means don't cd, but the dir should still be on the stack + assert_eq!(env::current_dir().unwrap(), original); + } + + #[test] + fn test_dirs_clear() { + let _g = TestGuard::new(); + let tmp = TempDir::new().unwrap(); + + exec_input(format!("pushd {}", tmp.path().display()), None, false, None).unwrap(); + assert_eq!(read_meta(|m| m.dirs().len()), 1); + + exec_input("dirs -c".into(), None, false, None).unwrap(); + assert_eq!(read_meta(|m| m.dirs().len()), 0); + } + + #[test] + fn test_dirs_one_per_line() { + let g = TestGuard::new(); + let original = env::current_dir().unwrap(); + let tmp = TempDir::new().unwrap(); + let path = tmp.path().to_path_buf(); + + exec_input(format!("pushd {}", path.display()), None, false, None).unwrap(); + g.read_output(); + + exec_input("dirs -p".into(), None, false, None).unwrap(); + let out = g.read_output(); + let lines: Vec<&str> = out.split('\n').filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 2); + assert_eq!(lines[0], super::truncate_home_path(path.to_string_lossy().to_string())); + assert_eq!(lines[1], super::truncate_home_path(original.to_string_lossy().to_string())); + } + + #[test] + fn test_popd_indexed_from_top() { + let _g = TestGuard::new(); + let original = env::current_dir().unwrap(); + let tmp1 = TempDir::new().unwrap(); + let tmp2 = TempDir::new().unwrap(); + let path1 = tmp1.path().to_path_buf(); + let path2 = tmp2.path().to_path_buf(); + + // Stack: cwd=path2, [path1, original] + exec_input(format!("pushd {}", path1.display()), None, false, None).unwrap(); + exec_input(format!("pushd {}", path2.display()), None, false, None).unwrap(); + + // popd +1 removes index (1-1)=0 from stored dirs, i.e. path1 + exec_input("popd +1".into(), None, false, None).unwrap(); + assert_eq!(env::current_dir().unwrap(), path2); // no cd + + let stack = read_meta(|m| m.dirs().clone()); + assert_eq!(stack.len(), 1); + assert_eq!(stack[0], original); + } + + #[test] + fn test_pushd_nonexistent_dir() { + let _g = TestGuard::new(); + + let result = exec_input("pushd /nonexistent_dir_12345".into(), None, false, None); + assert!(result.is_err()); + } +} diff --git a/src/builtin/echo.rs b/src/builtin/echo.rs index 71ae274..0c0793e 100644 --- a/src/builtin/echo.rs +++ b/src/builtin/echo.rs @@ -5,7 +5,7 @@ use crate::{ parse::{NdRule, Node, execute::prepare_argv}, prelude::*, procio::borrow_fd, - state, + state::{self, read_shopts}, }; pub const ECHO_OPTS: [OptSpec; 4] = [ @@ -31,7 +31,7 @@ bitflags! { #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct EchoFlags: u32 { const NO_NEWLINE = 0b000001; - const USE_STDERR = 0b000010; + const NO_ESCAPE = 0b000010; const USE_ESCAPE = 0b000100; const USE_PROMPT = 0b001000; } @@ -54,18 +54,17 @@ pub fn echo(node: Node) -> ShResult<()> { argv.remove(0); } - let output_channel = if flags.contains(EchoFlags::USE_STDERR) { - borrow_fd(STDERR_FILENO) - } else { - borrow_fd(STDOUT_FILENO) - }; + let output_channel = borrow_fd(STDOUT_FILENO); + let xpg_echo = read_shopts(|o| o.core.xpg_echo); // If true, echo expands escape sequences by default, and -E opts out + + let use_escape = (xpg_echo && !flags.contains(EchoFlags::NO_ESCAPE)) || flags.contains(EchoFlags::USE_ESCAPE); let mut echo_output = prepare_echo_args( argv .into_iter() .map(|a| a.0) // Extract the String from the tuple of (String,Span) .collect::>(), - flags.contains(EchoFlags::USE_ESCAPE), + use_escape, flags.contains(EchoFlags::USE_PROMPT), )? .join(" "); @@ -206,9 +205,9 @@ pub fn get_echo_flags(opts: Vec) -> ShResult { for opt in opts { match opt { Opt::Short('n') => flags |= EchoFlags::NO_NEWLINE, - Opt::Short('r') => flags |= EchoFlags::USE_STDERR, Opt::Short('e') => flags |= EchoFlags::USE_ESCAPE, Opt::Short('p') => flags |= EchoFlags::USE_PROMPT, + Opt::Short('E') => flags |= EchoFlags::NO_ESCAPE, _ => { return Err(ShErr::simple( ShErrKind::ExecFail, @@ -220,3 +219,254 @@ pub fn get_echo_flags(opts: Vec) -> ShResult { Ok(flags) } + +#[cfg(test)] +mod tests { + use super::prepare_echo_args; + use crate::state::{self, write_shopts}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Pure: prepare_echo_args ===================== + + #[test] + fn prepare_no_escape() { + let result = prepare_echo_args(vec!["hello\\nworld".into()], false, false).unwrap(); + assert_eq!(result, vec!["hello\\nworld"]); + } + + #[test] + fn prepare_escape_newline() { + let result = prepare_echo_args(vec!["hello\\nworld".into()], true, false).unwrap(); + assert_eq!(result, vec!["hello\nworld"]); + } + + #[test] + fn prepare_escape_tab() { + let result = prepare_echo_args(vec!["a\\tb".into()], true, false).unwrap(); + assert_eq!(result, vec!["a\tb"]); + } + + #[test] + fn prepare_escape_carriage_return() { + let result = prepare_echo_args(vec!["a\\rb".into()], true, false).unwrap(); + assert_eq!(result, vec!["a\rb"]); + } + + #[test] + fn prepare_escape_bell() { + let result = prepare_echo_args(vec!["a\\ab".into()], true, false).unwrap(); + assert_eq!(result, vec!["a\x07b"]); + } + + #[test] + fn prepare_escape_backspace() { + let result = prepare_echo_args(vec!["a\\bb".into()], true, false).unwrap(); + assert_eq!(result, vec!["a\x08b"]); + } + + #[test] + fn prepare_escape_escape_char() { + let result = prepare_echo_args(vec!["a\\eb".into()], true, false).unwrap(); + assert_eq!(result, vec!["a\x1bb"]); + } + + #[test] + fn prepare_escape_upper_e() { + let result = prepare_echo_args(vec!["a\\Eb".into()], true, false).unwrap(); + assert_eq!(result, vec!["a\x1bb"]); + } + + #[test] + fn prepare_escape_backslash() { + let result = prepare_echo_args(vec!["a\\\\b".into()], true, false).unwrap(); + assert_eq!(result, vec!["a\\b"]); + } + + #[test] + fn prepare_escape_hex() { + let result = prepare_echo_args(vec!["\\x41".into()], true, false).unwrap(); + assert_eq!(result, vec!["A"]); + } + + #[test] + fn prepare_escape_hex_lowercase() { + let result = prepare_echo_args(vec!["\\x61".into()], true, false).unwrap(); + assert_eq!(result, vec!["a"]); + } + + #[test] + fn prepare_escape_octal() { + let result = prepare_echo_args(vec!["\\0101".into()], true, false).unwrap(); + assert_eq!(result, vec!["A"]); // octal 101 = 65 = 'A' + } + + #[test] + fn prepare_escape_multiple() { + let result = prepare_echo_args(vec!["a\\nb\\tc".into()], true, false).unwrap(); + assert_eq!(result, vec!["a\nb\tc"]); + } + + #[test] + fn prepare_multiple_args() { + let result = prepare_echo_args( + vec!["hello".into(), "world".into()], + false, + false, + ).unwrap(); + assert_eq!(result, vec!["hello", "world"]); + } + + #[test] + fn prepare_trailing_backslash() { + let result = prepare_echo_args(vec!["hello\\".into()], true, false).unwrap(); + assert_eq!(result, vec!["hello\\"]); + } + + #[test] + fn prepare_unknown_escape_literal() { + // Unknown escape like \z should keep the backslash + let result = prepare_echo_args(vec!["\\z".into()], true, false).unwrap(); + assert_eq!(result, vec!["\\z"]); + } + + // ===================== Integration: basic echo ===================== + + #[test] + fn echo_simple() { + let guard = TestGuard::new(); + test_input("echo hello").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\n"); + } + + #[test] + fn echo_multiple_args() { + let guard = TestGuard::new(); + test_input("echo hello world").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello world\n"); + } + + #[test] + fn echo_no_args() { + let guard = TestGuard::new(); + test_input("echo").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "\n"); + } + + #[test] + fn echo_status_zero() { + let _g = TestGuard::new(); + test_input("echo hello").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== Integration: -n flag ===================== + + #[test] + fn echo_no_newline() { + let guard = TestGuard::new(); + test_input("echo -n hello").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello"); + } + + #[test] + fn echo_no_newline_no_args() { + let guard = TestGuard::new(); + test_input("echo -n").unwrap(); + let out = guard.read_output(); + assert_eq!(out, ""); + } + + // ===================== Integration: -e flag ===================== + + #[test] + fn echo_escape_newline() { + let guard = TestGuard::new(); + test_input("echo -e 'hello\\nworld'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\nworld\n"); + } + + #[test] + fn echo_escape_tab() { + let guard = TestGuard::new(); + test_input("echo -e 'a\\tb'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "a\tb\n"); + } + + #[test] + fn echo_no_escape_by_default() { + let guard = TestGuard::new(); + test_input("echo 'hello\\nworld'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\\nworld\n"); + } + + // ===================== Integration: -E flag + xpg_echo ===================== + + #[test] + fn echo_xpg_echo_expands_by_default() { + let guard = TestGuard::new(); + write_shopts(|o| o.core.xpg_echo = true); + + test_input("echo 'hello\\nworld'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\nworld\n"); + } + + #[test] + fn echo_xpg_echo_suppressed_by_big_e() { + let guard = TestGuard::new(); + write_shopts(|o| o.core.xpg_echo = true); + + test_input("echo -E 'hello\\nworld'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\\nworld\n"); + } + + #[test] + fn echo_small_e_overrides_without_xpg() { + let guard = TestGuard::new(); + write_shopts(|o| o.core.xpg_echo = false); + + test_input("echo -e 'a\\tb'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "a\tb\n"); + } + + #[test] + fn echo_big_e_noop_without_xpg() { + let guard = TestGuard::new(); + write_shopts(|o| o.core.xpg_echo = false); + + // -E without xpg_echo is a no-op — escapes already off + test_input("echo -E 'hello\\nworld'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\\nworld\n"); + } + + // ===================== Integration: combined flags ===================== + + #[test] + fn echo_n_and_e() { + let guard = TestGuard::new(); + test_input("echo -n -e 'a\\nb'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "a\nb"); + } + + #[test] + fn echo_xpg_n_suppresses_newline() { + let guard = TestGuard::new(); + write_shopts(|o| o.core.xpg_echo = true); + + test_input("echo -n 'hello\\nworld'").unwrap(); + let out = guard.read_output(); + // xpg_echo expands \n, -n suppresses trailing newline + assert_eq!(out, "hello\nworld"); + } +} diff --git a/src/builtin/eval.rs b/src/builtin/eval.rs index cd3c74a..b572788 100644 --- a/src/builtin/eval.rs +++ b/src/builtin/eval.rs @@ -34,3 +34,90 @@ pub fn eval(node: Node) -> ShResult<()> { exec_input(joined_argv, None, false, Some("eval".into())) } + +#[cfg(test)] +mod tests { + use crate::state::{self, read_vars, write_vars, VarFlags, VarKind}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Basic ===================== + + #[test] + fn eval_simple_command() { + let guard = TestGuard::new(); + test_input("eval echo hello").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\n"); + } + + #[test] + fn eval_no_args_succeeds() { + let _g = TestGuard::new(); + test_input("eval").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn eval_status_zero() { + let _g = TestGuard::new(); + test_input("eval true").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== Joins args ===================== + + #[test] + fn eval_joins_args() { + let guard = TestGuard::new(); + // eval receives "echo" "hello" "world" as separate args, joins to "echo hello world" + test_input("eval echo hello world").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello world\n"); + } + + // ===================== Re-evaluation ===================== + + #[test] + fn eval_expands_variable() { + let guard = TestGuard::new(); + write_vars(|v| v.set_var("CMD", VarKind::Str("echo evaluated".into()), VarFlags::NONE)).unwrap(); + + test_input("eval $CMD").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "evaluated\n"); + } + + #[test] + fn eval_sets_variable() { + let _g = TestGuard::new(); + test_input("eval x=42").unwrap(); + let val = read_vars(|v| v.get_var("x")); + assert_eq!(val, "42"); + } + + #[test] + fn eval_pipeline() { + let guard = TestGuard::new(); + test_input("eval 'echo hello | cat'").unwrap(); + let out = guard.read_output(); + assert_eq!(out, "hello\n"); + } + + #[test] + fn eval_compound_command() { + let guard = TestGuard::new(); + test_input("eval 'echo first; echo second'").unwrap(); + let out = guard.read_output(); + assert!(out.contains("first")); + assert!(out.contains("second")); + } + + // ===================== Status propagation ===================== + + #[test] + fn eval_propagates_failure_status() { + let _g = TestGuard::new(); + let _ = test_input("eval false"); + assert_ne!(state::get_status(), 0); + } +} diff --git a/src/builtin/exec.rs b/src/builtin/exec.rs index 140d3dd..31c962a 100644 --- a/src/builtin/exec.rs +++ b/src/builtin/exec.rs @@ -45,3 +45,24 @@ pub fn exec_builtin(node: Node) -> ShResult<()> { _ => Err(ShErr::at(ShErrKind::Errno(e), span, format!("{e}"))), } } + +#[cfg(test)] +mod tests { + use crate::state; + use crate::testutil::{TestGuard, test_input}; + // Testing exec is a bit tricky since it replaces the current process, so we just test that it correctly handles the case of no arguments and the case of a nonexistent command. We can't really test that it successfully executes a command since that would replace the test process itself. + + #[test] + fn exec_no_args_succeeds() { + let _g = TestGuard::new(); + test_input("exec").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn exec_nonexistent_command_fails() { + let _g = TestGuard::new(); + let result = test_input("exec _____________no_such_______command_xyz_____________hopefully______this_doesnt______exist_____somewhere_in___your______PATH__________________"); + assert!(result.is_err()); + } +} diff --git a/src/builtin/flowctl.rs b/src/builtin/flowctl.rs index ca5fdd4..b2947d2 100644 --- a/src/builtin/flowctl.rs +++ b/src/builtin/flowctl.rs @@ -41,3 +41,105 @@ pub fn flowctl(node: Node, kind: ShErrKind) -> ShResult<()> { Err(ShErr::simple(kind, message)) } + +#[cfg(test)] +mod tests { + use crate::libsh::error::ShErrKind; + use crate::state; + use crate::testutil::{TestGuard, test_input}; + + // ===================== break ===================== + + #[test] + fn break_exits_loop() { + let guard = TestGuard::new(); + test_input("for i in 1 2 3; do echo $i; break; done").unwrap(); + let out = guard.read_output(); + assert_eq!(out.trim(), "1"); + } + + #[test] + fn break_outside_loop_errors() { + let _g = TestGuard::new(); + let result = test_input("break"); + assert!(result.is_err()); + } + + #[test] + fn break_non_numeric_errors() { + let _g = TestGuard::new(); + let result = test_input("for i in 1; do break abc; done"); + assert!(result.is_err()); + } + + // ===================== continue ===================== + + #[test] + fn continue_skips_iteration() { + let guard = TestGuard::new(); + test_input("for i in 1 2 3; do if [[ $i == 2 ]]; then continue; fi; echo $i; done").unwrap(); + let out = guard.read_output(); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines, vec!["1", "3"]); + } + + #[test] + fn continue_outside_loop_errors() { + let _g = TestGuard::new(); + let result = test_input("continue"); + assert!(result.is_err()); + } + + // ===================== return ===================== + + #[test] + fn return_exits_function() { + let guard = TestGuard::new(); + test_input("f() { echo before; return; echo after; }").unwrap(); + test_input("f").unwrap(); + let out = guard.read_output(); + assert_eq!(out.trim(), "before"); + } + + #[test] + fn return_with_status() { + let _g = TestGuard::new(); + test_input("f() { return 42; }").unwrap(); + test_input("f").unwrap(); + assert_eq!(state::get_status(), 42); + } + + #[test] + fn return_outside_function_errors() { + let _g = TestGuard::new(); + let result = test_input("return"); + assert!(result.is_err()); + } + + // ===================== exit ===================== + + #[test] + fn exit_returns_clean_exit() { + let _g = TestGuard::new(); + let result = test_input("exit 0"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err.kind(), ShErrKind::CleanExit(0))); + } + + #[test] + fn exit_with_code() { + let _g = TestGuard::new(); + let result = test_input("exit 5"); + assert!(result.is_err()); + let err = result.unwrap_err(); + assert!(matches!(err.kind(), ShErrKind::CleanExit(5))); + } + + #[test] + fn exit_non_numeric_errors() { + let _g = TestGuard::new(); + let result = test_input("exit abc"); + assert!(result.is_err()); + } +} diff --git a/src/builtin/getopts.rs b/src/builtin/getopts.rs index 7a9c12b..435b1ce 100644 --- a/src/builtin/getopts.rs +++ b/src/builtin/getopts.rs @@ -251,3 +251,217 @@ pub fn getopts(node: Node) -> ShResult<()> { getopts_inner(&opts_spec, &opt_var.0, &pos_params, span) } } + +#[cfg(test)] +mod tests { + use crate::state::{self, read_vars}; + use crate::testutil::{TestGuard, test_input}; + + fn get_var(name: &str) -> String { + read_vars(|v| v.get_var(name)) + } + + // ===================== Spec parsing ===================== + + #[test] + fn parse_simple_spec() { + use super::GetOptsSpec; + use std::str::FromStr; + let spec = GetOptsSpec::from_str("abc").unwrap(); + assert!(!spec.silent_err); + assert_eq!(spec.opt_specs.len(), 3); + } + + #[test] + fn parse_spec_with_args() { + use super::GetOptsSpec; + use std::str::FromStr; + let spec = GetOptsSpec::from_str("a:bc:").unwrap(); + assert!(!spec.silent_err); + assert!(spec.opt_specs[0].takes_arg); // a: + assert!(!spec.opt_specs[1].takes_arg); // b + assert!(spec.opt_specs[2].takes_arg); // c: + } + + #[test] + fn parse_silent_spec() { + use super::GetOptsSpec; + use std::str::FromStr; + let spec = GetOptsSpec::from_str(":ab").unwrap(); + assert!(spec.silent_err); + assert_eq!(spec.opt_specs.len(), 2); + } + + #[test] + fn parse_invalid_char() { + use super::GetOptsSpec; + use std::str::FromStr; + let result = GetOptsSpec::from_str("a@b"); + assert!(result.is_err()); + } + + // ===================== Basic option matching ===================== + + #[test] + fn getopts_simple_flag() { + let _g = TestGuard::new(); + test_input("getopts ab opt -a").unwrap(); + assert_eq!(get_var("opt"), "a"); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn getopts_second_flag() { + let _g = TestGuard::new(); + test_input("getopts ab opt -b").unwrap(); + assert_eq!(get_var("opt"), "b"); + } + + // ===================== Option with argument ===================== + + #[test] + fn getopts_option_with_separate_arg() { + let _g = TestGuard::new(); + test_input("getopts a: opt -a value").unwrap(); + assert_eq!(get_var("opt"), "a"); + assert_eq!(get_var("OPTARG"), "value"); + } + + #[test] + fn getopts_option_with_attached_arg() { + let _g = TestGuard::new(); + test_input("getopts a: opt -avalue").unwrap(); + assert_eq!(get_var("opt"), "a"); + assert_eq!(get_var("OPTARG"), "value"); + } + + // ===================== Bundled options ===================== + + #[test] + fn getopts_bundled_flags() { + let _g = TestGuard::new(); + + // First call gets 'a' from -ab + test_input("getopts abc opt -ab").unwrap(); + assert_eq!(get_var("opt"), "a"); + + // Second call gets 'b' from same -ab + test_input("getopts abc opt -ab").unwrap(); + assert_eq!(get_var("opt"), "b"); + } + + // ===================== OPTIND advancement ===================== + + #[test] + fn getopts_advances_optind() { + let _g = TestGuard::new(); + test_input("getopts ab opt -a").unwrap(); + + let optind: usize = get_var("OPTIND").parse().unwrap(); + assert_eq!(optind, 2); // Advanced past -a + } + + #[test] + fn getopts_arg_option_advances_by_two() { + let _g = TestGuard::new(); + test_input("getopts a: opt -a val").unwrap(); + + let optind: usize = get_var("OPTIND").parse().unwrap(); + assert_eq!(optind, 3); // Advanced past both -a and val + } + + // ===================== Multiple calls (loop simulation) ===================== + + #[test] + fn getopts_multiple_separate_args() { + let _g = TestGuard::new(); + + test_input("getopts ab opt -a -b").unwrap(); + assert_eq!(get_var("opt"), "a"); + assert_eq!(state::get_status(), 0); + + test_input("getopts ab opt -a -b").unwrap(); + assert_eq!(get_var("opt"), "b"); + assert_eq!(state::get_status(), 0); + + // Third call: no more options + test_input("getopts ab opt -a -b").unwrap(); + assert_eq!(state::get_status(), 1); + } + + // ===================== End of options ===================== + + #[test] + fn getopts_no_options_returns_1() { + let _g = TestGuard::new(); + test_input("getopts ab opt foo").unwrap(); + assert_eq!(state::get_status(), 1); + } + + #[test] + fn getopts_double_dash_stops() { + let _g = TestGuard::new(); + test_input("getopts ab opt -- -a").unwrap(); + assert_eq!(state::get_status(), 1); + } + + #[test] + fn getopts_bare_dash_stops() { + let _g = TestGuard::new(); + test_input("getopts ab opt -").unwrap(); + assert_eq!(state::get_status(), 1); + } + + // ===================== Unknown option ===================== + + #[test] + fn getopts_unknown_option() { + let _g = TestGuard::new(); + test_input("getopts ab opt -z").unwrap(); + assert_eq!(get_var("opt"), "?"); + assert_eq!(state::get_status(), 0); + } + + // ===================== Silent error mode ===================== + + #[test] + fn getopts_silent_unknown_sets_optarg() { + let _g = TestGuard::new(); + test_input("getopts :ab opt -z").unwrap(); + assert_eq!(get_var("opt"), "?"); + assert_eq!(get_var("OPTARG"), "z"); + } + + #[test] + fn getopts_silent_missing_arg() { + let _g = TestGuard::new(); + test_input("getopts :a: opt -a").unwrap(); + assert_eq!(get_var("opt"), ":"); + assert_eq!(get_var("OPTARG"), "a"); + } + + // ===================== Missing required argument (non-silent) ===================== + + #[test] + fn getopts_missing_arg_non_silent() { + let _g = TestGuard::new(); + test_input("getopts a: opt -a").unwrap(); + assert_eq!(get_var("opt"), "?"); + } + + // ===================== Error cases ===================== + + #[test] + fn getopts_missing_spec() { + let _g = TestGuard::new(); + let result = test_input("getopts"); + assert!(result.is_err()); + } + + #[test] + fn getopts_missing_varname() { + let _g = TestGuard::new(); + let result = test_input("getopts ab"); + assert!(result.is_err()); + } +} diff --git a/src/builtin/intro.rs b/src/builtin/intro.rs index 7de4400..5476cdd 100644 --- a/src/builtin/intro.rs +++ b/src/builtin/intro.rs @@ -1,4 +1,4 @@ -use std::{env, os::unix::fs::PermissionsExt, path::Path}; +use std::os::unix::fs::PermissionsExt; use ariadne::{Fmt, Span}; @@ -6,6 +6,8 @@ use crate::{ builtin::BUILTINS, libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::{NdRule, Node, execute::prepare_argv, lex::KEYWORDS}, + prelude::*, + procio::borrow_fd, state::{self, ShAlias, ShFunc, read_logic}, }; @@ -31,28 +33,33 @@ pub fn type_builtin(node: Node) -> ShResult<()> { */ 'outer: for (arg, span) in argv { + let stdout = borrow_fd(STDOUT_FILENO); if let Some(func) = read_logic(|v| v.get_func(&arg)) { let ShFunc { body: _, source } = func; let (line, col) = source.line_and_col(); let name = source.source().name(); - println!( - "{arg} is a function defined at {name}:{}:{}", + let msg = format!( + "{arg} is a function defined at {name}:{}:{}\n", line + 1, col + 1 ); + write(stdout, msg.as_bytes())?; } else if let Some(alias) = read_logic(|v| v.get_alias(&arg)) { let ShAlias { body, source } = alias; let (line, col) = source.line_and_col(); let name = source.source().name(); - println!( - "{arg} is an alias for '{body}' defined at {name}:{}:{}", + let msg = format!( + "{arg} is an alias for '{body}' defined at {name}:{}:{}\n", line + 1, col + 1 ); + write(stdout, msg.as_bytes())?; } else if BUILTINS.contains(&arg.as_str()) { - println!("{arg} is a shell builtin"); + let msg = format!("{arg} is a shell builtin\n"); + write(stdout, msg.as_bytes())?; } else if KEYWORDS.contains(&arg.as_str()) { - println!("{arg} is a shell keyword"); + let msg = format!("{arg} is a shell keyword\n"); + write(stdout, msg.as_bytes())?; } else { let path = env::var("PATH").unwrap_or_default(); let paths = path.split(':').map(Path::new).collect::>(); @@ -70,7 +77,8 @@ pub fn type_builtin(node: Node) -> ShResult<()> { && let Some(name) = entry.file_name().to_str() && name == arg { - println!("{arg} is {}", entry.path().display()); + let msg = format!("{arg} is {}\n", entry.path().display()); + write(stdout, msg.as_bytes())?; continue 'outer; } } @@ -92,3 +100,136 @@ pub fn type_builtin(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use crate::state::{self}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Builtins ===================== + + #[test] + fn type_builtin_echo() { + let guard = TestGuard::new(); + test_input("type echo").unwrap(); + let out = guard.read_output(); + assert!(out.contains("echo")); + assert!(out.contains("shell builtin")); + } + + #[test] + fn type_builtin_cd() { + let guard = TestGuard::new(); + test_input("type cd").unwrap(); + let out = guard.read_output(); + assert!(out.contains("cd")); + assert!(out.contains("shell builtin")); + } + + // ===================== Keywords ===================== + + #[test] + fn type_keyword_if() { + let guard = TestGuard::new(); + test_input("type if").unwrap(); + let out = guard.read_output(); + assert!(out.contains("if")); + assert!(out.contains("shell keyword")); + } + + #[test] + fn type_keyword_for() { + let guard = TestGuard::new(); + test_input("type for").unwrap(); + let out = guard.read_output(); + assert!(out.contains("for")); + assert!(out.contains("shell keyword")); + } + + // ===================== Functions ===================== + + #[test] + fn type_function() { + let guard = TestGuard::new(); + test_input("myfn() { echo hi; }").unwrap(); + guard.read_output(); + + test_input("type myfn").unwrap(); + let out = guard.read_output(); + assert!(out.contains("myfn")); + assert!(out.contains("function")); + } + + // ===================== Aliases ===================== + + #[test] + fn type_alias() { + let guard = TestGuard::new(); + test_input("alias ll='ls -la'").unwrap(); + guard.read_output(); + + test_input("type ll").unwrap(); + let out = guard.read_output(); + assert!(out.contains("ll")); + assert!(out.contains("alias")); + assert!(out.contains("ls -la")); + } + + // ===================== External commands ===================== + + #[test] + fn type_external_command() { + let guard = TestGuard::new(); + // /bin/cat or /usr/bin/cat should exist on any Unix system + test_input("type cat").unwrap(); + let out = guard.read_output(); + assert!(out.contains("cat")); + assert!(out.contains("is")); + assert!(out.contains("/")); // Should show a path + } + + // ===================== Not found ===================== + + #[test] + fn type_not_found() { + let _g = TestGuard::new(); + let result = test_input("type __hopefully____not_______a____command__"); + assert!(result.is_err()); + assert_eq!(state::get_status(), 1); + } + + // ===================== Priority order ===================== + + #[test] + fn type_function_shadows_builtin() { + let guard = TestGuard::new(); + // Define a function named 'echo' — should shadow the builtin + test_input("echo() { true; }").unwrap(); + guard.read_output(); + + test_input("type echo").unwrap(); + let out = guard.read_output(); + assert!(out.contains("function")); + } + + #[test] + fn type_alias_shadows_external() { + let guard = TestGuard::new(); + test_input("alias cat='echo meow'").unwrap(); + guard.read_output(); + + test_input("type cat").unwrap(); + let out = guard.read_output(); + // alias check comes before external PATH scan + assert!(out.contains("alias")); + } + + // ===================== Status ===================== + + #[test] + fn type_status_zero_on_found() { + let _g = TestGuard::new(); + test_input("type echo").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/keymap.rs b/src/builtin/keymap.rs index 1592379..c093f9f 100644 --- a/src/builtin/keymap.rs +++ b/src/builtin/keymap.rs @@ -59,7 +59,7 @@ impl KeyMapOpts { } Ok(Self { remove, flags }) } - pub fn keymap_opts() -> [OptSpec; 6] { + pub fn keymap_opts() -> [OptSpec; 7] { [ OptSpec { opt: Opt::Short('n'), // normal mode @@ -81,6 +81,10 @@ impl KeyMapOpts { opt: Opt::Short('o'), // operator-pending mode takes_arg: false, }, + OptSpec { + opt: Opt::Long("remove".into()), + takes_arg: true, + }, OptSpec { opt: Opt::Short('r'), // replace mode takes_arg: false, @@ -172,3 +176,169 @@ pub fn keymap(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::getopt::Opt; + use crate::expand::expand_keymap; + use crate::state::{self, read_logic}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== KeyMapOpts parsing ===================== + + #[test] + fn opts_normal_mode() { + let opts = KeyMapOpts::from_opts(&[Opt::Short('n')]).unwrap(); + assert!(opts.flags.contains(KeyMapFlags::NORMAL)); + } + + #[test] + fn opts_insert_mode() { + let opts = KeyMapOpts::from_opts(&[Opt::Short('i')]).unwrap(); + assert!(opts.flags.contains(KeyMapFlags::INSERT)); + } + + #[test] + fn opts_multiple_modes() { + let opts = KeyMapOpts::from_opts(&[Opt::Short('n'), Opt::Short('i')]).unwrap(); + assert!(opts.flags.contains(KeyMapFlags::NORMAL)); + assert!(opts.flags.contains(KeyMapFlags::INSERT)); + } + + #[test] + fn opts_no_mode_errors() { + let result = KeyMapOpts::from_opts(&[]); + assert!(result.is_err()); + } + + #[test] + fn opts_remove() { + let opts = KeyMapOpts::from_opts(&[ + Opt::Short('n'), + Opt::LongWithArg("remove".into(), "jk".into()), + ]).unwrap(); + assert_eq!(opts.remove, Some("jk".into())); + } + + #[test] + fn opts_duplicate_remove_errors() { + let result = KeyMapOpts::from_opts(&[ + Opt::Short('n'), + Opt::LongWithArg("remove".into(), "jk".into()), + Opt::LongWithArg("remove".into(), "kj".into()), + ]); + assert!(result.is_err()); + } + + // ===================== KeyMap::compare ===================== + + #[test] + fn compare_exact_match() { + let km = KeyMap { + flags: KeyMapFlags::NORMAL, + keys: "jk".into(), + action: "".into(), + }; + let keys = expand_keymap("jk"); + assert_eq!(km.compare(&keys), KeyMapMatch::IsExact); + } + + #[test] + fn compare_prefix_match() { + let km = KeyMap { + flags: KeyMapFlags::NORMAL, + keys: "jk".into(), + action: "".into(), + }; + let keys = expand_keymap("j"); + assert_eq!(km.compare(&keys), KeyMapMatch::IsPrefix); + } + + #[test] + fn compare_no_match() { + let km = KeyMap { + flags: KeyMapFlags::NORMAL, + keys: "jk".into(), + action: "".into(), + }; + let keys = expand_keymap("zz"); + assert_eq!(km.compare(&keys), KeyMapMatch::NoMatch); + } + + // ===================== Registration via test_input ===================== + + #[test] + fn keymap_register() { + let _g = TestGuard::new(); + test_input("keymap -n jk ''").unwrap(); + + let maps = read_logic(|l| l.keymaps_filtered( + KeyMapFlags::NORMAL, + &expand_keymap("jk"), + )); + assert!(!maps.is_empty()); + } + + #[test] + fn keymap_register_insert() { + let _g = TestGuard::new(); + test_input("keymap -i jk ''").unwrap(); + + let maps = read_logic(|l| l.keymaps_filtered( + KeyMapFlags::INSERT, + &expand_keymap("jk"), + )); + assert!(!maps.is_empty()); + } + + #[test] + fn keymap_overwrite() { + let _g = TestGuard::new(); + test_input("keymap -n jk ''").unwrap(); + test_input("keymap -n jk 'dd'").unwrap(); + + let maps = read_logic(|l| l.keymaps_filtered( + KeyMapFlags::NORMAL, + &expand_keymap("jk"), + )); + assert_eq!(maps.len(), 1); + assert_eq!(maps[0].action, "dd"); + } + + #[test] + fn keymap_remove() { + let _g = TestGuard::new(); + test_input("keymap -n jk ''").unwrap(); + test_input("keymap -n --remove jk").unwrap(); + + let maps = read_logic(|l| l.keymaps_filtered( + KeyMapFlags::NORMAL, + &expand_keymap("jk"), + )); + assert!(maps.is_empty()); + } + + #[test] + fn keymap_status_zero() { + let _g = TestGuard::new(); + test_input("keymap -n jk ''").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== Error cases ===================== + + #[test] + fn keymap_missing_keys() { + let _g = TestGuard::new(); + let result = test_input("keymap -n"); + assert!(result.is_err()); + } + + #[test] + fn keymap_missing_action() { + let _g = TestGuard::new(); + let result = test_input("keymap -n jk"); + assert!(result.is_err()); + } +} diff --git a/src/builtin/map.rs b/src/builtin/map.rs index 7952364..2c50852 100644 --- a/src/builtin/map.rs +++ b/src/builtin/map.rs @@ -368,6 +368,240 @@ pub fn map(node: Node) -> ShResult<()> { Ok(()) } +#[cfg(test)] +mod tests { + use super::{MapNode, MapFlags, get_map_opts}; + use crate::getopt::Opt; + use crate::state::{self, read_vars}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Pure: MapNode get/set/remove ===================== + + #[test] + fn mapnode_set_and_get() { + let mut root = MapNode::default(); + root.set(&["key".into()], MapNode::StaticLeaf("val".into())); + let node = root.get(&["key".into()]).unwrap(); + assert!(matches!(node, MapNode::StaticLeaf(s) if s == "val")); + } + + #[test] + fn mapnode_nested_set_and_get() { + let mut root = MapNode::default(); + root.set( + &["a".into(), "b".into(), "c".into()], + MapNode::StaticLeaf("deep".into()), + ); + let node = root.get(&["a".into(), "b".into(), "c".into()]).unwrap(); + assert!(matches!(node, MapNode::StaticLeaf(s) if s == "deep")); + } + + #[test] + fn mapnode_get_missing() { + let root = MapNode::default(); + assert!(root.get(&["nope".into()]).is_none()); + } + + #[test] + fn mapnode_remove() { + let mut root = MapNode::default(); + root.set(&["key".into()], MapNode::StaticLeaf("val".into())); + let removed = root.remove(&["key".into()]); + assert!(removed.is_some()); + assert!(root.get(&["key".into()]).is_none()); + } + + #[test] + fn mapnode_remove_nested() { + let mut root = MapNode::default(); + root.set( + &["a".into(), "b".into()], + MapNode::StaticLeaf("val".into()), + ); + root.remove(&["a".into(), "b".into()]); + assert!(root.get(&["a".into(), "b".into()]).is_none()); + // Parent branch should still exist + assert!(root.get(&["a".into()]).is_some()); + } + + #[test] + fn mapnode_keys() { + let mut root = MapNode::default(); + root.set(&["x".into()], MapNode::StaticLeaf("1".into())); + root.set(&["y".into()], MapNode::StaticLeaf("2".into())); + let mut keys = root.keys(); + keys.sort(); + assert_eq!(keys, vec!["x", "y"]); + } + + #[test] + fn mapnode_display_leaf() { + let leaf = MapNode::StaticLeaf("hello".into()); + assert_eq!(leaf.display(false, false).unwrap(), "hello"); + } + + #[test] + fn mapnode_display_json() { + let mut root = MapNode::default(); + root.set(&["k".into()], MapNode::StaticLeaf("v".into())); + let json = root.display(true, false).unwrap(); + assert!(json.contains("\"k\"")); + assert!(json.contains("\"v\"")); + } + + #[test] + fn mapnode_overwrite() { + let mut root = MapNode::default(); + root.set(&["key".into()], MapNode::StaticLeaf("old".into())); + root.set(&["key".into()], MapNode::StaticLeaf("new".into())); + let node = root.get(&["key".into()]).unwrap(); + assert!(matches!(node, MapNode::StaticLeaf(s) if s == "new")); + } + + #[test] + fn mapnode_promote_leaf_to_branch() { + let mut root = MapNode::default(); + root.set(&["key".into()], MapNode::StaticLeaf("leaf".into())); + // Setting a sub-path should promote the leaf to a branch + root.set( + &["key".into(), "sub".into()], + MapNode::StaticLeaf("nested".into()), + ); + let node = root.get(&["key".into(), "sub".into()]).unwrap(); + assert!(matches!(node, MapNode::StaticLeaf(s) if s == "nested")); + } + + // ===================== Pure: MapNode JSON round-trip ===================== + + #[test] + fn mapnode_json_roundtrip() { + let mut root = MapNode::default(); + root.set(&["name".into()], MapNode::StaticLeaf("test".into())); + root.set(&["count".into()], MapNode::StaticLeaf("42".into())); + + let val: serde_json::Value = root.clone().into(); + let back: MapNode = val.into(); + assert!(back.get(&["name".into()]).is_some()); + assert!(back.get(&["count".into()]).is_some()); + } + + // ===================== Pure: option parsing ===================== + + #[test] + fn parse_remove_flag() { + let opts = get_map_opts(vec![Opt::Short('r')]); + assert!(opts.flags.contains(MapFlags::REMOVE)); + } + + #[test] + fn parse_json_flag() { + let opts = get_map_opts(vec![Opt::Short('j')]); + assert!(opts.flags.contains(MapFlags::JSON)); + } + + #[test] + fn parse_keys_flag() { + let opts = get_map_opts(vec![Opt::Short('k')]); + assert!(opts.flags.contains(MapFlags::KEYS)); + } + + #[test] + fn parse_pretty_flag() { + let opts = get_map_opts(vec![Opt::Long("pretty".into())]); + assert!(opts.flags.contains(MapFlags::PRETTY)); + } + + #[test] + fn parse_func_flag() { + let opts = get_map_opts(vec![Opt::Short('F')]); + assert!(opts.flags.contains(MapFlags::FUNC)); + } + + #[test] + fn parse_combined_flags() { + let opts = get_map_opts(vec![Opt::Short('j'), Opt::Short('k')]); + assert!(opts.flags.contains(MapFlags::JSON)); + assert!(opts.flags.contains(MapFlags::KEYS)); + } + + // ===================== Integration ===================== + + #[test] + fn map_set_and_read() { + let guard = TestGuard::new(); + test_input("map mymap.key=hello").unwrap(); + test_input("map mymap.key").unwrap(); + let out = guard.read_output(); + assert_eq!(out.trim(), "hello"); + } + + #[test] + fn map_nested_path() { + let guard = TestGuard::new(); + test_input("map mymap.a.b.c=deep").unwrap(); + test_input("map mymap.a.b.c").unwrap(); + let out = guard.read_output(); + assert_eq!(out.trim(), "deep"); + } + + #[test] + fn map_remove() { + let _g = TestGuard::new(); + test_input("map mymap.key=val").unwrap(); + test_input("map -r mymap.key").unwrap(); + let has = read_vars(|v| { + v.get_map("mymap") + .and_then(|m| m.get(&["key".into()]).cloned()) + .is_some() + }); + assert!(!has); + } + + #[test] + fn map_remove_entire() { + let _g = TestGuard::new(); + test_input("map mymap.key=val").unwrap(); + test_input("map -r mymap").unwrap(); + let has = read_vars(|v| v.get_map("mymap").is_some()); + assert!(!has); + } + + #[test] + fn map_keys() { + let guard = TestGuard::new(); + test_input("map mymap.x=1").unwrap(); + test_input("map mymap.y=2").unwrap(); + test_input("map -k mymap").unwrap(); + let out = guard.read_output(); + assert!(out.contains("x")); + assert!(out.contains("y")); + } + + #[test] + fn map_json_output() { + let guard = TestGuard::new(); + test_input("map mymap.key=val").unwrap(); + test_input("map -j mymap").unwrap(); + let out = guard.read_output(); + assert!(out.contains("\"key\"")); + assert!(out.contains("\"val\"")); + } + + #[test] + fn map_nonexistent_errors() { + let _g = TestGuard::new(); + let result = test_input("map __no_such_map__"); + assert!(result.is_err()); + } + + #[test] + fn map_status_zero() { + let _g = TestGuard::new(); + test_input("map mymap.key=val").unwrap(); + assert_eq!(state::get_status(), 0); + } +} + pub fn get_map_opts(opts: Vec) -> MapOpts { let mut map_opts = MapOpts { flags: MapFlags::empty(), diff --git a/src/builtin/mod.rs b/src/builtin/mod.rs index 3a8b5d5..a86bc09 100644 --- a/src/builtin/mod.rs +++ b/src/builtin/mod.rs @@ -23,12 +23,11 @@ pub mod source; pub mod test; // [[ ]] thing pub mod trap; pub mod varcmds; -pub mod zoltraak; pub mod resource; pub const BUILTINS: [&str; 49] = [ - "echo", "cd", "read", "export", "local", "pwd", "source", "shift", "jobs", "fg", "bg", "disown", - "alias", "unalias", "return", "break", "continue", "exit", "zoltraak", "shopt", "builtin", + "echo", "cd", "read", "export", "local", "pwd", "source", ".", "shift", "jobs", "fg", "bg", "disown", + "alias", "unalias", "return", "break", "continue", "exit", "shopt", "builtin", "command", "trap", "pushd", "popd", "dirs", "exec", "eval", "true", "false", ":", "readonly", "unset", "complete", "compgen", "map", "pop", "fpop", "push", "fpush", "rotate", "wait", "type", "getopts", "keymap", "read_key", "autocmd", "ulimit", "umask" @@ -48,3 +47,34 @@ pub fn noop_builtin() -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +pub mod tests { + use crate::{state, testutil::{TestGuard, test_input}}; + + // You can never be too sure!!!!!! + + #[test] + fn test_true() { + let _g = TestGuard::new(); + test_input("true").unwrap(); + + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_false() { + let _g = TestGuard::new(); + test_input("false").unwrap(); + + assert_eq!(state::get_status(), 1); + } + + #[test] + fn test_noop() { + let _g = TestGuard::new(); + test_input(":").unwrap(); + + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/pwd.rs b/src/builtin/pwd.rs index 2a60796..f8a673f 100644 --- a/src/builtin/pwd.rs +++ b/src/builtin/pwd.rs @@ -24,3 +24,41 @@ pub fn pwd(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use std::env; + use tempfile::TempDir; + use crate::state; + use crate::testutil::{TestGuard, test_input}; + + #[test] + fn pwd_prints_cwd() { + let guard = TestGuard::new(); + let cwd = env::current_dir().unwrap(); + + test_input("pwd").unwrap(); + let out = guard.read_output(); + assert_eq!(out.trim(), cwd.display().to_string()); + } + + #[test] + fn pwd_after_cd() { + let guard = TestGuard::new(); + let tmp = TempDir::new().unwrap(); + + test_input(format!("cd {}", tmp.path().display())).unwrap(); + guard.read_output(); + + test_input("pwd").unwrap(); + let out = guard.read_output(); + assert_eq!(out.trim(), tmp.path().display().to_string()); + } + + #[test] + fn pwd_status_zero() { + let _g = TestGuard::new(); + test_input("pwd").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/read.rs b/src/builtin/read.rs index 935e93f..c84cb17 100644 --- a/src/builtin/read.rs +++ b/src/builtin/read.rs @@ -212,7 +212,8 @@ pub fn read_builtin(node: Node) -> ShResult<()> { for (i, arg) in argv.iter().enumerate() { if i == argv.len() - 1 { // Last arg, stuff the rest of the input into it - write_vars(|v| v.set_var(&arg.0, VarKind::Str(remaining.clone()), VarFlags::NONE))?; + let trimmed = remaining.trim_start_matches(|c: char| field_sep.contains(c)); + write_vars(|v| v.set_var(&arg.0, VarKind::Str(trimmed.to_string()), VarFlags::NONE))?; break; } @@ -340,6 +341,134 @@ pub fn read_key(node: Node) -> ShResult<()> { Ok(()) } +#[cfg(test)] +mod tests { + use crate::state::{self, read_vars, write_vars, VarFlags, VarKind}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Basic read into REPLY ===================== + + #[test] + fn read_pipe_into_reply() { + let _g = TestGuard::new(); + test_input("read < <(echo hello)").unwrap(); + let val = read_vars(|v| v.get_var("REPLY")); + assert_eq!(val, "hello"); + } + + #[test] + fn read_pipe_into_named_var() { + let _g = TestGuard::new(); + test_input("read myvar < <(echo world)").unwrap(); + let val = read_vars(|v| v.get_var("myvar")); + assert_eq!(val, "world"); + } + + // ===================== Field splitting ===================== + + #[test] + fn read_two_vars() { + let _g = TestGuard::new(); + test_input("read a b < <(echo 'hello world')").unwrap(); + assert_eq!(read_vars(|v| v.get_var("a")), "hello"); + assert_eq!(read_vars(|v| v.get_var("b")), "world"); + } + + #[test] + fn read_last_var_gets_remainder() { + let _g = TestGuard::new(); + test_input("read a b < <(echo 'one two three four')").unwrap(); + assert_eq!(read_vars(|v| v.get_var("a")), "one"); + assert_eq!(read_vars(|v| v.get_var("b")), "two three four"); + } + + #[test] + fn read_more_vars_than_fields() { + let _g = TestGuard::new(); + test_input("read a b c < <(echo 'only')").unwrap(); + assert_eq!(read_vars(|v| v.get_var("a")), "only"); + // b and c get empty strings since there are no more fields + assert_eq!(read_vars(|v| v.get_var("b")), ""); + assert_eq!(read_vars(|v| v.get_var("c")), ""); + } + + // ===================== Custom IFS ===================== + + #[test] + fn read_custom_ifs() { + let _g = TestGuard::new(); + write_vars(|v| v.set_var("IFS", VarKind::Str(":".into()), VarFlags::NONE)).unwrap(); + + test_input("read x y z < <(echo 'a:b:c')").unwrap(); + assert_eq!(read_vars(|v| v.get_var("x")), "a"); + assert_eq!(read_vars(|v| v.get_var("y")), "b"); + assert_eq!(read_vars(|v| v.get_var("z")), "c"); + } + + #[test] + fn read_custom_ifs_remainder() { + let _g = TestGuard::new(); + write_vars(|v| v.set_var("IFS", VarKind::Str(":".into()), VarFlags::NONE)).unwrap(); + + test_input("read x y < <(echo 'a:b:c:d')").unwrap(); + assert_eq!(read_vars(|v| v.get_var("x")), "a"); + assert_eq!(read_vars(|v| v.get_var("y")), "b:c:d"); + } + + // ===================== Custom delimiter ===================== + + #[test] + fn read_custom_delim() { + let _g = TestGuard::new(); + // -d sets the delimiter; printf sends "hello,world" — read stops at ',' + test_input("read -d , myvar < <(echo -n 'hello,world')").unwrap(); + assert_eq!(read_vars(|v| v.get_var("myvar")), "hello"); + } + + // ===================== Status ===================== + + #[test] + fn read_status_zero() { + let _g = TestGuard::new(); + test_input("read < <(echo hello)").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn read_eof_status_one() { + let _g = TestGuard::new(); + // Empty input / EOF should set status 1 + test_input("read < <(echo -n '')").unwrap(); + assert_eq!(state::get_status(), 1); + } + + // ===================== Flag parsing (pure) ===================== + + #[test] + fn flags_raw_mode() { + use super::get_read_flags; + use crate::getopt::Opt; + let flags = get_read_flags(vec![Opt::Short('r')]).unwrap(); + assert!(flags.flags.contains(super::ReadFlags::NO_ESCAPES)); + } + + #[test] + fn flags_prompt() { + use super::get_read_flags; + use crate::getopt::Opt; + let flags = get_read_flags(vec![Opt::ShortWithArg('p', "Enter: ".into())]).unwrap(); + assert_eq!(flags.prompt, Some("Enter: ".into())); + } + + #[test] + fn flags_delimiter() { + use super::get_read_flags; + use crate::getopt::Opt; + let flags = get_read_flags(vec![Opt::ShortWithArg('d', ",".into())]).unwrap(); + assert_eq!(flags.delim, b','); + } +} + pub fn get_read_key_opts(opts: Vec) -> ShResult { let mut read_key_opts = ReadKeyOpts { var_name: None, diff --git a/src/builtin/resource.rs b/src/builtin/resource.rs index ca43f5c..4ad1a7b 100644 --- a/src/builtin/resource.rs +++ b/src/builtin/resource.rs @@ -3,7 +3,7 @@ use nix::sys::resource::{Resource, getrlimit, setrlimit}; use yansi::Color; use crate::{ - getopt::{Opt, OptSpec, get_opts_from_tokens}, libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color}, parse::{NdRule, Node, execute::prepare_argv}, prelude::*, state::{self} + getopt::{Opt, OptSpec, get_opts_from_tokens, get_opts_from_tokens_strict}, libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt, next_color}, parse::{NdRule, Node, execute::prepare_argv}, prelude::*, state::{self} }; fn ulimit_opt_spec() -> [OptSpec;5] { @@ -100,7 +100,7 @@ pub fn ulimit(node: Node) -> ShResult<()> { unreachable!() }; - let (_, opts) = get_opts_from_tokens(argv, &ulimit_opt_spec()).promote_err(span.clone())?; + let (_, opts) = get_opts_from_tokens_strict(argv, &ulimit_opt_spec()).promote_err(span.clone())?; let ulimit_opts = get_ulimit_opts(&opts).promote_err(span.clone())?; if let Some(fds) = ulimit_opts.fds { @@ -167,3 +167,99 @@ pub fn ulimit(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use super::get_ulimit_opts; + use crate::getopt::Opt; + use crate::state; + use crate::testutil::{TestGuard, test_input}; + use nix::sys::resource::{Resource, getrlimit}; + + // ===================== Pure: option parsing ===================== + + #[test] + fn parse_fds() { + let opts = get_ulimit_opts(&[Opt::ShortWithArg('n', "1024".into())]).unwrap(); + assert_eq!(opts.fds, Some(1024)); + } + + #[test] + fn parse_procs() { + let opts = get_ulimit_opts(&[Opt::ShortWithArg('u', "512".into())]).unwrap(); + assert_eq!(opts.procs, Some(512)); + } + + #[test] + fn parse_stack() { + let opts = get_ulimit_opts(&[Opt::ShortWithArg('s', "8192".into())]).unwrap(); + assert_eq!(opts.stack, Some(8192)); + } + + #[test] + fn parse_core() { + let opts = get_ulimit_opts(&[Opt::ShortWithArg('c', "0".into())]).unwrap(); + assert_eq!(opts.core, Some(0)); + } + + #[test] + fn parse_vmem() { + let opts = get_ulimit_opts(&[Opt::ShortWithArg('v', "100000".into())]).unwrap(); + assert_eq!(opts.vmem, Some(100000)); + } + + #[test] + fn parse_multiple() { + let opts = get_ulimit_opts(&[ + Opt::ShortWithArg('n', "256".into()), + Opt::ShortWithArg('c', "0".into()), + ]).unwrap(); + assert_eq!(opts.fds, Some(256)); + assert_eq!(opts.core, Some(0)); + assert!(opts.procs.is_none()); + } + + #[test] + fn parse_non_numeric_fails() { + let result = get_ulimit_opts(&[Opt::ShortWithArg('n', "abc".into())]); + assert!(result.is_err()); + } + + #[test] + fn parse_invalid_option() { + let result = get_ulimit_opts(&[Opt::Short('z')]); + assert!(result.is_err()); + } + + // ===================== Integration ===================== + + #[test] + fn ulimit_set_core_zero() { + let _g = TestGuard::new(); + // Setting core dump size to 0 is always safe + test_input("ulimit -c 0").unwrap(); + let (soft, _) = getrlimit(Resource::RLIMIT_CORE).unwrap(); + assert_eq!(soft, 0); + } + + #[test] + fn ulimit_invalid_flag() { + let _g = TestGuard::new(); + let result = test_input("ulimit -z 100"); + assert!(result.is_err()); + } + + #[test] + fn ulimit_non_numeric_value() { + let _g = TestGuard::new(); + let result = test_input("ulimit -n abc"); + assert!(result.is_err()); + } + + #[test] + fn ulimit_status_zero() { + let _g = TestGuard::new(); + test_input("ulimit -c 0").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/shift.rs b/src/builtin/shift.rs index c2c6f0a..2dedf09 100644 --- a/src/builtin/shift.rs +++ b/src/builtin/shift.rs @@ -35,3 +35,53 @@ pub fn shift(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use crate::state; + use crate::testutil::{TestGuard, test_input}; + + #[test] + fn shift_in_function() { + let guard = TestGuard::new(); + test_input("f() { echo $1; shift 1; echo $1; }").unwrap(); + test_input("f a b").unwrap(); + let out = guard.read_output(); + let lines: Vec<&str> = out.lines().collect(); + assert_eq!(lines[0], "a"); + assert_eq!(lines[1], "b"); + } + + #[test] + fn shift_multiple() { + let guard = TestGuard::new(); + test_input("f() { shift 2; echo $1; }").unwrap(); + test_input("f a b c").unwrap(); + let out = guard.read_output(); + assert_eq!(out.trim(), "c"); + } + + #[test] + fn shift_all_params() { + let guard = TestGuard::new(); + test_input("f() { shift 3; echo \"[$1]\"; }").unwrap(); + test_input("f a b c").unwrap(); + let out = guard.read_output(); + assert_eq!(out.trim(), "[]"); + } + + #[test] + fn shift_non_numeric_fails() { + let _g = TestGuard::new(); + let result = test_input("shift abc"); + assert!(result.is_err()); + } + + #[test] + fn shift_status_zero() { + let _g = TestGuard::new(); + test_input("f() { shift 1; }").unwrap(); + test_input("f a b").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/shopt.rs b/src/builtin/shopt.rs index 520c912..1e61f9b 100644 --- a/src/builtin/shopt.rs +++ b/src/builtin/shopt.rs @@ -45,3 +45,104 @@ pub fn shopt(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use crate::state::{self, read_shopts}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Display ===================== + + #[test] + fn shopt_no_args_displays_all() { + let guard = TestGuard::new(); + test_input("shopt").unwrap(); + let out = guard.read_output(); + assert!(out.contains("dotglob")); + assert!(out.contains("autocd")); + assert!(out.contains("max_hist")); + assert!(out.contains("edit_mode")); + } + + #[test] + fn shopt_query_category() { + let guard = TestGuard::new(); + test_input("shopt core").unwrap(); + let out = guard.read_output(); + assert!(out.contains("dotglob")); + assert!(out.contains("autocd")); + // Should not contain prompt opts + assert!(!out.contains("edit_mode")); + } + + #[test] + fn shopt_query_single_opt() { + let guard = TestGuard::new(); + test_input("shopt core.dotglob").unwrap(); + let out = guard.read_output(); + assert!(out.contains("false")); + } + + // ===================== Set ===================== + + #[test] + fn shopt_set_bool() { + let _g = TestGuard::new(); + test_input("shopt core.dotglob=true").unwrap(); + assert!(read_shopts(|o| o.core.dotglob)); + } + + #[test] + fn shopt_set_int() { + let _g = TestGuard::new(); + test_input("shopt core.max_hist=500").unwrap(); + assert_eq!(read_shopts(|o| o.core.max_hist), 500); + } + + #[test] + fn shopt_set_string() { + let _g = TestGuard::new(); + test_input("shopt prompt.leader=space").unwrap(); + assert_eq!(read_shopts(|o| o.prompt.leader.clone()), "space"); + } + + #[test] + fn shopt_set_edit_mode() { + let _g = TestGuard::new(); + test_input("shopt prompt.edit_mode=emacs").unwrap(); + let mode = read_shopts(|o| format!("{}", o.prompt.edit_mode)); + assert_eq!(mode, "emacs"); + } + + // ===================== Error cases ===================== + + #[test] + fn shopt_invalid_category() { + let _g = TestGuard::new(); + let result = test_input("shopt bogus.dotglob"); + assert!(result.is_err()); + } + + #[test] + fn shopt_invalid_option() { + let _g = TestGuard::new(); + let result = test_input("shopt core.nonexistent"); + assert!(result.is_err()); + } + + #[test] + fn shopt_invalid_value() { + let _g = TestGuard::new(); + let result = test_input("shopt core.dotglob=notabool"); + assert!(result.is_err()); + } + + // ===================== Status ===================== + + #[test] + fn shopt_status_zero() { + let _g = TestGuard::new(); + test_input("shopt core.autocd=true").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/source.rs b/src/builtin/source.rs index 4d34f3a..6459e7a 100644 --- a/src/builtin/source.rs +++ b/src/builtin/source.rs @@ -41,3 +41,131 @@ pub fn source(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +pub mod tests { + use std::io::Write; + + use tempfile::{NamedTempFile, TempDir}; + use crate::state::{self, read_logic, read_vars}; + use crate::testutil::{TestGuard, test_input}; + + #[test] + fn source_simple() { + let _g = TestGuard::new(); + let mut file = NamedTempFile::new().unwrap(); + let path = file.path().display().to_string(); + file.write_all(b"some_var=some_val").unwrap(); + + test_input(format!("source {path}")).unwrap(); + let var = read_vars(|v| v.get_var("some_var")); + assert_eq!(var, "some_val".to_string()); + } + + #[test] + fn source_multiple_commands() { + let _g = TestGuard::new(); + let mut file = NamedTempFile::new().unwrap(); + let path = file.path().display().to_string(); + file.write_all(b"x=1\ny=2\nz=3").unwrap(); + + test_input(format!("source {path}")).unwrap(); + assert_eq!(read_vars(|v| v.get_var("x")), "1"); + assert_eq!(read_vars(|v| v.get_var("y")), "2"); + assert_eq!(read_vars(|v| v.get_var("z")), "3"); + } + + #[test] + fn source_defines_function() { + let _g = TestGuard::new(); + let mut file = NamedTempFile::new().unwrap(); + let path = file.path().display().to_string(); + file.write_all(b"greet() { echo hi; }").unwrap(); + + test_input(format!("source {path}")).unwrap(); + let func = read_logic(|l| l.get_func("greet")); + assert!(func.is_some()); + } + + #[test] + fn source_defines_alias() { + let _g = TestGuard::new(); + let mut file = NamedTempFile::new().unwrap(); + let path = file.path().display().to_string(); + file.write_all(b"alias ll='ls -la'").unwrap(); + + test_input(format!("source {path}")).unwrap(); + let alias = read_logic(|l| l.get_alias("ll")); + assert!(alias.is_some()); + } + + #[test] + fn source_output_captured() { + let guard = TestGuard::new(); + let mut file = NamedTempFile::new().unwrap(); + let path = file.path().display().to_string(); + file.write_all(b"echo sourced").unwrap(); + + test_input(format!("source {path}")).unwrap(); + let out = guard.read_output(); + assert!(out.contains("sourced")); + } + + #[test] + fn source_multiple_files() { + let _g = TestGuard::new(); + let mut file1 = NamedTempFile::new().unwrap(); + let mut file2 = NamedTempFile::new().unwrap(); + let path1 = file1.path().display().to_string(); + let path2 = file2.path().display().to_string(); + file1.write_all(b"a=from_file1").unwrap(); + file2.write_all(b"b=from_file2").unwrap(); + + test_input(format!("source {path1} {path2}")).unwrap(); + assert_eq!(read_vars(|v| v.get_var("a")), "from_file1"); + assert_eq!(read_vars(|v| v.get_var("b")), "from_file2"); + } + + // ===================== Dot syntax ===================== + + #[test] + fn source_dot_syntax() { + let _g = TestGuard::new(); + let mut file = NamedTempFile::new().unwrap(); + let path = file.path().display().to_string(); + file.write_all(b"dot_var=dot_val").unwrap(); + + test_input(format!(". {path}")).unwrap(); + assert_eq!(read_vars(|v| v.get_var("dot_var")), "dot_val"); + } + + // ===================== Error cases ===================== + + #[test] + fn source_nonexistent_file() { + let _g = TestGuard::new(); + let result = test_input("source /tmp/__no_such_file_xyz__"); + assert!(result.is_err()); + } + + #[test] + fn source_directory_fails() { + let _g = TestGuard::new(); + let dir = TempDir::new().unwrap(); + let result = test_input(format!("source {}", dir.path().display())); + assert!(result.is_err()); + } + + // ===================== Status ===================== + + #[test] + fn source_status_zero() { + let _g = TestGuard::new(); + let mut file = NamedTempFile::new().unwrap(); + let path = file.path().display().to_string(); + file.write_all(b"true").unwrap(); + + test_input(format!("source {path}")).unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/test.rs b/src/builtin/test.rs index fbe9fd6..0b3f16c 100644 --- a/src/builtin/test.rs +++ b/src/builtin/test.rs @@ -94,7 +94,7 @@ impl FromStr for TestOp { "-ge" => Ok(Self::IntGe), "-le" => Ok(Self::IntLe), _ if TEST_UNARY_OPS.contains(&s) => Ok(Self::Unary(s.parse::()?)), - _ => Err(ShErr::simple(ShErrKind::SyntaxErr, "Invalid test operator")), + _ => Err(ShErr::simple(ShErrKind::SyntaxErr, format!("Invalid test operator '{}'", s))), } } } @@ -121,6 +121,7 @@ pub fn double_bracket_test(node: Node) -> ShResult { }; let mut last_result = false; let mut conjunct_op: Option; + log::trace!("test cases: {:#?}", cases); for case in cases { let result = match case { @@ -290,21 +291,332 @@ pub fn double_bracket_test(node: Node) -> ShResult { } }; + last_result = result; if let Some(op) = conjunct_op { match op { - ConjunctOp::And if !last_result => { - last_result = result; - break; - } - ConjunctOp::Or if last_result => { - last_result = result; - break; - } + ConjunctOp::And if !last_result => break, + ConjunctOp::Or if last_result => break, _ => {} } - } else { - last_result = result; } } Ok(last_result) } + +#[cfg(test)] +mod tests { + use std::fs; + use tempfile::{TempDir, NamedTempFile}; + use crate::state; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Unary: file tests ===================== + + #[test] + fn test_exists_true() { + let _g = TestGuard::new(); + let file = NamedTempFile::new().unwrap(); + test_input(format!("[[ -e {} ]]", file.path().display())).unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_exists_false() { + let _g = TestGuard::new(); + test_input("[[ -e /tmp/__no_such_file_test_rs__ ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_is_directory() { + let _g = TestGuard::new(); + let dir = TempDir::new().unwrap(); + test_input(format!("[[ -d {} ]]", dir.path().display())).unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_is_directory_false() { + let _g = TestGuard::new(); + let file = NamedTempFile::new().unwrap(); + test_input(format!("[[ -d {} ]]", file.path().display())).unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_is_file() { + let _g = TestGuard::new(); + let file = NamedTempFile::new().unwrap(); + test_input(format!("[[ -f {} ]]", file.path().display())).unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_is_file_false() { + let _g = TestGuard::new(); + let dir = TempDir::new().unwrap(); + test_input(format!("[[ -f {} ]]", dir.path().display())).unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_readable() { + let _g = TestGuard::new(); + let file = NamedTempFile::new().unwrap(); + test_input(format!("[[ -r {} ]]", file.path().display())).unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_writable() { + let _g = TestGuard::new(); + let file = NamedTempFile::new().unwrap(); + test_input(format!("[[ -w {} ]]", file.path().display())).unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_non_empty_file() { + let _g = TestGuard::new(); + let file = NamedTempFile::new().unwrap(); + fs::write(file.path(), "content").unwrap(); + test_input(format!("[[ -s {} ]]", file.path().display())).unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_empty_file() { + let _g = TestGuard::new(); + let file = NamedTempFile::new().unwrap(); + test_input(format!("[[ -s {} ]]", file.path().display())).unwrap(); + assert_ne!(state::get_status(), 0); + } + + // ===================== Unary: string tests ===================== + + #[test] + fn test_non_null_true() { + let _g = TestGuard::new(); + test_input("[[ -n hello ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_non_null_empty() { + let _g = TestGuard::new(); + test_input("[[ -n '' ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_null_true() { + let _g = TestGuard::new(); + test_input("[[ -z '' ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_null_false() { + let _g = TestGuard::new(); + test_input("[[ -z hello ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + // ===================== Binary: string comparison ===================== + + #[test] + fn test_string_eq() { + let _g = TestGuard::new(); + test_input("[[ hello == hello ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_string_eq_false() { + let _g = TestGuard::new(); + test_input("[[ hello == world ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_string_neq() { + let _g = TestGuard::new(); + test_input("[[ hello != world ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_string_neq_false() { + let _g = TestGuard::new(); + test_input("[[ hello != hello ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_string_glob_match() { + let _g = TestGuard::new(); + test_input("[[ hello == hel* ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_string_glob_no_match() { + let _g = TestGuard::new(); + test_input("[[ hello == wor* ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + // ===================== Binary: integer comparison ===================== + + #[test] + fn test_int_eq() { + let _g = TestGuard::new(); + test_input("[[ 42 -eq 42 ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_int_eq_false() { + let _g = TestGuard::new(); + test_input("[[ 42 -eq 43 ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_int_ne() { + let _g = TestGuard::new(); + test_input("[[ 1 -ne 2 ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_int_gt() { + let _g = TestGuard::new(); + test_input("[[ 10 -gt 5 ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_int_gt_false() { + let _g = TestGuard::new(); + test_input("[[ 5 -gt 10 ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_int_lt() { + let _g = TestGuard::new(); + test_input("[[ 5 -lt 10 ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_int_ge() { + let _g = TestGuard::new(); + test_input("[[ 10 -ge 10 ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_int_le() { + let _g = TestGuard::new(); + test_input("[[ 5 -le 5 ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_int_negative() { + let _g = TestGuard::new(); + test_input("[[ -5 -lt 0 ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_int_non_integer_errors() { + let _g = TestGuard::new(); + let result = test_input("[[ abc -eq 1 ]]"); + assert!(result.is_err()); + } + + // ===================== Binary: regex match ===================== + + #[test] + fn test_regex_match() { + let _g = TestGuard::new(); + test_input("[[ hello123 =~ ^hello[0-9]+$ ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_regex_no_match() { + let _g = TestGuard::new(); + test_input("[[ goodbye =~ ^hello ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + // ===================== Conjuncts ===================== + + #[test] + fn test_and_both_true() { + let _g = TestGuard::new(); + test_input("[[ -n hello && -n world ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_and_first_false() { + let _g = TestGuard::new(); + test_input("[[ -z hello && -n world ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + #[test] + fn test_or_first_true() { + let _g = TestGuard::new(); + test_input("[[ -n hello || -z hello ]]").unwrap(); + assert_eq!(state::get_status(), 0); + } + + #[test] + fn test_or_both_false() { + let _g = TestGuard::new(); + test_input("[[ -z hello || -z world ]]").unwrap(); + assert_ne!(state::get_status(), 0); + } + + // ===================== Pure: operator parsing ===================== + + #[test] + fn parse_unary_ops() { + use super::UnaryOp; + use std::str::FromStr; + for op in ["-e", "-d", "-f", "-h", "-L", "-r", "-w", "-x", "-s", + "-p", "-S", "-b", "-c", "-k", "-O", "-G", "-N", "-u", + "-g", "-t", "-n", "-z"] { + assert!(UnaryOp::from_str(op).is_ok(), "failed to parse {op}"); + } + } + + #[test] + fn parse_invalid_unary_op() { + use super::UnaryOp; + use std::str::FromStr; + assert!(UnaryOp::from_str("-Q").is_err()); + } + + #[test] + fn parse_binary_ops() { + use super::TestOp; + use std::str::FromStr; + for op in ["==", "!=", "=~", "-eq", "-ne", "-gt", "-lt", "-ge", "-le"] { + assert!(TestOp::from_str(op).is_ok(), "failed to parse {op}"); + } + } + + #[test] + fn parse_invalid_binary_op() { + use super::TestOp; + use std::str::FromStr; + assert!(TestOp::from_str("~=").is_err()); + } +} diff --git a/src/builtin/trap.rs b/src/builtin/trap.rs index 24a5218..0951b76 100644 --- a/src/builtin/trap.rs +++ b/src/builtin/trap.rs @@ -167,3 +167,146 @@ pub fn trap(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use super::TrapTarget; + use std::str::FromStr; + use nix::sys::signal::Signal; + use crate::state::{self, read_logic}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== Pure: TrapTarget parsing ===================== + + #[test] + fn parse_exit() { + assert_eq!(TrapTarget::from_str("EXIT").unwrap(), TrapTarget::Exit); + } + + #[test] + fn parse_err() { + assert_eq!(TrapTarget::from_str("ERR").unwrap(), TrapTarget::Error); + } + + #[test] + fn parse_signal_int() { + assert_eq!( + TrapTarget::from_str("INT").unwrap(), + TrapTarget::Signal(Signal::SIGINT) + ); + } + + #[test] + fn parse_signal_term() { + assert_eq!( + TrapTarget::from_str("TERM").unwrap(), + TrapTarget::Signal(Signal::SIGTERM) + ); + } + + #[test] + fn parse_signal_usr1() { + assert_eq!( + TrapTarget::from_str("USR1").unwrap(), + TrapTarget::Signal(Signal::SIGUSR1) + ); + } + + #[test] + fn parse_invalid() { + assert!(TrapTarget::from_str("BOGUS").is_err()); + } + + // ===================== Pure: Display round-trip ===================== + + #[test] + fn display_exit() { + assert_eq!(TrapTarget::Exit.to_string(), "EXIT"); + } + + #[test] + fn display_err() { + assert_eq!(TrapTarget::Error.to_string(), "ERR"); + } + + #[test] + fn display_signal_roundtrip() { + for name in &["INT", "QUIT", "TERM", "USR1", "USR2", "ALRM", "CHLD", "WINCH"] { + let target = TrapTarget::from_str(name).unwrap(); + assert_eq!(target.to_string(), *name); + } + } + + // ===================== Integration: registration ===================== + + #[test] + fn trap_registers_exit() { + let _g = TestGuard::new(); + test_input("trap 'echo bye' EXIT").unwrap(); + let cmd = read_logic(|l| l.get_trap(TrapTarget::Exit)); + assert_eq!(cmd.unwrap(), "echo bye"); + } + + #[test] + fn trap_registers_signal() { + let _g = TestGuard::new(); + test_input("trap 'echo caught' INT").unwrap(); + let cmd = read_logic(|l| l.get_trap(TrapTarget::Signal(Signal::SIGINT))); + assert_eq!(cmd.unwrap(), "echo caught"); + } + + #[test] + fn trap_multiple_signals() { + let _g = TestGuard::new(); + test_input("trap 'handle' INT TERM").unwrap(); + let int = read_logic(|l| l.get_trap(TrapTarget::Signal(Signal::SIGINT))); + let term = read_logic(|l| l.get_trap(TrapTarget::Signal(Signal::SIGTERM))); + assert_eq!(int.unwrap(), "handle"); + assert_eq!(term.unwrap(), "handle"); + } + + #[test] + fn trap_remove() { + let _g = TestGuard::new(); + test_input("trap 'echo hi' EXIT").unwrap(); + assert!(read_logic(|l| l.get_trap(TrapTarget::Exit)).is_some()); + test_input("trap - EXIT").unwrap(); + assert!(read_logic(|l| l.get_trap(TrapTarget::Exit)).is_none()); + } + + #[test] + fn trap_display() { + let guard = TestGuard::new(); + test_input("trap 'echo bye' EXIT").unwrap(); + test_input("trap").unwrap(); + let out = guard.read_output(); + assert!(out.contains("echo bye")); + assert!(out.contains("EXIT")); + } + + // ===================== Error cases ===================== + + #[test] + fn trap_single_arg_usage() { + let _g = TestGuard::new(); + // Single arg prints usage and sets status 1 + test_input("trap 'echo hi'").unwrap(); + assert_eq!(state::get_status(), 1); + } + + #[test] + fn trap_invalid_signal() { + let _g = TestGuard::new(); + let result = test_input("trap 'echo hi' BOGUS"); + assert!(result.is_err()); + } + + // ===================== Status ===================== + + #[test] + fn trap_status_zero() { + let _g = TestGuard::new(); + test_input("trap 'echo bye' EXIT").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/varcmds.rs b/src/builtin/varcmds.rs index dc39447..9815007 100644 --- a/src/builtin/varcmds.rs +++ b/src/builtin/varcmds.rs @@ -196,3 +196,219 @@ pub fn local(node: Node) -> ShResult<()> { state::set_status(0); Ok(()) } + +#[cfg(test)] +mod tests { + use crate::state::{self, VarFlags, read_vars}; + use crate::testutil::{TestGuard, test_input}; + + // ===================== readonly ===================== + + #[test] + fn readonly_sets_flag() { + let _g = TestGuard::new(); + test_input("readonly myvar").unwrap(); + let flags = read_vars(|v| v.get_var_flags("myvar")); + assert!(flags.unwrap().contains(VarFlags::READONLY)); + } + + #[test] + fn readonly_with_value() { + let _g = TestGuard::new(); + test_input("readonly myvar=hello").unwrap(); + assert_eq!(read_vars(|v| v.get_var("myvar")), "hello"); + let flags = read_vars(|v| v.get_var_flags("myvar")); + assert!(flags.unwrap().contains(VarFlags::READONLY)); + } + + #[test] + fn readonly_prevents_reassignment() { + let _g = TestGuard::new(); + test_input("readonly myvar=hello").unwrap(); + let result = test_input("myvar=world"); + assert!(result.is_err()); + assert_eq!(read_vars(|v| v.get_var("myvar")), "hello"); + } + + #[test] + fn readonly_display() { + let guard = TestGuard::new(); + test_input("readonly rdo_test_var=abc").unwrap(); + test_input("readonly").unwrap(); + let out = guard.read_output(); + assert!(out.contains("rdo_test_var=abc")); + } + + #[test] + fn readonly_multiple() { + let _g = TestGuard::new(); + test_input("readonly a=1 b=2").unwrap(); + assert_eq!(read_vars(|v| v.get_var("a")), "1"); + assert_eq!(read_vars(|v| v.get_var("b")), "2"); + assert!(read_vars(|v| v.get_var_flags("a")).unwrap().contains(VarFlags::READONLY)); + assert!(read_vars(|v| v.get_var_flags("b")).unwrap().contains(VarFlags::READONLY)); + } + + #[test] + fn readonly_status_zero() { + let _g = TestGuard::new(); + test_input("readonly x=1").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== unset ===================== + + #[test] + fn unset_removes_variable() { + let _g = TestGuard::new(); + test_input("myvar=hello").unwrap(); + assert_eq!(read_vars(|v| v.get_var("myvar")), "hello"); + test_input("unset myvar").unwrap(); + assert_eq!(read_vars(|v| v.get_var("myvar")), ""); + } + + #[test] + fn unset_multiple() { + let _g = TestGuard::new(); + test_input("a=1").unwrap(); + test_input("b=2").unwrap(); + test_input("unset a b").unwrap(); + assert_eq!(read_vars(|v| v.get_var("a")), ""); + assert_eq!(read_vars(|v| v.get_var("b")), ""); + } + + #[test] + fn unset_nonexistent_fails() { + let _g = TestGuard::new(); + let result = test_input("unset __no_such_var__"); + assert!(result.is_err()); + } + + #[test] + fn unset_no_args_fails() { + let _g = TestGuard::new(); + let result = test_input("unset"); + assert!(result.is_err()); + } + + #[test] + fn unset_readonly_fails() { + let _g = TestGuard::new(); + test_input("readonly myvar=protected").unwrap(); + let result = test_input("unset myvar"); + assert!(result.is_err()); + assert_eq!(read_vars(|v| v.get_var("myvar")), "protected"); + } + + #[test] + fn unset_status_zero() { + let _g = TestGuard::new(); + test_input("x=1").unwrap(); + test_input("unset x").unwrap(); + assert_eq!(state::get_status(), 0); + } + + // ===================== export ===================== + + #[test] + fn export_with_value() { + let _g = TestGuard::new(); + test_input("export SHED_TEST_VAR=hello_export").unwrap(); + assert_eq!(read_vars(|v| v.get_var("SHED_TEST_VAR")), "hello_export"); + assert_eq!(std::env::var("SHED_TEST_VAR").unwrap(), "hello_export"); + unsafe { std::env::remove_var("SHED_TEST_VAR") }; + } + + #[test] + fn export_existing_variable() { + let _g = TestGuard::new(); + test_input("SHED_TEST_VAR2=existing").unwrap(); + test_input("export SHED_TEST_VAR2").unwrap(); + assert_eq!(std::env::var("SHED_TEST_VAR2").unwrap(), "existing"); + unsafe { std::env::remove_var("SHED_TEST_VAR2") }; + } + + #[test] + fn export_sets_flag() { + let _g = TestGuard::new(); + test_input("export SHED_TEST_VAR3=flagged").unwrap(); + let flags = read_vars(|v| v.get_var_flags("SHED_TEST_VAR3")); + assert!(flags.unwrap().contains(VarFlags::EXPORT)); + unsafe { std::env::remove_var("SHED_TEST_VAR3") }; + } + + #[test] + fn export_display() { + let guard = TestGuard::new(); + test_input("export").unwrap(); + let out = guard.read_output(); + assert!(out.contains("PATH=") || out.contains("HOME=")); + } + + #[test] + fn export_multiple() { + let _g = TestGuard::new(); + test_input("export SHED_A=1 SHED_B=2").unwrap(); + assert_eq!(std::env::var("SHED_A").unwrap(), "1"); + assert_eq!(std::env::var("SHED_B").unwrap(), "2"); + unsafe { std::env::remove_var("SHED_A") }; + unsafe { std::env::remove_var("SHED_B") }; + } + + #[test] + fn export_status_zero() { + let _g = TestGuard::new(); + test_input("export SHED_ST=1").unwrap(); + assert_eq!(state::get_status(), 0); + unsafe { std::env::remove_var("SHED_ST") }; + } + + // ===================== local ===================== + + #[test] + fn local_sets_variable() { + let _g = TestGuard::new(); + test_input("local mylocal=hello").unwrap(); + assert_eq!(read_vars(|v| v.get_var("mylocal")), "hello"); + } + + #[test] + fn local_sets_flag() { + let _g = TestGuard::new(); + test_input("local mylocal=val").unwrap(); + let flags = read_vars(|v| v.get_var_flags("mylocal")); + assert!(flags.unwrap().contains(VarFlags::LOCAL)); + } + + #[test] + fn local_empty_value() { + let _g = TestGuard::new(); + test_input("local mylocal").unwrap(); + assert_eq!(read_vars(|v| v.get_var("mylocal")), ""); + assert!(read_vars(|v| v.get_var_flags("mylocal")).unwrap().contains(VarFlags::LOCAL)); + } + + #[test] + fn local_display() { + let guard = TestGuard::new(); + test_input("lv_test=display_val").unwrap(); + test_input("local").unwrap(); + let out = guard.read_output(); + assert!(out.contains("lv_test=display_val")); + } + + #[test] + fn local_multiple() { + let _g = TestGuard::new(); + test_input("local x=10 y=20").unwrap(); + assert_eq!(read_vars(|v| v.get_var("x")), "10"); + assert_eq!(read_vars(|v| v.get_var("y")), "20"); + } + + #[test] + fn local_status_zero() { + let _g = TestGuard::new(); + test_input("local z=1").unwrap(); + assert_eq!(state::get_status(), 0); + } +} diff --git a/src/builtin/zoltraak.rs b/src/builtin/zoltraak.rs deleted file mode 100644 index 4a59e90..0000000 --- a/src/builtin/zoltraak.rs +++ /dev/null @@ -1,207 +0,0 @@ -use std::os::unix::fs::OpenOptionsExt; - -use crate::{ - getopt::{Opt, OptSpec, get_opts_from_tokens}, - libsh::error::{ShErr, ShErrKind, ShResult, ShResultExt}, - parse::{NdRule, Node, execute::prepare_argv}, - prelude::*, - procio::borrow_fd, -}; - -bitflags! { - #[derive(Clone,Copy,Debug,PartialEq,Eq)] - struct ZoltFlags: u32 { - const DRY = 0b000001; - const CONFIRM = 0b000010; - const NO_PRESERVE_ROOT = 0b000100; - const RECURSIVE = 0b001000; - const FORCE = 0b010000; - const VERBOSE = 0b100000; - } -} - -/// Annihilate a file -/// -/// This command works similarly to 'rm', but behaves more destructively. -/// The file given as an argument is completely destroyed. The command works by -/// shredding all of the data contained in the file, before truncating the -/// length of the file to 0 to ensure that not even any metadata remains. -pub fn zoltraak(node: Node) -> ShResult<()> { - let NdRule::Command { - assignments: _, - argv, - } = node.class - else { - unreachable!() - }; - let zolt_opts = [ - OptSpec { - opt: Opt::Long("dry-run".into()), - takes_arg: false, - }, - OptSpec { - opt: Opt::Long("confirm".into()), - takes_arg: false, - }, - OptSpec { - opt: Opt::Long("no-preserve-root".into()), - takes_arg: false, - }, - OptSpec { - opt: Opt::Short('r'), - takes_arg: false, - }, - OptSpec { - opt: Opt::Short('f'), - takes_arg: false, - }, - OptSpec { - opt: Opt::Short('v'), - takes_arg: false, - }, - ]; - let mut flags = ZoltFlags::empty(); - - let (argv, opts) = get_opts_from_tokens(argv, &zolt_opts)?; - - for opt in opts { - match opt { - Opt::Long(flag) => match flag.as_str() { - "no-preserve-root" => flags |= ZoltFlags::NO_PRESERVE_ROOT, - "confirm" => flags |= ZoltFlags::CONFIRM, - "dry-run" => flags |= ZoltFlags::DRY, - _ => { - return Err(ShErr::simple( - ShErrKind::SyntaxErr, - format!("zoltraak: unrecognized option '{flag}'"), - )); - } - }, - Opt::Short(flag) => match flag { - 'r' => flags |= ZoltFlags::RECURSIVE, - 'f' => flags |= ZoltFlags::FORCE, - 'v' => flags |= ZoltFlags::VERBOSE, - _ => { - return Err(ShErr::simple( - ShErrKind::SyntaxErr, - format!("zoltraak: unrecognized option '{flag}'"), - )); - } - }, - Opt::LongWithArg(flag, _) => { - return Err(ShErr::simple( - ShErrKind::SyntaxErr, - format!("zoltraak: unrecognized option '{flag}'"), - )); - } - Opt::ShortWithArg(flag, _) => { - return Err(ShErr::simple( - ShErrKind::SyntaxErr, - format!("zoltraak: unrecognized option '{flag}'"), - )); - } - } - } - - let mut argv = prepare_argv(argv)?; - if !argv.is_empty() { - argv.remove(0); - } - - for (arg, span) in argv { - if &arg == "/" && !flags.contains(ZoltFlags::NO_PRESERVE_ROOT) { - return Err( - ShErr::simple( - ShErrKind::ExecFail, - "zoltraak: Attempted to destroy root directory '/'", - ) - .with_note("If you really want to do this, you can use the --no-preserve-root flag"), - ); - } - annihilate(&arg, flags).blame(span)? - } - - Ok(()) -} - -fn annihilate(path: &str, flags: ZoltFlags) -> ShResult<()> { - let path_buf = PathBuf::from(path); - let is_recursive = flags.contains(ZoltFlags::RECURSIVE); - let is_verbose = flags.contains(ZoltFlags::VERBOSE); - - const BLOCK_SIZE: u64 = 4096; - - if !path_buf.exists() { - return Err(ShErr::simple( - ShErrKind::ExecFail, - format!("zoltraak: File '{path}' not found"), - )); - } - - if path_buf.is_file() { - let mut file = OpenOptions::new() - .write(true) - .custom_flags(libc::O_DIRECT) - .open(path_buf)?; - - let meta = file.metadata()?; - let file_size = meta.len(); - let full_blocks = file_size / BLOCK_SIZE; - let byte_remainder = file_size % BLOCK_SIZE; - - let full_buf = vec![0; BLOCK_SIZE as usize]; - let remainder_buf = vec![0; byte_remainder as usize]; - - for _ in 0..full_blocks { - file.write_all(&full_buf)?; - } - - if byte_remainder > 0 { - file.write_all(&remainder_buf)?; - } - - file.set_len(0)?; - mem::drop(file); - fs::remove_file(path)?; - if is_verbose { - let stderr = borrow_fd(STDERR_FILENO); - write(stderr, format!("shredded file '{path}'\n").as_bytes())?; - } - } else if path_buf.is_dir() { - if is_recursive { - annihilate_recursive(path, flags)?; // scary - } else { - return Err( - ShErr::simple( - ShErrKind::ExecFail, - format!("zoltraak: '{path}' is a directory"), - ) - .with_note("Use the '-r' flag to recursively shred directories"), - ); - } - } - - Ok(()) -} - -fn annihilate_recursive(dir: &str, flags: ZoltFlags) -> ShResult<()> { - let dir_path = PathBuf::from(dir); - let is_verbose = flags.contains(ZoltFlags::VERBOSE); - - for dir_entry in fs::read_dir(&dir_path)? { - let entry = dir_entry?.path(); - let file = entry.to_str().unwrap(); - - if entry.is_file() { - annihilate(file, flags)?; - } else if entry.is_dir() { - annihilate_recursive(file, flags)?; - } - } - fs::remove_dir(dir)?; - if is_verbose { - let stderr = borrow_fd(STDERR_FILENO); - write(stderr, format!("shredded directory '{dir}'\n").as_bytes())?; - } - Ok(()) -} diff --git a/src/expand.rs b/src/expand.rs index b2a793a..aa01f26 100644 --- a/src/expand.rs +++ b/src/expand.rs @@ -141,7 +141,7 @@ fn has_braces(s: &str) -> bool { } // skip escaped char '\'' => qt_state.toggle_single(), '"' => qt_state.toggle_double(), - '{' if qt_state.in_quote() => { + '{' if qt_state.outside() => { if depth == 0 { found_open = true; has_comma = false; @@ -640,11 +640,6 @@ pub fn expand_glob(raw: &str) -> ShResult { Ok(words.join(" ")) } -pub fn is_a_number(raw: &str) -> bool { - let trimmed = raw.trim(); - trimmed.parse::().is_ok() || trimmed.parse::().is_ok() -} - enum ArithTk { Num(f64), Op(ArithOp), @@ -2382,3 +2377,1199 @@ pub fn parse_key_alias(alias: &str) -> Option { Some(KeyEvent(key, mods)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use crate::readline::keys::{KeyCode, KeyEvent, ModKeys}; + use crate::state::{write_vars, read_vars, ArrIndex, VarKind, VarFlags}; + use crate::parse::lex::Span; + use crate::testutil::{TestGuard, test_input}; + + // ===================== has_braces ===================== + + #[test] + fn has_braces_simple_comma() { + assert!(has_braces("{a,b,c}")); + } + + #[test] + fn has_braces_range() { + assert!(has_braces("{1..5}")); + } + + #[test] + fn has_braces_no_braces() { + assert!(!has_braces("hello")); + } + + #[test] + fn has_braces_single_item() { + assert!(!has_braces("{hello}")); + } + + #[test] + fn has_braces_with_prefix_suffix() { + assert!(has_braces("pre{a,b}post")); + } + + #[test] + fn has_braces_nested() { + assert!(has_braces("{a,{b,c}}")); + } + + #[test] + fn has_braces_quoted_single() { + assert!(!has_braces("'{a,b}'")); + } + + #[test] + fn has_braces_quoted_double() { + assert!(!has_braces("\"{a,b}\"")); + } + + #[test] + fn has_braces_escaped() { + assert!(!has_braces("\\{a,b\\}")); + } + + // ===================== split_brace_inner ===================== + + #[test] + fn split_inner_simple() { + assert_eq!(split_brace_inner("a,b,c"), vec!["a", "b", "c"]); + } + + #[test] + fn split_inner_nested_braces() { + assert_eq!(split_brace_inner("a,{b,c},d"), vec!["a", "{b,c}", "d"]); + } + + #[test] + fn split_inner_no_comma() { + assert_eq!(split_brace_inner("abc"), vec!["abc"]); + } + + #[test] + fn split_inner_empty_parts() { + assert_eq!(split_brace_inner(",a,"), vec!["", "a", ""]); + } + + // ===================== try_expand_range / expand_range ===================== + + #[test] + fn range_numeric() { + assert_eq!( + try_expand_range("1..5").unwrap(), + vec!["1", "2", "3", "4", "5"] + ); + } + + #[test] + fn range_alpha() { + assert_eq!( + try_expand_range("a..e").unwrap(), + vec!["a", "b", "c", "d", "e"] + ); + } + + #[test] + fn range_with_step() { + assert_eq!( + try_expand_range("1..10..2").unwrap(), + vec!["1", "3", "5", "7", "9"] + ); + } + + #[test] + fn range_reverse_numeric() { + assert_eq!( + try_expand_range("5..1").unwrap(), + vec!["5", "4", "3", "2", "1"] + ); + } + + #[test] + fn range_reverse_alpha() { + assert_eq!( + try_expand_range("e..a").unwrap(), + vec!["e", "d", "c", "b", "a"] + ); + } + + #[test] + fn range_zero_padded() { + assert_eq!( + try_expand_range("01..05").unwrap(), + vec!["01", "02", "03", "04", "05"] + ); + } + + #[test] + fn range_invalid() { + assert!(try_expand_range("abc").is_none()); + } + + #[test] + fn range_zero_step() { + assert!(try_expand_range("1..5..0").is_none()); + } + + #[test] + fn range_single_char() { + assert_eq!(expand_range("a", "a", 1).unwrap(), vec!["a"]); + } + + // ===================== expand_braces_full ===================== + + #[test] + fn braces_simple_list() { + assert_eq!( + expand_braces_full("{a,b,c}").unwrap(), + vec!["a", "b", "c"] + ); + } + + #[test] + fn braces_with_prefix_suffix() { + assert_eq!( + expand_braces_full("pre{a,b}post").unwrap(), + vec!["preapost", "prebpost"] + ); + } + + #[test] + fn braces_nested() { + assert_eq!( + expand_braces_full("{a,{b,c}}").unwrap(), + vec!["a", "b", "c"] + ); + } + + #[test] + fn braces_numeric_range() { + assert_eq!( + expand_braces_full("{1..5}").unwrap(), + vec!["1", "2", "3", "4", "5"] + ); + } + + #[test] + fn braces_range_with_step() { + assert_eq!( + expand_braces_full("{1..10..2}").unwrap(), + vec!["1", "3", "5", "7", "9"] + ); + } + + #[test] + fn braces_alpha_range() { + assert_eq!( + expand_braces_full("{a..f}").unwrap(), + vec!["a", "b", "c", "d", "e", "f"] + ); + } + + #[test] + fn braces_reverse_range() { + assert_eq!( + expand_braces_full("{5..1}").unwrap(), + vec!["5", "4", "3", "2", "1"] + ); + } + + #[test] + fn braces_reverse_alpha() { + assert_eq!( + expand_braces_full("{z..v}").unwrap(), + vec!["z", "y", "x", "w", "v"] + ); + } + + #[test] + fn braces_zero_padded() { + assert_eq!( + expand_braces_full("{01..05}").unwrap(), + vec!["01", "02", "03", "04", "05"] + ); + } + + #[test] + fn braces_no_expansion() { + assert_eq!(expand_braces_full("hello").unwrap(), vec!["hello"]); + } + + #[test] + fn braces_multiple_groups() { + assert_eq!( + expand_braces_full("{a,b}{1,2}").unwrap(), + vec!["a1", "a2", "b1", "b2"] + ); + } + + #[test] + fn braces_empty_element() { + let result = expand_braces_full("pre{,a}post").unwrap(); + assert_eq!(result, vec!["prepost", "preapost"]); + } + + #[test] + fn braces_cursed() { + let result = expand_braces_full("foo{a,{1,2,3,{1..4},5},c}{5..1}bar").unwrap(); + assert_eq!(result, vec![ "fooa5bar", "fooa4bar", "fooa3bar", "fooa2bar", "fooa1bar", "foo15bar", "foo14bar", "foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar", "foo22bar", "foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar", "foo15bar", "foo14bar", "foo13bar", "foo12bar", "foo11bar", "foo25bar", "foo24bar", "foo23bar", "foo22bar", "foo21bar", "foo35bar", "foo34bar", "foo33bar", "foo32bar", "foo31bar", "foo45bar", "foo44bar", "foo43bar", "foo42bar", "foo41bar", "foo55bar", "foo54bar", "foo53bar", "foo52bar", "foo51bar", "fooc5bar", "fooc4bar", "fooc3bar", "fooc2bar", "fooc1bar", ]) + } + + // ===================== Arithmetic ===================== + + #[test] + fn arith_addition() { + assert_eq!(expand_arithmetic("(1+2)").unwrap().unwrap(), "3"); + } + + #[test] + fn arith_subtraction() { + assert_eq!(expand_arithmetic("(10-3)").unwrap().unwrap(), "7"); + } + + #[test] + fn arith_multiplication() { + assert_eq!(expand_arithmetic("(3*4)").unwrap().unwrap(), "12"); + } + + #[test] + fn arith_division() { + assert_eq!(expand_arithmetic("(10/2)").unwrap().unwrap(), "5"); + } + + #[test] + fn arith_modulo() { + assert_eq!(expand_arithmetic("(10%3)").unwrap().unwrap(), "1"); + } + + #[test] + fn arith_precedence() { + assert_eq!(expand_arithmetic("(2+3*4)").unwrap().unwrap(), "14"); + } + + #[test] + fn arith_parens() { + assert_eq!(expand_arithmetic("((2+3)*4)").unwrap().unwrap(), "20"); + } + + #[test] + fn arith_nested_parens() { + assert_eq!(expand_arithmetic("((1+2)*(3+4))").unwrap().unwrap(), "21"); + } + + #[test] + fn arith_spaces() { + assert_eq!(expand_arithmetic("( 1 + 2 )").unwrap().unwrap(), "3"); + } + + // ===================== glob_to_regex ===================== + + #[test] + fn glob_star_matches_anything() { + let re = glob_to_regex("*", false); + assert!(re.is_match("anything")); + assert!(re.is_match("")); + } + + #[test] + fn glob_question_matches_single() { + let re = glob_to_regex("?", true); + assert!(re.is_match("a")); + assert!(!re.is_match("ab")); + assert!(!re.is_match("")); + } + + #[test] + fn glob_star_dot_ext() { + let re = glob_to_regex("*.txt", true); + assert!(re.is_match("hello.txt")); + assert!(re.is_match(".txt")); + assert!(!re.is_match("hello.rs")); + } + + #[test] + fn glob_char_class() { + let re = glob_to_regex("[abc]", true); + assert!(re.is_match("a")); + assert!(re.is_match("b")); + assert!(!re.is_match("d")); + } + + #[test] + fn glob_dot_escaped() { + let re = glob_to_regex("foo.bar", true); + assert!(re.is_match("foo.bar")); + assert!(!re.is_match("fooXbar")); + } + + #[test] + fn glob_special_chars_escaped() { + let re = glob_to_regex("a+b(c)", true); + assert!(re.is_match("a+b(c)")); + assert!(!re.is_match("ab")); + } + + #[test] + fn glob_anchored_vs_unanchored() { + let anchored = glob_to_regex("hello", true); + assert!(anchored.is_match("hello")); + assert!(!anchored.is_match("say hello")); + + let unanchored = glob_to_regex("hello", false); + assert!(unanchored.is_match("hello")); + assert!(unanchored.is_match("say hello world")); + } + + // ===================== ParamExp parsing ===================== + + #[test] + fn param_exp_default_unset_or_null() { + let exp: ParamExp = ":-default".parse().unwrap(); + assert!(matches!(exp, ParamExp::DefaultUnsetOrNull(ref d) if d == "default")); + } + + #[test] + fn param_exp_default_unset() { + let exp: ParamExp = "-fallback".parse().unwrap(); + assert!(matches!(exp, ParamExp::DefaultUnset(ref d) if d == "fallback")); + } + + #[test] + fn param_exp_set_default_unset_or_null() { + let exp: ParamExp = ":=val".parse().unwrap(); + assert!(matches!(exp, ParamExp::SetDefaultUnsetOrNull(ref v) if v == "val")); + } + + #[test] + fn param_exp_set_default_unset() { + let exp: ParamExp = "=val".parse().unwrap(); + assert!(matches!(exp, ParamExp::SetDefaultUnset(ref v) if v == "val")); + } + + #[test] + fn param_exp_alt_set_not_null() { + let exp: ParamExp = ":+alt".parse().unwrap(); + assert!(matches!(exp, ParamExp::AltSetNotNull(ref a) if a == "alt")); + } + + #[test] + fn param_exp_alt_not_null() { + let exp: ParamExp = "+alt".parse().unwrap(); + assert!(matches!(exp, ParamExp::AltNotNull(ref a) if a == "alt")); + } + + #[test] + fn param_exp_err_unset_or_null() { + let exp: ParamExp = ":?errmsg".parse().unwrap(); + assert!(matches!(exp, ParamExp::ErrUnsetOrNull(ref e) if e == "errmsg")); + } + + #[test] + fn param_exp_err_unset() { + let exp: ParamExp = "?errmsg".parse().unwrap(); + assert!(matches!(exp, ParamExp::ErrUnset(ref e) if e == "errmsg")); + } + + #[test] + fn param_exp_len() { + let exp: ParamExp = "##pattern".parse().unwrap(); + assert!(matches!(exp, ParamExp::RemLongestPrefix(ref p) if p == "pattern")); + } + + #[test] + fn param_exp_rem_shortest_prefix() { + let exp: ParamExp = "#pat".parse().unwrap(); + assert!(matches!(exp, ParamExp::RemShortestPrefix(ref p) if p == "pat")); + } + + #[test] + fn param_exp_rem_longest_prefix() { + let exp: ParamExp = "##pat".parse().unwrap(); + assert!(matches!(exp, ParamExp::RemLongestPrefix(ref p) if p == "pat")); + } + + #[test] + fn param_exp_rem_shortest_suffix() { + let exp: ParamExp = "%pat".parse().unwrap(); + assert!(matches!(exp, ParamExp::RemShortestSuffix(ref p) if p == "pat")); + } + + #[test] + fn param_exp_rem_longest_suffix() { + let exp: ParamExp = "%%pat".parse().unwrap(); + assert!(matches!(exp, ParamExp::RemLongestSuffix(ref p) if p == "pat")); + } + + #[test] + fn param_exp_replace_first() { + let exp: ParamExp = "/old/new".parse().unwrap(); + assert!(matches!(exp, ParamExp::ReplaceFirstMatch(ref s, ref r) if s == "old" && r == "new")); + } + + #[test] + fn param_exp_replace_all() { + let exp: ParamExp = "//old/new".parse().unwrap(); + assert!(matches!(exp, ParamExp::ReplaceAllMatches(ref s, ref r) if s == "old" && r == "new")); + } + + #[test] + fn param_exp_replace_prefix() { + let exp: ParamExp = "/#old/new".parse().unwrap(); + assert!(matches!(exp, ParamExp::ReplacePrefix(ref s, ref r) if s == "old" && r == "new")); + } + + #[test] + fn param_exp_replace_suffix() { + let exp: ParamExp = "/%old/new".parse().unwrap(); + assert!(matches!(exp, ParamExp::ReplaceSuffix(ref s, ref r) if s == "old" && r == "new")); + } + + #[test] + fn param_exp_indirect() { + let exp: ParamExp = "!var".parse().unwrap(); + assert!(matches!(exp, ParamExp::ExpandInnerVar(ref v) if v == "var")); + } + + #[test] + fn param_exp_var_names_prefix() { + let exp: ParamExp = "!prefix*".parse().unwrap(); + assert!(matches!(exp, ParamExp::VarNamesWithPrefix(ref p) if p == "prefix*")); + } + + #[test] + fn param_exp_substr() { + let exp: ParamExp = ":2".parse().unwrap(); + assert!(matches!(exp, ParamExp::Substr(2))); + } + + #[test] + fn param_exp_substr_len() { + let exp: ParamExp = ":1:3".parse().unwrap(); + assert!(matches!(exp, ParamExp::SubstrLen(1, 3))); + } + + // ===================== unescape_str ===================== + + #[test] + fn unescape_backslash() { + let result = unescape_str("hello\\nworld"); + assert_eq!(result, "hellonworld"); + } + + #[test] + fn unescape_tilde_at_start() { + let result = unescape_str("~/foo"); + assert!(result.starts_with(markers::TILDE_SUB)); + assert!(result.ends_with("/foo")); + } + + #[test] + fn unescape_tilde_not_at_start() { + let result = unescape_str("a~b"); + assert!(!result.contains(markers::TILDE_SUB)); + assert!(result.contains('~')); + } + + #[test] + fn unescape_dollar_becomes_var_sub() { + let result = unescape_str("$foo"); + assert!(result.starts_with(markers::VAR_SUB)); + assert!(result.ends_with("foo")); + } + + #[test] + fn unescape_single_quotes() { + let result = unescape_str("'hello'"); + let expected = format!("{}hello{}", markers::SNG_QUOTE, markers::SNG_QUOTE); + assert_eq!(result, expected); + } + + #[test] + fn unescape_double_quotes() { + let result = unescape_str("\"hello\""); + let expected = format!("{}hello{}", markers::DUB_QUOTE, markers::DUB_QUOTE); + assert_eq!(result, expected); + } + + #[test] + fn unescape_dollar_single_quote_newline() { + let result = unescape_str("$'\\n'"); + let expected = format!("{}\n{}", markers::SNG_QUOTE, markers::SNG_QUOTE); + assert_eq!(result, expected); + } + + #[test] + fn unescape_dollar_single_quote_tab() { + let result = unescape_str("$'\\t'"); + let expected = format!("{}\t{}", markers::SNG_QUOTE, markers::SNG_QUOTE); + assert_eq!(result, expected); + } + + #[test] + fn unescape_dollar_single_quote_escape() { + let result = unescape_str("$'\\e'"); + let expected = format!("{}\x1b{}", markers::SNG_QUOTE, markers::SNG_QUOTE); + assert_eq!(result, expected); + } + + #[test] + fn unescape_dollar_single_quote_hex() { + let result = unescape_str("$'\\x41'"); + let expected = format!("{}A{}", markers::SNG_QUOTE, markers::SNG_QUOTE); + assert_eq!(result, expected); + } + + #[test] + fn unescape_dollar_single_quote_backslash() { + let result = unescape_str("$'\\\\'"); + let expected = format!("{}\\{}", markers::SNG_QUOTE, markers::SNG_QUOTE); + assert_eq!(result, expected); + } + + // ===================== tokenize_prompt ===================== + + #[test] + fn prompt_username() { + let tokens = tokenize_prompt("\\u"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::Username)); + } + + #[test] + fn prompt_hostname() { + let tokens = tokenize_prompt("\\h"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::Hostname)); + } + + #[test] + fn prompt_pwd() { + let tokens = tokenize_prompt("\\w"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::Pwd)); + } + + #[test] + fn prompt_pwd_short() { + let tokens = tokenize_prompt("\\W"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::PwdShort)); + } + + #[test] + fn prompt_symbol() { + let tokens = tokenize_prompt("\\$"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::PromptSymbol)); + } + + #[test] + fn prompt_newline() { + let tokens = tokenize_prompt("\\n"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::Text(ref t) if t == "\n")); + } + + #[test] + fn prompt_shell_name() { + let tokens = tokenize_prompt("\\s"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::ShellName)); + } + + #[test] + fn prompt_literal_backslash() { + let tokens = tokenize_prompt("\\\\"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::Text(ref t) if t == "\\")); + } + + #[test] + fn prompt_mixed() { + let tokens = tokenize_prompt("\\u@\\h \\w\\$ "); + // \u, Text("@"), \h, Text(" "), \w, \$, Text(" ") + assert_eq!(tokens.len(), 7); + assert!(matches!(tokens[0], PromptTk::Username)); + assert!(matches!(tokens[1], PromptTk::Text(ref t) if t == "@")); + assert!(matches!(tokens[2], PromptTk::Hostname)); + assert!(matches!(tokens[3], PromptTk::Text(ref t) if t == " ")); + assert!(matches!(tokens[4], PromptTk::Pwd)); + assert!(matches!(tokens[5], PromptTk::PromptSymbol)); + assert!(matches!(tokens[6], PromptTk::Text(ref t) if t == " ")); + } + + #[test] + fn prompt_ansi_sequence() { + let tokens = tokenize_prompt("\\e[31m"); + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::AnsiSeq(ref s) if s == "\x1b[31m")); + } + + #[test] + fn prompt_octal() { + let tokens = tokenize_prompt("\\141"); // 'a' in octal + assert_eq!(tokens.len(), 1); + assert!(matches!(tokens[0], PromptTk::AsciiOct(97))); + } + + // ===================== format_cmd_runtime ===================== + + #[test] + fn runtime_millis() { + let dur = Duration::from_millis(500); + assert_eq!(format_cmd_runtime(dur), "500ms"); + } + + #[test] + fn runtime_seconds() { + let dur = Duration::from_secs(5); + assert_eq!(format_cmd_runtime(dur), "5s"); + } + + #[test] + fn runtime_minutes_and_seconds() { + let dur = Duration::from_secs(125); + assert_eq!(format_cmd_runtime(dur), "2m 5s"); + } + + #[test] + fn runtime_hours() { + let dur = Duration::from_secs(3661); + assert_eq!(format_cmd_runtime(dur), "1h 1m 1s"); + } + + #[test] + fn runtime_micros() { + let dur = Duration::from_micros(500); + assert_eq!(format_cmd_runtime(dur), "500µs"); + } + + // ===================== parse_key_alias ===================== + + #[test] + fn key_alias_cr() { + let key = parse_key_alias("CR").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Char('\r'), ModKeys::NONE)); + } + + #[test] + fn key_alias_enter() { + let key = parse_key_alias("ENTER").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Enter, ModKeys::NONE)); + } + + #[test] + fn key_alias_esc() { + let key = parse_key_alias("ESC").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Esc, ModKeys::NONE)); + } + + #[test] + fn key_alias_tab() { + let key = parse_key_alias("TAB").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Tab, ModKeys::NONE)); + } + + #[test] + fn key_alias_backspace() { + let key = parse_key_alias("BS").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Backspace, ModKeys::NONE)); + } + + #[test] + fn key_alias_space() { + let key = parse_key_alias("SPACE").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Char(' '), ModKeys::NONE)); + } + + #[test] + fn key_alias_arrows() { + assert_eq!(parse_key_alias("UP").unwrap(), KeyEvent(KeyCode::Up, ModKeys::NONE)); + assert_eq!(parse_key_alias("DOWN").unwrap(), KeyEvent(KeyCode::Down, ModKeys::NONE)); + assert_eq!(parse_key_alias("LEFT").unwrap(), KeyEvent(KeyCode::Left, ModKeys::NONE)); + assert_eq!(parse_key_alias("RIGHT").unwrap(), KeyEvent(KeyCode::Right, ModKeys::NONE)); + } + + #[test] + fn key_alias_ctrl_modifier() { + let key = parse_key_alias("C-a").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Char('A'), ModKeys::CTRL)); + } + + #[test] + fn key_alias_ctrl_shift_alt_modifier() { + let key = parse_key_alias("C-S-A-b").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Char('B'), ModKeys::CTRL | ModKeys::SHIFT | ModKeys::ALT)); + } + + #[test] + fn key_alias_alt_modifier() { + let key = parse_key_alias("M-x").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Char('X'), ModKeys::ALT)); + } + + #[test] + fn key_alias_shift_modifier() { + let key = parse_key_alias("S-TAB").unwrap(); + assert_eq!(key, KeyEvent(KeyCode::Tab, ModKeys::SHIFT)); + } + + #[test] + fn key_alias_invalid() { + assert!(parse_key_alias("INVALID_KEY").is_none()); + } + + // ===================== expand_keymap ===================== + + #[test] + fn keymap_single_char() { + let keys = expand_keymap("a"); + assert_eq!(keys, vec![KeyEvent(KeyCode::Char('a'), ModKeys::NONE)]); + } + + #[test] + fn keymap_sequence() { + let keys = expand_keymap("abc"); + assert_eq!(keys.len(), 3); + assert_eq!(keys[0], KeyEvent(KeyCode::Char('a'), ModKeys::NONE)); + assert_eq!(keys[1], KeyEvent(KeyCode::Char('b'), ModKeys::NONE)); + assert_eq!(keys[2], KeyEvent(KeyCode::Char('c'), ModKeys::NONE)); + } + + #[test] + fn keymap_ctrl_key() { + let keys = expand_keymap(""); + assert_eq!(keys, vec![KeyEvent(KeyCode::Char('A'), ModKeys::CTRL)]); + } + + #[test] + fn keymap_escaped_char() { + let keys = expand_keymap("\\<"); + assert_eq!(keys, vec![KeyEvent(KeyCode::Char('<'), ModKeys::NONE)]); + } + + #[test] + fn keymap_mixed() { + let keys = expand_keymap("ab"); + assert_eq!(keys.len(), 3); + assert_eq!(keys[0], KeyEvent(KeyCode::Char('a'), ModKeys::NONE)); + assert_eq!(keys[1], KeyEvent(KeyCode::Char('\r'), ModKeys::NONE)); + assert_eq!(keys[2], KeyEvent(KeyCode::Char('b'), ModKeys::NONE)); + } + + // ===================== Variable Expansion (TestGuard) ===================== + + #[test] + fn var_expansion_basic() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("MYVAR", VarKind::Str("hello".into()), VarFlags::NONE)).unwrap(); + + let raw = unescape_str("$MYVAR"); + let result = expand_raw(&mut raw.chars().peekable()).unwrap(); + assert_eq!(result, "hello"); + } + + #[test] + fn var_expansion_braced() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("FOO", VarKind::Str("bar".into()), VarFlags::NONE)).unwrap(); + + let raw = unescape_str("${FOO}"); + let result = expand_raw(&mut raw.chars().peekable()).unwrap(); + assert_eq!(result, "bar"); + } + + #[test] + fn var_expansion_unset_empty() { + let _guard = TestGuard::new(); + + let raw = unescape_str("$NONEXISTENT"); + let result = expand_raw(&mut raw.chars().peekable()).unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn var_expansion_concatenated() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("A", VarKind::Str("hello".into()), VarFlags::NONE)).unwrap(); + write_vars(|v| v.set_var("B", VarKind::Str("world".into()), VarFlags::NONE)).unwrap(); + + let raw = unescape_str("${A}_${B}"); + let result = expand_raw(&mut raw.chars().peekable()).unwrap(); + assert_eq!(result, "hello_world"); + } + + // ===================== Parameter Expansion (TestGuard) ===================== + + #[test] + fn param_default_unset_or_null_unset() { + let _guard = TestGuard::new(); + let result = perform_param_expansion("UNSET:-fallback").unwrap(); + assert_eq!(result, "fallback"); + } + + #[test] + fn param_default_unset_or_null_null() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("EMPTY", VarKind::Str("".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("EMPTY:-fallback").unwrap(); + assert_eq!(result, "fallback"); + } + + #[test] + fn param_default_unset_or_null_set() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("SET", VarKind::Str("value".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("SET:-fallback").unwrap(); + assert_eq!(result, "value"); + } + + #[test] + fn param_default_unset_only() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("EMPTY", VarKind::Str("".into()), VarFlags::NONE)).unwrap(); + + // ${EMPTY-fallback} — EMPTY is set (even if null), so returns null + let result = perform_param_expansion("EMPTY-fallback").unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn param_alt_set_not_null() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("SET", VarKind::Str("value".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("SET:+alt").unwrap(); + assert_eq!(result, "alt"); + } + + #[test] + fn param_alt_unset() { + let _guard = TestGuard::new(); + + let result = perform_param_expansion("UNSET:+alt").unwrap(); + assert_eq!(result, ""); + } + + #[test] + fn param_err_unset() { + let _guard = TestGuard::new(); + + let result = perform_param_expansion("UNSET:?variable not set"); + assert!(result.is_err()); + } + + #[test] + fn param_length() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("STR", VarKind::Str("hello".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("#STR").unwrap(); + assert_eq!(result, "5"); + } + + #[test] + fn param_substr() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("STR", VarKind::Str("hello world".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("STR:6").unwrap(); + assert_eq!(result, "world"); + } + + #[test] + fn param_substr_len() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("STR", VarKind::Str("hello world".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("STR:0:5").unwrap(); + assert_eq!(result, "hello"); + } + + #[test] + fn param_remove_shortest_prefix() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("PATH", VarKind::Str("/usr/local/bin".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("PATH#*/").unwrap(); + assert_eq!(result, "usr/local/bin"); + } + + #[test] + fn param_remove_longest_prefix() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("PATH", VarKind::Str("/usr/local/bin".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("PATH##*/").unwrap(); + assert_eq!(result, "bin"); + } + + #[test] + fn param_remove_shortest_suffix() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("FILE", VarKind::Str("file.tar.gz".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("FILE%.*").unwrap(); + assert_eq!(result, "file.tar"); + } + + #[test] + fn param_remove_longest_suffix() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("FILE", VarKind::Str("file.tar.gz".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("FILE%%.*").unwrap(); + assert_eq!(result, "file"); + } + + #[test] + fn param_replace_first() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("STR", VarKind::Str("hello hello".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("STR/hello/world").unwrap(); + assert_eq!(result, "world hello"); + } + + #[test] + fn param_replace_all() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("STR", VarKind::Str("hello hello".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("STR//hello/world").unwrap(); + assert_eq!(result, "world world"); + } + + #[test] + fn param_indirect() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("REF", VarKind::Str("TARGET".into()), VarFlags::NONE)).unwrap(); + write_vars(|v| v.set_var("TARGET", VarKind::Str("value".into()), VarFlags::NONE)).unwrap(); + + let result = perform_param_expansion("!REF").unwrap(); + assert_eq!(result, "value"); + } + + #[test] + fn param_set_default_assigns() { + let _guard = TestGuard::new(); + + let result = perform_param_expansion("NEWVAR:=assigned").unwrap(); + assert_eq!(result, "assigned"); + + // Verify it was actually set + let val = read_vars(|v| v.get_var("NEWVAR")); + assert_eq!(val, "assigned"); + } + + // ===================== Command Substitution (TestGuard) ===================== + + #[test] + fn cmd_sub_echo() { + let _guard = TestGuard::new(); + let result = expand_cmd_sub("echo hello").unwrap(); + assert_eq!(result, "hello"); + } + + #[test] + fn cmd_sub_trailing_newlines_stripped() { + let _guard = TestGuard::new(); + let result = expand_cmd_sub("printf 'hello\\n\\n'").unwrap(); + assert_eq!(result, "hello"); + } + + #[test] + fn cmd_sub_arithmetic() { + let result = expand_cmd_sub("(1+2)").unwrap(); + assert_eq!(result, "3"); + } + + // ===================== Tilde Expansion (TestGuard) ===================== + + #[test] + fn tilde_expansion_home() { + let _guard = TestGuard::new(); + let home = std::env::var("HOME").unwrap(); + + let raw = unescape_str("~/foo"); + let result = expand_raw(&mut raw.chars().peekable()).unwrap(); + assert_eq!(result, format!("{}/foo", home)); + } + + #[test] + fn tilde_expansion_bare() { + let _guard = TestGuard::new(); + let home = std::env::var("HOME").unwrap(); + + let raw = unescape_str("~"); + let result = expand_raw(&mut raw.chars().peekable()).unwrap(); + assert_eq!(result, home); + } + + // ===================== Word Splitting (TestGuard) ===================== + + #[test] + fn word_split_default_ifs() { + let _guard = TestGuard::new(); + + let mut exp = Expander { raw: "hello world\tfoo".to_string() }; + let words = exp.split_words(); + assert_eq!(words, vec!["hello", "world", "foo"]); + } + + #[test] + fn word_split_custom_ifs() { + let _guard = TestGuard::new(); + unsafe { std::env::set_var("IFS", ":"); } + + let mut exp = Expander { raw: "a:b:c".to_string() }; + let words = exp.split_words(); + assert_eq!(words, vec!["a", "b", "c"]); + } + + #[test] + fn word_split_empty_ifs() { + let _guard = TestGuard::new(); + unsafe { std::env::set_var("IFS", ""); } + + let mut exp = Expander { raw: "hello world".to_string() }; + let words = exp.split_words(); + assert_eq!(words, vec!["hello world"]); + } + + #[test] + fn word_split_quoted_no_split() { + let _guard = TestGuard::new(); + + let raw = format!("{}hello world{}", markers::DUB_QUOTE, markers::DUB_QUOTE); + let mut exp = Expander { raw }; + let words = exp.split_words(); + assert_eq!(words, vec!["hello world"]); + } + + // ===================== Arithmetic with Variables (TestGuard) ===================== + + #[test] + fn arith_with_variable() { + let _guard = TestGuard::new(); + write_vars(|v| v.set_var("x", VarKind::Str("5".into()), VarFlags::NONE)).unwrap(); + + // expand_arithmetic processes the body after stripping outer parens + // unescape_math converts $x into marker+x + let body = "$x+3"; + let unescaped = unescape_math(body); + let expanded = expand_raw(&mut unescaped.chars().peekable()).unwrap(); + let tokens = ArithTk::tokenize(&expanded).unwrap().unwrap(); + let rpn = ArithTk::to_rpn(tokens).unwrap(); + let result = ArithTk::eval_rpn(rpn).unwrap(); + assert_eq!(result, 8.0); + } + + // ===================== Array Indexing (TestGuard) ===================== + + #[test] + fn array_index_first() { + let _guard = TestGuard::new(); + write_vars(|v| { + v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE) + }).unwrap(); + + let val = read_vars(|v| v.index_var("arr", ArrIndex::Literal(0))).unwrap(); + assert_eq!(val, "a"); + } + + #[test] + fn array_index_second() { + let _guard = TestGuard::new(); + write_vars(|v| { + v.set_var("arr", VarKind::arr_from_vec(vec!["x".into(), "y".into(), "z".into()]), VarFlags::NONE) + }).unwrap(); + + let val = read_vars(|v| v.index_var("arr", ArrIndex::Literal(1))).unwrap(); + assert_eq!(val, "y"); + } + + #[test] + fn array_all_elems() { + let _guard = TestGuard::new(); + write_vars(|v| { + v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE) + }).unwrap(); + + let elems = read_vars(|v| v.get_arr_elems("arr")).unwrap(); + assert_eq!(elems, vec!["a", "b", "c"]); + } + + #[test] + fn array_elem_count() { + let _guard = TestGuard::new(); + write_vars(|v| { + v.set_var("arr", VarKind::arr_from_vec(vec!["a".into(), "b".into(), "c".into()]), VarFlags::NONE) + }).unwrap(); + + let elems = read_vars(|v| v.get_arr_elems("arr")).unwrap(); + assert_eq!(elems.len(), 3); + } + + // ===================== Alias Expansion (TestGuard) ===================== + + #[test] + fn alias_simple() { + let _guard = TestGuard::new(); + let dummy_span = Span::default(); + crate::state::SHED.with(|s| { + s.logic.borrow_mut().insert_alias("ll", "ls -la", dummy_span.clone()); + }); + + let log_tab = crate::state::SHED.with(|s| s.logic.borrow().clone()); + let result = expand_aliases("ll".to_string(), HashSet::new(), &log_tab); + assert_eq!(result, "ls -la"); + } + + #[test] + fn alias_circular_prevention() { + let _guard = TestGuard::new(); + let dummy_span = Span::default(); + crate::state::SHED.with(|s| { + s.logic.borrow_mut().insert_alias("foo", "foo --verbose", dummy_span.clone()); + }); + + let log_tab = crate::state::SHED.with(|s| s.logic.borrow().clone()); + let result = expand_aliases("foo".to_string(), HashSet::new(), &log_tab); + // After first expansion: "foo --verbose", then "foo" is in already_expanded + // so it won't expand again + assert_eq!(result, "foo --verbose"); + } + + // ===================== Direct Input Tests (TestGuard) ===================== + + #[test] + fn index_simple() { + let guard = TestGuard::new(); + write_vars(|v| v.set_var("arr", VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])), VarFlags::NONE)).unwrap(); + + test_input("echo $arr").unwrap(); + + let out = guard.read_output(); + assert_eq!(out, "foo bar biz\n"); + } + + #[test] + fn index_cursed() { + let guard = TestGuard::new(); + write_vars(|v| v.set_var("arr", VarKind::Arr(VecDeque::from(["foo".into(), "bar".into(), "biz".into()])), VarFlags::NONE)).unwrap(); + write_vars(|v| v.set_var("i", VarKind::Arr(VecDeque::from(["0".into(), "1".into(), "2".into()])), VarFlags::NONE)).unwrap(); + + test_input("echo $echo ${var:-${arr[$(($(echo ${i[0]}) + 1))]}}").unwrap(); + + let out = guard.read_output(); + assert_eq!(out, "bar\n"); + } +} diff --git a/src/getopt.rs b/src/getopt.rs index 495e8e3..47d7064 100644 --- a/src/getopt.rs +++ b/src/getopt.rs @@ -1,8 +1,9 @@ use std::sync::Arc; +use ariadne::Fmt; use fmt::Display; -use crate::{libsh::error::ShResult, parse::lex::Tk, prelude::*}; +use crate::{libsh::error::{ShErr, ShErrKind, ShResult, next_color}, parse::lex::Tk, prelude::*}; pub type OptSet = Arc<[Opt]>; @@ -67,10 +68,21 @@ pub fn get_opts(words: Vec) -> (Vec, Vec) { (non_opts, opts) } +pub fn get_opts_from_tokens_strict( + tokens: Vec, + opt_specs: &[OptSpec], +) -> ShResult<(Vec, Vec)> { + sort_tks(tokens, opt_specs, true) +} + pub fn get_opts_from_tokens( tokens: Vec, opt_specs: &[OptSpec], ) -> ShResult<(Vec, Vec)> { + sort_tks(tokens, opt_specs, false) +} + +pub fn sort_tks(tokens: Vec, opt_specs: &[OptSpec], strict: bool) -> ShResult<(Vec, Vec)> { let mut tokens_iter = tokens .into_iter() .map(|t| t.expand()) @@ -113,10 +125,218 @@ pub fn get_opts_from_tokens( } } if !pushed { - non_opts.push(token.clone()); + if strict { + return Err(ShErr::simple( + ShErrKind::ParseErr, + format!("Unknown option: {}", opt.to_string().fg(next_color())), + )); + } else { + non_opts.push(token.clone()); + } } } } } Ok((non_opts, opts)) } + + +#[cfg(test)] +mod tests { + use crate::parse::lex::{LexFlags, LexStream}; + +use super::*; + + #[test] + fn parse_short_single() { + let opts = Opt::parse("-a"); + assert_eq!(opts, vec![Opt::Short('a')]); + } + + #[test] + fn parse_short_combined() { + let opts = Opt::parse("-abc"); + assert_eq!(opts, vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]); + } + + #[test] + fn parse_long() { + let opts = Opt::parse("--verbose"); + assert_eq!(opts, vec![Opt::Long("verbose".into())]); + } + + #[test] + fn parse_non_option() { + let opts = Opt::parse("hello"); + assert!(opts.is_empty()); + } + + #[test] + fn get_opts_basic() { + let words = vec!["file.txt".into(), "-v".into(), "--help".into(), "arg".into()]; + let (non_opts, opts) = get_opts(words); + assert_eq!(non_opts, vec!["file.txt", "arg"]); + assert_eq!(opts, vec![Opt::Short('v'), Opt::Long("help".into())]); + } + + #[test] + fn get_opts_double_dash_stops_parsing() { + let words = vec!["-a".into(), "--".into(), "-b".into(), "--foo".into()]; + let (non_opts, opts) = get_opts(words); + assert_eq!(opts, vec![Opt::Short('a')]); + assert_eq!(non_opts, vec!["-b", "--foo"]); + } + + #[test] + fn get_opts_combined_short() { + let words = vec!["-abc".into(), "file".into()]; + let (non_opts, opts) = get_opts(words); + assert_eq!(opts, vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]); + assert_eq!(non_opts, vec!["file"]); + } + + #[test] + fn get_opts_no_flags() { + let words = vec!["foo".into(), "bar".into()]; + let (non_opts, opts) = get_opts(words); + assert!(opts.is_empty()); + assert_eq!(non_opts, vec!["foo", "bar"]); + } + + #[test] + fn get_opts_empty_input() { + let (non_opts, opts) = get_opts(vec![]); + assert!(opts.is_empty()); + assert!(non_opts.is_empty()); + } + + #[test] + fn display_formatting() { + assert_eq!(Opt::Short('v').to_string(), "-v"); + assert_eq!(Opt::Long("help".into()).to_string(), "--help"); + assert_eq!(Opt::ShortWithArg('o', "file".into()).to_string(), "-o file"); + assert_eq!(Opt::LongWithArg("output".into(), "file".into()).to_string(), "--output file"); + } + + fn lex(input: &str) -> Vec { + LexStream::new(Arc::new(input.to_string()), LexFlags::empty()) + .collect::>>() + .unwrap() + } + + #[test] + fn get_opts_from_tks() { + let tokens = lex("file.txt --help -v arg"); + + let opt_spec = vec![ + OptSpec { opt: Opt::Short('v'), takes_arg: false }, + OptSpec { opt: Opt::Long("help".into()), takes_arg: false }, + ]; + + let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); + + let mut opts = opts.into_iter(); + assert!(opts.any(|o| o == Opt::Short('v') || o == Opt::Long("help".into()))); + assert!(opts.any(|o| o == Opt::Short('v') || o == Opt::Long("help".into()))); + + let mut non_opts = non_opts.into_iter().map(|s| s.to_string()); + assert!(non_opts.any(|s| s == "file.txt" || s == "arg")); + assert!(non_opts.any(|s| s == "file.txt" || s == "arg")); + } + + #[test] + fn tks_short_with_arg() { + let tokens = lex("-o output.txt file.txt"); + + let opt_spec = vec![ + OptSpec { opt: Opt::Short('o'), takes_arg: true }, + ]; + + let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); + + assert_eq!(opts, vec![Opt::ShortWithArg('o', "output.txt".into())]); + let non_opts: Vec = non_opts.into_iter().map(|s| s.to_string()).collect(); + assert!(non_opts.contains(&"file.txt".to_string())); + } + + #[test] + fn tks_long_with_arg() { + let tokens = lex("--output result.txt input.txt"); + + let opt_spec = vec![ + OptSpec { opt: Opt::Long("output".into()), takes_arg: true }, + ]; + + let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); + + assert_eq!(opts, vec![Opt::LongWithArg("output".into(), "result.txt".into())]); + let non_opts: Vec = non_opts.into_iter().map(|s| s.to_string()).collect(); + assert!(non_opts.contains(&"input.txt".to_string())); + } + + #[test] + fn tks_double_dash_stops() { + let tokens = lex("-v -- -a --foo"); + + let opt_spec = vec![ + OptSpec { opt: Opt::Short('v'), takes_arg: false }, + OptSpec { opt: Opt::Short('a'), takes_arg: false }, + ]; + + let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); + + assert_eq!(opts, vec![Opt::Short('v')]); + let non_opts: Vec = non_opts.into_iter().map(|s| s.to_string()).collect(); + assert!(non_opts.contains(&"-a".to_string())); + assert!(non_opts.contains(&"--foo".to_string())); + } + + #[test] + fn tks_combined_short_with_spec() { + let tokens = lex("-abc"); + + let opt_spec = vec![ + OptSpec { opt: Opt::Short('a'), takes_arg: false }, + OptSpec { opt: Opt::Short('b'), takes_arg: false }, + OptSpec { opt: Opt::Short('c'), takes_arg: false }, + ]; + + let (_non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); + + assert_eq!(opts, vec![Opt::Short('a'), Opt::Short('b'), Opt::Short('c')]); + } + + #[test] + fn tks_unknown_opt_becomes_non_opt() { + let tokens = lex("-v -x file"); + + let opt_spec = vec![ + OptSpec { opt: Opt::Short('v'), takes_arg: false }, + ]; + + let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); + + assert_eq!(opts, vec![Opt::Short('v')]); + // -x is not in spec, so its token goes to non_opts + assert!(non_opts.into_iter().map(|s| s.to_string()).any(|s| s == "-x" || s == "file")); + } + + #[test] + fn tks_mixed_short_and_long_with_args() { + let tokens = lex("-n 5 --output file.txt input"); + + let opt_spec = vec![ + OptSpec { opt: Opt::Short('n'), takes_arg: true }, + OptSpec { opt: Opt::Long("output".into()), takes_arg: true }, + ]; + + let (non_opts, opts) = get_opts_from_tokens(tokens, &opt_spec).unwrap(); + + assert_eq!(opts, vec![ + Opt::ShortWithArg('n', "5".into()), + Opt::LongWithArg("output".into(), "file.txt".into()), + ]); + let non_opts: Vec = non_opts.into_iter().map(|s| s.to_string()).collect(); + assert!(non_opts.contains(&"input".to_string())); + } +} diff --git a/src/jobs.rs b/src/jobs.rs index 3a19138..c5276c9 100644 --- a/src/jobs.rs +++ b/src/jobs.rs @@ -1,4 +1,6 @@ +use ariadne::Fmt; use scopeguard::defer; +use yansi::Color; use crate::{ libsh::{ @@ -149,7 +151,7 @@ pub struct RegisteredFd { pub owner_pid: Pid, } -#[derive(Default, Debug)] +#[derive(Clone, Default, Debug)] pub struct JobTab { fg: Option, order: Vec, @@ -724,12 +726,12 @@ impl Job { stat_line = format!("{}{} ", pid, stat_line); stat_line = format!("{} {}", stat_line, cmd); stat_line = match job_stat { - WtStat::Stopped(..) | WtStat::Signaled(..) => stat_line.styled(Style::Magenta), + WtStat::Stopped(..) | WtStat::Signaled(..) => stat_line.fg(Color::Magenta).to_string(), WtStat::Exited(_, code) => match code { - 0 => stat_line.styled(Style::Green), - _ => stat_line.styled(Style::Red), + 0 => stat_line.fg(Color::Green).to_string(), + _ => stat_line.fg(Color::Red).to_string(), }, - _ => stat_line.styled(Style::Cyan), + _ => stat_line.fg(Color::Cyan).to_string(), }; if i != 0 { let padding = " ".repeat(id_box.len() - 1); diff --git a/src/libsh/error.rs b/src/libsh/error.rs index cafdbca..3f78c5d 100644 --- a/src/libsh/error.rs +++ b/src/libsh/error.rs @@ -1,13 +1,13 @@ -use ariadne::Color; +use ariadne::{Color, Fmt}; use ariadne::{Report, ReportKind}; use rand::TryRng; +use yansi::Paint; use std::cell::RefCell; use std::collections::{HashMap, VecDeque}; use std::fmt::Display; use crate::procio::RedirGuard; use crate::{ - libsh::term::{Style, Styled}, parse::lex::{Span, SpanSource}, prelude::*, }; @@ -144,12 +144,13 @@ impl Note { impl Display for Note { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let note = "note".styled(Style::Green); + let note = Fmt::fg("note", Color::Green); let main = &self.main; if self.depth == 0 { writeln!(f, "{note}: {main}")?; } else { - let bar_break = "-".styled(Style::Cyan | Style::Bold); + let bar_break = Fmt::fg("-", Color::Cyan); + let bar_break = bar_break.bold(); let indent = " ".repeat(self.depth); writeln!(f, " {indent}{bar_break} {main}")?; } diff --git a/src/libsh/flog.rs b/src/libsh/flog.rs deleted file mode 100644 index d3c5160..0000000 --- a/src/libsh/flog.rs +++ /dev/null @@ -1,149 +0,0 @@ -use std::fmt::Display; - -use super::term::{Style, Styled}; - -#[derive(Clone, Copy, PartialEq, PartialOrd, Ord, Eq, Debug)] -#[repr(u8)] -pub enum ShedLogLevel { - NONE = 0, - ERROR = 1, - WARN = 2, - INFO = 3, - DEBUG = 4, - TRACE = 5, -} - -impl Display for ShedLogLevel { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - use ShedLogLevel::*; - match self { - ERROR => write!(f, "{}", "ERROR".styled(Style::Red | Style::Bold)), - WARN => write!(f, "{}", "WARN".styled(Style::Yellow | Style::Bold)), - INFO => write!(f, "{}", "INFO".styled(Style::Green | Style::Bold)), - DEBUG => write!(f, "{}", "DEBUG".styled(Style::Magenta | Style::Bold)), - TRACE => write!(f, "{}", "TRACE".styled(Style::Blue | Style::Bold)), - NONE => write!(f, ""), - } - } -} - -pub fn log_level() -> ShedLogLevel { - use ShedLogLevel::*; - let level = std::env::var("SHED_LOG_LEVEL").unwrap_or_default(); - match level.to_lowercase().as_str() { - "error" => ERROR, - "warn" => WARN, - "info" => INFO, - "debug" => DEBUG, - "trace" => TRACE, - _ => NONE, - } -} - -/// A structured logging macro designed for `shed`. -/// -/// `flog!` was implemented because `rustyline` uses `env_logger`, which -/// clutters the debug output. This macro prints log messages in a structured -/// format, including the log level, filename, and line number. -/// -/// # Usage -/// -/// The macro supports three types of arguments: -/// -/// ## 1. **Formatted Messages** -/// Similar to `println!` or `format!`, allows embedding values inside a -/// formatted string. -/// -/// ```rust -/// flog!(ERROR, "foo is {}", foo); -/// ``` -/// **Output:** -/// ```plaintext -/// [ERROR][file.rs:10] foo is -/// ``` -/// -/// ## 2. **Literals** -/// Directly prints each literal argument as a separate line. -/// -/// ```rust -/// flog!(WARN, "foo", "bar"); -/// ``` -/// **Output:** -/// ```plaintext -/// [WARN][file.rs:10] foo -/// [WARN][file.rs:10] bar -/// ``` -/// -/// ## 3. **Expressions** -/// Logs the evaluated result of each given expression, displaying both the -/// expression and its value. -/// -/// ```rust -/// flog!(INFO, 1.min(2)); -/// ``` -/// **Output:** -/// ```plaintext -/// [INFO][file.rs:10] 1 -/// ``` -/// -/// # Considerations -/// - This macro uses `eprintln!()` internally, so its formatting rules must be -/// followed. -/// - **Literals and formatted messages** require arguments that implement -/// [`std::fmt::Display`]. -/// - **Expressions** require arguments that implement [`std::fmt::Debug`]. -#[macro_export] -macro_rules! flog { - ($level:path, $fmt:literal, $($args:expr),+ $(,)?) => {{ - use $crate::libsh::flog::log_level; - use $crate::libsh::term::Styled; - use $crate::libsh::term::Style; - - if $level <= log_level() { - let file = file!().styled(Style::Cyan); - let line = line!().to_string().styled(Style::Cyan); - - eprintln!( - "[{}][{}:{}] {}", - $level, file, line, format!($fmt, $($args),+) - ); - } - }}; - - ($level:path, $($val:expr),+ $(,)?) => {{ - use $crate::libsh::flog::log_level; - use $crate::libsh::term::Styled; - use $crate::libsh::term::Style; - - if $level <= log_level() { - let file = file!().styled(Style::Cyan); - let line = line!().to_string().styled(Style::Cyan); - - $( - let val_name = stringify!($val); - eprintln!( - "[{}][{}:{}] {} = {:#?}", - $level, file, line, val_name, &$val - ); - )+ - } - }}; - - ($level:path, $($lit:literal),+ $(,)?) => {{ - use $crate::libsh::flog::log_level; - use $crate::libsh::term::Styled; - use $crate::libsh::term::Style; - - if $level <= log_level() { - let file = file!().styled(Style::Cyan); - let line = line!().to_string().styled(Style::Cyan); - - $( - eprintln!( - "[{}][{}:{}] {}", - $level, file, line, $lit - ); - )+ - } - }}; -} diff --git a/src/libsh/mod.rs b/src/libsh/mod.rs index c9bab3c..ec7ce4b 100644 --- a/src/libsh/mod.rs +++ b/src/libsh/mod.rs @@ -1,5 +1,4 @@ pub mod error; -pub mod flog; pub mod guards; pub mod sys; pub mod term; diff --git a/src/main.rs b/src/main.rs index 6e8a61f..2e58c58 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,9 @@ pub mod shopt; pub mod signal; pub mod state; +#[cfg(test)] +pub mod testutil; + use std::os::fd::BorrowedFd; use std::process::ExitCode; use std::sync::atomic::Ordering; @@ -361,11 +364,13 @@ fn handle_readline_event(readline: &mut ShedVi, event: ShResult) pre_exec.exec_with(&input); + // Time this command and temporarily restore cooked terminal mode while it runs. let start = Instant::now(); write_meta(|m| m.start_timer()); if let Err(e) = RawModeGuard::with_cooked_mode(|| { exec_input(input.clone(), None, true, Some("".into())) }) { + // CleanExit signals an intentional shell exit; any other error is printed. match e.kind() { ShErrKind::CleanExit(code) => { QUIT_CODE.store(*code, Ordering::SeqCst); diff --git a/src/parse/execute.rs b/src/parse/execute.rs index 81d4566..5b53221 100644 --- a/src/parse/execute.rs +++ b/src/parse/execute.rs @@ -5,11 +5,10 @@ use std::{ }; use ariadne::Fmt; -use nix::sys::resource; use crate::{ builtin::{ - alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, map, pwd::pwd, read::{self, read_builtin}, resource::ulimit, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset}, zoltraak::zoltraak + alias::{alias, unalias}, arrops::{arr_fpop, arr_fpush, arr_pop, arr_push, arr_rotate}, autocmd::autocmd, cd::cd, complete::{compgen_builtin, complete_builtin}, dirstack::{dirs, popd, pushd}, echo::echo, eval, exec, flowctl::flowctl, getopts::getopts, intro, jobctl::{self, JobBehavior, continue_job, disown, jobs}, keymap, map, pwd::pwd, read::{self, read_builtin}, resource::ulimit, shift::shift, shopt::shopt, source::source, test::double_bracket_test, trap::{TrapTarget, trap}, varcmds::{export, local, readonly, unset} }, expand::{expand_aliases, expand_case_pattern, glob_to_regex}, jobs::{ChildProc, JobStack, attach_tty, dispatch_job}, @@ -450,7 +449,6 @@ impl Dispatcher { let fork_builtins = brc_grp.flags.contains(NdFlags::FORK_BUILTINS); self.io_stack.append_to_frame(brc_grp.redirs); - if self.interactive {} let guard = self.io_stack.pop_frame().redirect()?; let brc_grp_logic = |s: &mut Self| -> ShResult<()> { for node in body { @@ -911,7 +909,7 @@ impl Dispatcher { "export" => export(cmd), "local" => local(cmd), "pwd" => pwd(cmd), - "source" => source(cmd), + "source" | "." => source(cmd), "shift" => shift(cmd), "fg" => continue_job(cmd, JobBehavior::Foregound), "bg" => continue_job(cmd, JobBehavior::Background), @@ -923,7 +921,6 @@ impl Dispatcher { "break" => flowctl(cmd, ShErrKind::LoopBreak(0)), "continue" => flowctl(cmd, ShErrKind::LoopContinue(0)), "exit" => flowctl(cmd, ShErrKind::CleanExit(0)), - "zoltraak" => zoltraak(cmd), "shopt" => shopt(cmd), "read" => read_builtin(cmd), "trap" => trap(cmd), diff --git a/src/parse/mod.rs b/src/parse/mod.rs index accbcd1..b3b3da5 100644 --- a/src/parse/mod.rs +++ b/src/parse/mod.rs @@ -18,9 +18,9 @@ use crate::{ pub mod execute; pub mod lex; -pub const TEST_UNARY_OPS: [&str; 21] = [ - "-a", "-b", "-c", "-d", "-e", "-f", "-g", "-h", "-L", "-k", "-p", "-r", "-s", "-S", "-t", "-u", - "-w", "-x", "-O", "-G", "-N", +pub const TEST_UNARY_OPS: [&str; 23] = [ + "-a", "-b", "-c", "-d", "-e", "-f", "-g", "-h", "-L", "-k", "-n", "-p", "-r", "-s", "-S", "-t", + "-u", "-w", "-x", "-z", "-O", "-G", "-N", ]; /// Try to match a specific parsing rule diff --git a/src/prelude.rs b/src/prelude.rs index 72664f7..ef8a39f 100644 --- a/src/prelude.rs +++ b/src/prelude.rs @@ -33,7 +33,5 @@ pub use nix::{ }, }; -pub use crate::flog; -pub use crate::libsh::flog::ShedLogLevel::*; // Additional utilities, if needed, can be added here diff --git a/src/procio.rs b/src/procio.rs index f9bf592..24084d6 100644 --- a/src/procio.rs +++ b/src/procio.rs @@ -385,3 +385,157 @@ impl Iterator for PipeGenerator { Some((rpipe, Some(wpipe))) } } + +#[cfg(test)] +pub mod tests { + use crate::testutil::{TestGuard, has_cmd, has_cmds, test_input}; + use pretty_assertions::assert_eq; + + #[test] + fn pipeline_simple() { + if !has_cmd("sed") { return }; + let g = TestGuard::new(); + + test_input("echo foo | sed 's/foo/bar/'").unwrap(); + + let out = g.read_output(); + assert_eq!(out, "bar\n"); + } + + #[test] + fn pipeline_multi() { + if !has_cmds(&[ + "cut", + "sed" + ]) { return; } + let g = TestGuard::new(); + + test_input("echo foo bar baz | cut -d ' ' -f 2 | sed 's/a/A/'").unwrap(); + + let out = g.read_output(); + assert_eq!(out, "bAr\n"); + } + + #[test] + fn rube_goldberg_pipeline() { + if !has_cmds(&[ + "sed", + "cat", + ]) { return } + let g = TestGuard::new(); + + test_input("{ echo foo; echo bar } | if cat; then :; else echo failed; fi | (read line && echo $line | sed 's/foo/baz/'; sed 's/bar/buzz/')").unwrap(); + + let out = g.read_output(); + assert_eq!(out, "baz\nbuzz\n"); + } + + #[test] + fn simple_file_redir() { + let mut g = TestGuard::new(); + + test_input("echo this is in a file > /tmp/simple_file_redir.txt").unwrap(); + + g.add_cleanup(|| { std::fs::remove_file("/tmp/simple_file_redir.txt").ok(); }); + let contents = std::fs::read_to_string("/tmp/simple_file_redir.txt").unwrap(); + + assert_eq!(contents, "this is in a file\n"); + } + + #[test] + fn append_file_redir() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("append.txt"); + let _g = TestGuard::new(); + + test_input(format!("echo first > {}", path.display())).unwrap(); + test_input(format!("echo second >> {}", path.display())).unwrap(); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert_eq!(contents, "first\nsecond\n"); + } + + #[test] + fn input_redir() { + if !has_cmd("cat") { return; } + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("input.txt"); + std::fs::write(&path, "hello from file\n").unwrap(); + let g = TestGuard::new(); + + test_input(format!("cat < {}", path.display())).unwrap(); + + let out = g.read_output(); + assert_eq!(out, "hello from file\n"); + } + + #[test] + fn stderr_redir_to_file() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("err.txt"); + let g = TestGuard::new(); + + test_input(format!("echo error msg 2> {} >&2", path.display())).unwrap(); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert_eq!(contents, "error msg\n"); + // stdout should be empty since we redirected to stderr + let out = g.read_output(); + assert_eq!(out, ""); + } + + #[test] + fn pipe_and_stderr() { + if !has_cmd("cat") { return; } + let g = TestGuard::new(); + + test_input("echo on stderr >&2 |& cat").unwrap(); + + let out = g.read_output(); + assert_eq!(out, "on stderr\n"); + } + + #[test] + fn output_redir_clobber() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("clobber.txt"); + let _g = TestGuard::new(); + + test_input(format!("echo first > {}", path.display())).unwrap(); + test_input(format!("echo second > {}", path.display())).unwrap(); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert_eq!(contents, "second\n"); + } + + #[test] + fn pipeline_preserves_exit_status() { + if !has_cmd("cat") { return; } + let _g = TestGuard::new(); + + test_input("false | cat").unwrap(); + + // Pipeline exit status is the last command + let status = crate::state::get_status(); + assert_eq!(status, 0); + + test_input("cat < /dev/null | false").unwrap(); + + let status = crate::state::get_status(); + assert_ne!(status, 0); + } + + #[test] + fn fd_duplication() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("dup.txt"); + let _g = TestGuard::new(); + + // Redirect stdout to file, then dup stderr to stdout — both should go to file + test_input(format!("{{ echo out; echo err >&2 }} > {} 2>&1", path.display())).unwrap(); + + let contents = std::fs::read_to_string(&path).unwrap(); + assert!(contents.contains("out")); + assert!(contents.contains("err")); + } +} diff --git a/src/readline/complete.rs b/src/readline/complete.rs index b622400..d923db2 100644 --- a/src/readline/complete.rs +++ b/src/readline/complete.rs @@ -101,6 +101,21 @@ pub fn complete_vars(start: &str) -> Vec { }) } +pub fn complete_vars_raw(raw: &str) -> Vec { + if !read_vars(|v| v.get_var(raw)).is_empty() { + return vec![]; + } + // if we are here, we have a variable substitution that isn't complete + // so let's try to complete it + read_vars(|v| { + v.flatten_vars() + .keys() + .filter(|k| k.starts_with(raw) && *k != raw) + .map(|k| k.to_string()) + .collect::>() + }) +} + pub fn extract_var_name(text: &str) -> Option<(String, usize, usize)> { let mut chars = text.chars().peekable(); let mut name = String::new(); @@ -422,7 +437,7 @@ impl CompSpec for BashCompSpec { candidates.extend(complete_commands(&expanded)); } if self.vars { - candidates.extend(complete_vars(&expanded)); + candidates.extend(complete_vars_raw(&expanded)); } if self.users { candidates.extend(complete_users(&expanded)); diff --git a/src/readline/history.rs b/src/readline/history.rs index 034d9ae..c2127cc 100644 --- a/src/readline/history.rs +++ b/src/readline/history.rs @@ -466,3 +466,125 @@ impl History { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{state, testutil::TestGuard}; + use scopeguard::guard; + use std::{env, fs, path::Path, sync::Mutex}; + use tempfile::tempdir; + + fn with_env_var(key: &str, val: &str) -> impl Drop { + let prev = env::var(key).ok(); + unsafe { + env::set_var(key, val); + } + guard(prev, move |p| match p { + Some(v) => unsafe { + env::set_var(key, v) + }, + None => unsafe { + env::remove_var(key) + }, + }) + } + + /// Temporarily mutate shell options for a test and restore the + /// previous values when the returned guard is dropped. + fn with_shopts(modifier: impl FnOnce(&mut crate::shopt::ShOpts)) -> impl Drop { + let original = state::read_shopts(|s| s.clone()); + state::write_shopts(|s| modifier(s)); + guard(original, |orig| { + state::write_shopts(|s| *s = orig); + }) + } + + fn write_history_file(path: &Path) { + fs::write( + path, + [ + ": 1;1;first\n", + ": 2;1;second\n", + ": 3;1;third\n", + ] + .concat(), + ) + .unwrap(); + } + + #[test] + fn history_new_respects_max_hist_limit() { + let _lock = TestGuard::new(); + let tmp = tempdir().unwrap(); + let hist_path = tmp.path().join("history"); + write_history_file(&hist_path); + + let _env_guard = with_env_var("SHEDHIST", hist_path.to_str().unwrap()); + let _opts_guard = with_shopts(|s| { + s.core.max_hist = 2; + s.core.hist_ignore_dupes = true; + }); + + let history = History::new().unwrap(); + + assert_eq!(history.entries.len(), 2); + assert_eq!(history.search_mask.len(), 2); + assert_eq!(history.cursor, 2); + assert_eq!(history.max_size, Some(2)); + assert!(history.ignore_dups); + assert!(history.pending.is_none()); + assert_eq!(history.entries[0].command(), "second"); + assert_eq!(history.entries[1].command(), "third"); + } + + #[test] + fn history_new_keeps_all_when_unlimited() { + let _lock = TestGuard::new(); + let tmp = tempdir().unwrap(); + let hist_path = tmp.path().join("history"); + write_history_file(&hist_path); + + let _env_guard = with_env_var("SHEDHIST", hist_path.to_str().unwrap()); + let _opts_guard = with_shopts(|s| { + s.core.max_hist = -1; + s.core.hist_ignore_dupes = false; + }); + + let history = History::new().unwrap(); + + assert_eq!(history.entries.len(), 3); + assert_eq!(history.search_mask.len(), 3); + assert_eq!(history.cursor, 3); + assert_eq!(history.max_size, None); + assert!(!history.ignore_dups); + } + + #[test] + fn history_new_dedupes_search_mask_to_latest_occurrence() { + let _lock = TestGuard::new(); + let tmp = tempdir().unwrap(); + let hist_path = tmp.path().join("history"); + fs::write( + &hist_path, + [ + ": 1;1;repeat\n", + ": 2;1;unique\n", + ": 3;1;repeat\n", + ] + .concat(), + ) + .unwrap(); + + let _env_guard = with_env_var("SHEDHIST", hist_path.to_str().unwrap()); + let _opts_guard = with_shopts(|s| { + s.core.max_hist = 10; + }); + + let history = History::new().unwrap(); + + let masked: Vec<_> = history.search_mask.iter().map(|e| e.command()).collect(); + assert_eq!(masked, vec!["unique", "repeat"]); + assert_eq!(history.cursor, history.search_mask.len()); + } +} diff --git a/src/shopt.rs b/src/shopt.rs index bb0df48..3940e3f 100644 --- a/src/shopt.rs +++ b/src/shopt.rs @@ -145,6 +145,7 @@ pub struct ShOptCore { pub auto_hist: bool, pub bell_enabled: bool, pub max_recurse_depth: usize, + pub xpg_echo: bool, } impl ShOptCore { @@ -184,6 +185,12 @@ impl ShOptCore { "shopt: expected an integer for max_hist value (-1 for unlimited)", )); }; + if val < -1 { + return Err(ShErr::simple( + ShErrKind::SyntaxErr, + "shopt: expected a non-negative integer or -1 for max_hist value", + )); + } self.max_hist = val; } "interactive_comments" => { @@ -222,6 +229,15 @@ impl ShOptCore { }; self.max_recurse_depth = val; } + "xpg_echo" => { + let Ok(val) = val.parse::() else { + return Err(ShErr::simple( + ShErrKind::SyntaxErr, + "shopt: expected 'true' or 'false' for xpg_echo value", + )); + }; + self.xpg_echo = val; + } _ => { return Err(ShErr::simple( ShErrKind::SyntaxErr, @@ -283,6 +299,11 @@ impl ShOptCore { output.push_str(&format!("{}", self.max_recurse_depth)); Ok(Some(output)) } + "xpg_echo" => { + let mut output = String::from("Whether echo expands escape sequences by default\n"); + output.push_str(&format!("{}", self.xpg_echo)); + Ok(Some(output)) + } _ => Err(ShErr::simple( ShErrKind::SyntaxErr, format!("shopt: Unexpected 'core' option '{query}'"), @@ -305,6 +326,7 @@ impl Display for ShOptCore { output.push(format!("auto_hist = {}", self.auto_hist)); output.push(format!("bell_enabled = {}", self.bell_enabled)); output.push(format!("max_recurse_depth = {}", self.max_recurse_depth)); + output.push(format!("xpg_echo = {}", self.xpg_echo)); let final_output = output.join("\n"); @@ -323,6 +345,7 @@ impl Default for ShOptCore { auto_hist: true, bell_enabled: true, max_recurse_depth: 1000, + xpg_echo: false, } } } @@ -517,3 +540,128 @@ impl Default for ShOptPrompt { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_core_fields_covered() { + let ShOptCore { + dotglob, autocd, hist_ignore_dupes, max_hist, + interactive_comments, auto_hist, bell_enabled, max_recurse_depth, + xpg_echo, + } = ShOptCore::default(); + // If a field is added to the struct, this destructure fails to compile. + let _ = ( + dotglob, + autocd, + hist_ignore_dupes, + max_hist, + interactive_comments, + auto_hist, + bell_enabled, + max_recurse_depth, + xpg_echo, + ); + } + + #[test] + fn set_and_get_core_bool() { + let mut opts = ShOpts::default(); + assert!(!opts.core.dotglob); + + opts.set("core.dotglob", "true").unwrap(); + assert!(opts.core.dotglob); + + opts.set("core.dotglob", "false").unwrap(); + assert!(!opts.core.dotglob); + } + + #[test] + fn set_and_get_core_int() { + let mut opts = ShOpts::default(); + assert_eq!(opts.core.max_hist, 10_000); + + opts.set("core.max_hist", "500").unwrap(); + assert_eq!(opts.core.max_hist, 500); + + opts.set("core.max_hist", "-1").unwrap(); + assert_eq!(opts.core.max_hist, -1); + + assert!(opts.set("core.max_hist", "-500").is_err()); + } + + #[test] + fn set_and_get_prompt_opts() { + let mut opts = ShOpts::default(); + + opts.set("prompt.edit_mode", "emacs").unwrap(); + assert!(matches!(opts.prompt.edit_mode, ShedEditMode::Emacs)); + + opts.set("prompt.edit_mode", "vi").unwrap(); + assert!(matches!(opts.prompt.edit_mode, ShedEditMode::Vi)); + + opts.set("prompt.comp_limit", "50").unwrap(); + assert_eq!(opts.prompt.comp_limit, 50); + + opts.set("prompt.leader", "space").unwrap(); + assert_eq!(opts.prompt.leader, "space"); + } + + #[test] + fn query_set_returns_none() { + let mut opts = ShOpts::default(); + let result = opts.query("core.autocd=true").unwrap(); + assert!(result.is_none()); + assert!(opts.core.autocd); + } + + #[test] + fn query_get_returns_some() { + let opts = ShOpts::default(); + let result = opts.get("core.dotglob").unwrap(); + assert!(result.is_some()); + let text = result.unwrap(); + assert!(text.contains("false")); + } + + #[test] + fn invalid_category_errors() { + let mut opts = ShOpts::default(); + assert!(opts.set("bogus.dotglob", "true").is_err()); + assert!(opts.get("bogus.dotglob").is_err()); + } + + #[test] + fn invalid_option_errors() { + let mut opts = ShOpts::default(); + assert!(opts.set("core.nonexistent", "true").is_err()); + assert!(opts.set("prompt.nonexistent", "true").is_err()); + } + + #[test] + fn invalid_value_errors() { + let mut opts = ShOpts::default(); + assert!(opts.set("core.dotglob", "notabool").is_err()); + assert!(opts.set("core.max_hist", "notanint").is_err()); + assert!(opts.set("core.max_recurse_depth", "-5").is_err()); + assert!(opts.set("prompt.edit_mode", "notepad").is_err()); + assert!(opts.set("prompt.comp_limit", "abc").is_err()); + } + + #[test] + fn get_category_lists_all() { + let opts = ShOpts::default(); + let core_output = opts.get("core").unwrap().unwrap(); + assert!(core_output.contains("dotglob")); + assert!(core_output.contains("autocd")); + assert!(core_output.contains("max_hist")); + assert!(core_output.contains("bell_enabled")); + + let prompt_output = opts.get("prompt").unwrap().unwrap(); + assert!(prompt_output.contains("edit_mode")); + assert!(prompt_output.contains("comp_limit")); + assert!(prompt_output.contains("highlight")); + } +} diff --git a/src/state.rs b/src/state.rs index 6cbecef..a2d0358 100644 --- a/src/state.rs +++ b/src/state.rs @@ -32,12 +32,20 @@ use crate::{ shopt::ShOpts, }; +thread_local! { + pub static SHED: Shed = Shed::new(); +} + +#[derive(Clone,Debug)] pub struct Shed { pub jobs: RefCell, pub var_scopes: RefCell, pub meta: RefCell, pub logic: RefCell, pub shopts: RefCell, + + #[cfg(test)] + saved: RefCell>>, } impl Shed { @@ -48,6 +56,9 @@ impl Shed { meta: RefCell::new(MetaTab::new()), logic: RefCell::new(LogTab::new()), shopts: RefCell::new(ShOpts::default()), + + #[cfg(test)] + saved: RefCell::new(None), } } } @@ -58,6 +69,31 @@ impl Default for Shed { } } +#[cfg(test)] +impl Shed { + pub fn save(&self) { + let saved = Self { + jobs: RefCell::new(self.jobs.borrow().clone()), + var_scopes: RefCell::new(self.var_scopes.borrow().clone()), + meta: RefCell::new(self.meta.borrow().clone()), + logic: RefCell::new(self.logic.borrow().clone()), + shopts: RefCell::new(self.shopts.borrow().clone()), + saved: RefCell::new(None), + }; + *self.saved.borrow_mut() = Some(Box::new(saved)); + } + + pub fn restore(&self) { + if let Some(saved) = self.saved.take() { + *self.jobs.borrow_mut() = saved.jobs.into_inner(); + *self.var_scopes.borrow_mut() = saved.var_scopes.into_inner(); + *self.meta.borrow_mut() = saved.meta.into_inner(); + *self.logic.borrow_mut() = saved.logic.into_inner(); + *self.shopts.borrow_mut() = saved.shopts.into_inner(); + } + } +} + #[derive(Hash, Eq, PartialEq, Debug, Clone, Copy)] pub enum ShellParam { // Global @@ -485,10 +521,6 @@ impl ScopeStack { } } -thread_local! { - pub static SHED: Shed = Shed::new(); -} - #[derive(Clone, Debug)] pub struct ShAlias { pub body: String, @@ -1258,7 +1290,7 @@ impl VarTab { } /// A table of metadata for the shell -#[derive(Default, Debug)] +#[derive(Clone, Default, Debug)] pub struct MetaTab { // command running duration runtime_start: Option, diff --git a/src/testutil.rs b/src/testutil.rs new file mode 100644 index 0000000..871e636 --- /dev/null +++ b/src/testutil.rs @@ -0,0 +1,155 @@ +use std::{ + collections::HashMap, + env, + os::fd::{AsRawFd, OwnedFd}, + path::PathBuf, + sync::{self, MutexGuard}, +}; + +use nix::{ + fcntl::{FcntlArg, OFlag, fcntl}, + pty::openpty, + sys::termios::{OutputFlags, SetArg, tcgetattr, tcsetattr}, + unistd::read, +}; + +use crate::{ + libsh::error::ShResult, + parse::{Redir, RedirType, execute::exec_input}, + procio::{IoFrame, IoMode, RedirGuard}, + state::{MetaTab, SHED}, +}; + +static TEST_MUTEX: sync::Mutex<()> = sync::Mutex::new(()); + +pub fn has_cmds(cmds: &[&str]) -> bool { + let path_cmds = MetaTab::get_cmds_in_path(); + path_cmds.iter().all(|c| cmds.iter().any(|&cmd| c == cmd)) +} + +pub fn has_cmd(cmd: &str) -> bool { + MetaTab::get_cmds_in_path().into_iter().any(|c| c == cmd) +} + +pub fn test_input(input: impl Into) -> ShResult<()> { + exec_input(input.into(), None, true, None) +} + +pub struct TestGuard { + _lock: MutexGuard<'static, ()>, + _redir_guard: RedirGuard, + old_cwd: PathBuf, + saved_env: HashMap, + pty_master: OwnedFd, + pty_slave: OwnedFd, + + cleanups: Vec> +} + +impl TestGuard { + pub fn new() -> Self { + let _lock = TEST_MUTEX.lock().unwrap(); + + let pty = openpty(None, None).unwrap(); + let (pty_master,pty_slave) = (pty.master, pty.slave); + let mut attrs = tcgetattr(&pty_slave).unwrap(); + attrs.output_flags &= !OutputFlags::ONLCR; + tcsetattr(&pty_slave, SetArg::TCSANOW, &attrs).unwrap(); + + let mut frame = IoFrame::new(); + frame.push( + Redir::new( + IoMode::Fd { + tgt_fd: 0, + src_fd: pty_slave.as_raw_fd(), + }, + RedirType::Input, + ), + ); + frame.push( + Redir::new( + IoMode::Fd { + tgt_fd: 1, + src_fd: pty_slave.as_raw_fd(), + }, + RedirType::Output, + ), + ); + frame.push( + Redir::new( + IoMode::Fd { + tgt_fd: 2, + src_fd: pty_slave.as_raw_fd(), + }, + RedirType::Output, + ), + ); + + let _redir_guard = frame.redirect().unwrap(); + + let old_cwd = env::current_dir().unwrap(); + let saved_env = env::vars().collect(); + SHED.with(|s| s.save()); + Self { + _lock, + _redir_guard, + old_cwd, + saved_env, + pty_master, + pty_slave, + cleanups: vec![], + } + } + + pub fn add_cleanup(&mut self, f: impl FnOnce() + 'static) { + self.cleanups.push(Box::new(f)); + } + + pub fn read_output(&self) -> String { + let flags = fcntl(self.pty_master.as_raw_fd(), FcntlArg::F_GETFL).unwrap(); + let flags = OFlag::from_bits_truncate(flags); + fcntl( + self.pty_master.as_raw_fd(), + FcntlArg::F_SETFL(flags | OFlag::O_NONBLOCK), + ).unwrap(); + + let mut out = vec![]; + let mut buf = [0;4096]; + loop { + match read(self.pty_master.as_raw_fd(), &mut buf) { + Ok(0) => break, + Ok(n) => out.extend_from_slice(&buf[..n]), + Err(_) => break, + } + } + + fcntl( + self.pty_master.as_raw_fd(), + FcntlArg::F_SETFL(flags), + ).unwrap(); + + String::from_utf8_lossy(&out).to_string() + } +} + +impl Default for TestGuard { + fn default() -> Self { + Self::new() + } +} + +impl Drop for TestGuard { + fn drop(&mut self) { + env::set_current_dir(&self.old_cwd).ok(); + for (k, _) in env::vars() { + unsafe { env::remove_var(&k); } + } + for (k, v) in &self.saved_env { + unsafe { env::set_var(k, v); } + } + for cleanup in self.cleanups.drain(..).rev() { + cleanup(); + } + SHED.with(|s| s.restore()); + } +}