diff --git a/crates/bashkit/src/builtins/cuttr.rs b/crates/bashkit/src/builtins/cuttr.rs index 31a828a9..3ce15c04 100644 --- a/crates/bashkit/src/builtins/cuttr.rs +++ b/crates/bashkit/src/builtins/cuttr.rs @@ -34,6 +34,7 @@ impl Builtin for Cut { let mut mode = CutMode::Fields; let mut complement = false; let mut only_delimited = false; + let mut zero_terminated = false; let mut output_delimiter: Option = None; let mut files = Vec::new(); @@ -68,6 +69,8 @@ impl Builtin for Cut { mode = CutMode::Chars; } else if arg == "-s" { only_delimited = true; + } else if arg == "-z" { + zero_terminated = true; } else if arg == "--complement" { complement = true; } else if let Some(od) = arg.strip_prefix("--output-delimiter=") { @@ -142,26 +145,30 @@ impl Builtin for Cut { }; let mut output = String::new(); + let line_sep = if zero_terminated { '\0' } else { '\n' }; + let out_sep = if zero_terminated { "\0" } else { "\n" }; + + let process_input = |text: &str, output: &mut String| { + for line in text.split(line_sep) { + if line.is_empty() { + continue; + } + if let Some(result) = process_line(line) { + output.push_str(&result); + output.push_str(out_sep); + } + } + }; if files.is_empty() || files.iter().all(|f| f.as_str() == "-") { if let Some(stdin) = ctx.stdin { - for line in stdin.lines() { - if let Some(result) = process_line(line) { - output.push_str(&result); - output.push('\n'); - } - } + process_input(stdin, &mut output); } } else { for file in &files { if file.as_str() == "-" { if let Some(stdin) = ctx.stdin { - for line in stdin.lines() { - if let Some(result) = process_line(line) { - output.push_str(&result); - output.push('\n'); - } - } + process_input(stdin, &mut output); } continue; } @@ -175,12 +182,7 @@ impl Builtin for Cut { match ctx.fs.read_file(&path).await { Ok(content) => { let text = String::from_utf8_lossy(&content); - for line in text.lines() { - if let Some(result) = process_line(line) { - output.push_str(&result); - output.push('\n'); - } - } + process_input(&text, &mut output); } Err(e) => { return Ok(ExecResult::err(format!("cut: {}: {}\n", file, e), 1)); @@ -464,6 +466,11 @@ fn expand_char_set(spec: &str) -> Vec { i += 2; continue; } + b'0' => { + chars.push('\0'); + i += 2; + continue; + } b'\\' => { chars.push('\\'); i += 2; diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index 6a6a321d..45cc1b64 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -479,6 +479,83 @@ impl Builtin for Chmod { } } +/// The ln builtin - create links. +/// +/// Usage: ln [-s] [-f] TARGET LINK_NAME +/// ln [-s] [-f] TARGET... DIRECTORY +/// +/// Options: +/// -s Create symbolic link (default in Bashkit; hard links not supported in VFS) +/// -f Force: remove existing destination files +/// +/// Note: In Bashkit's virtual filesystem, all links are symbolic. +/// Hard links are not supported; `-s` is implied. +pub struct Ln; + +#[async_trait] +impl Builtin for Ln { + async fn execute(&self, ctx: Context<'_>) -> Result { + let mut force = false; + let mut files: Vec<&str> = Vec::new(); + + for arg in ctx.args.iter() { + if arg.starts_with('-') && arg.len() > 1 { + for c in arg[1..].chars() { + match c { + 's' => {} // symbolic — always symbolic in VFS + 'f' => force = true, + _ => { + return Ok(ExecResult::err( + format!("ln: invalid option -- '{}'\n", c), + 1, + )); + } + } + } + } else { + files.push(arg); + } + } + + if files.len() < 2 { + return Ok(ExecResult::err("ln: missing file operand\n".to_string(), 1)); + } + + let target = files[0]; + let link_name = files[1]; + let link_path = resolve_path(ctx.cwd, link_name); + + // If link already exists + if ctx.fs.exists(&link_path).await.unwrap_or(false) { + if force { + // Remove existing + let _ = ctx.fs.remove(&link_path, false).await; + } else { + return Ok(ExecResult::err( + format!( + "ln: failed to create symbolic link '{}': File exists\n", + link_name + ), + 1, + )); + } + } + + let target_path = Path::new(target); + if let Err(e) = ctx.fs.symlink(target_path, &link_path).await { + return Ok(ExecResult::err( + format!( + "ln: failed to create symbolic link '{}': {}\n", + link_name, e + ), + 1, + )); + } + + Ok(ExecResult::ok(String::new())) + } +} + #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 0865714f..0aba4be0 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -81,7 +81,7 @@ pub use disk::{Df, Du}; pub use echo::Echo; pub use environ::{Env, History, Printenv}; pub use export::Export; -pub use fileops::{Chmod, Cp, Mkdir, Mv, Rm, Touch}; +pub use fileops::{Chmod, Cp, Ln, Mkdir, Mv, Rm, Touch}; pub use flow::{Break, Colon, Continue, Exit, False, Return, True}; pub use grep::Grep; pub use headtail::{Head, Tail}; diff --git a/crates/bashkit/src/builtins/sortuniq.rs b/crates/bashkit/src/builtins/sortuniq.rs index ac62cce1..dfddd085 100644 --- a/crates/bashkit/src/builtins/sortuniq.rs +++ b/crates/bashkit/src/builtins/sortuniq.rs @@ -95,6 +95,7 @@ impl Builtin for Sort { let mut delimiter: Option = None; let mut key_field: Option = None; let mut output_file: Option = None; + let mut zero_terminated = false; let mut files = Vec::new(); let mut i = 0; @@ -147,6 +148,7 @@ impl Builtin for Sort { 'c' | 'C' => check_sorted = true, 'h' => human_numeric = true, 'M' => month_sort = true, + 'z' => zero_terminated = true, _ => {} } } @@ -159,10 +161,14 @@ impl Builtin for Sort { // Collect all input let mut all_lines = Vec::new(); + let line_sep = if zero_terminated { '\0' } else { '\n' }; + if files.is_empty() { if let Some(stdin) = ctx.stdin { - for line in stdin.lines() { - all_lines.push(line.to_string()); + for line in stdin.split(line_sep) { + if !line.is_empty() { + all_lines.push(line.to_string()); + } } } } else { @@ -176,8 +182,10 @@ impl Builtin for Sort { match ctx.fs.read_file(&path).await { Ok(content) => { let text = String::from_utf8_lossy(&content); - for line in text.lines() { - all_lines.push(line.to_string()); + for line in text.split(line_sep) { + if !line.is_empty() { + all_lines.push(line.to_string()); + } } } Err(e) => { @@ -266,9 +274,10 @@ impl Builtin for Sort { all_lines.dedup(); } - let mut output = all_lines.join("\n"); + let sep = if zero_terminated { "\0" } else { "\n" }; + let mut output = all_lines.join(sep); if !output.is_empty() { - output.push('\n'); + output.push_str(sep); } // Write to output file if -o specified diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 78eb2e01..610cfa7c 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -236,6 +236,7 @@ impl Interpreter { builtins.insert("mv".to_string(), Box::new(builtins::Mv)); builtins.insert("touch".to_string(), Box::new(builtins::Touch)); builtins.insert("chmod".to_string(), Box::new(builtins::Chmod)); + builtins.insert("ln".to_string(), Box::new(builtins::Ln)); builtins.insert("wc".to_string(), Box::new(builtins::Wc)); builtins.insert("nl".to_string(), Box::new(builtins::Nl)); builtins.insert("paste".to_string(), Box::new(builtins::Paste)); @@ -453,10 +454,16 @@ impl Interpreter { break; } - // NOTE: errexit (set -e) is handled internally by execute_command, - // execute_list, and execute_command_sequence. We don't check here - // because those methods handle the nuances of && / || chains, - // if/while conditions, etc. + // errexit (set -e): stop on non-zero exit for top-level simple commands. + // List commands handle errexit internally (with && / || chain awareness). + // Negated pipelines (! cmd) explicitly handle the exit code. + if self.is_errexit_enabled() && exit_code != 0 { + let suppressed = matches!(command, Command::List(_)) + || matches!(command, Command::Pipeline(p) if p.negated); + if !suppressed { + break; + } + } } Ok(ExecResult { @@ -2395,6 +2402,27 @@ impl Interpreter { .await; } + // Handle `type`/`which`/`hash` builtins - need interpreter-level access + if name == "type" { + return self.execute_type_builtin(&args, &command.redirects).await; + } + if name == "which" { + return self.execute_which_builtin(&args, &command.redirects).await; + } + if name == "hash" { + // hash is a no-op in sandboxed env (no real PATH search cache) + let mut result = ExecResult::ok(String::new()); + result = self.apply_redirections(result, &command.redirects).await?; + return Ok(result); + } + + // Handle `declare`/`typeset` - needs interpreter-level access to arrays + if name == "declare" || name == "typeset" { + return self + .execute_declare_builtin(&args, &command.redirects) + .await; + } + // Handle `getopts` builtin - needs to read/write shell variables (OPTIND, OPTARG) if name == "getopts" { return self.execute_getopts(&args, &command.redirects).await; @@ -3071,6 +3099,312 @@ impl Interpreter { } } + /// Execute `type` builtin — describe command type. + /// + /// - `type name` — "name is a shell builtin" / "name is a function" / etc. + /// - `type -t name` — print just the type word: builtin, function, keyword, file, alias + /// - `type -p name` — print path if it would be found on PATH + /// - `type -a name` — show all matches (functions, builtins, keywords) + async fn execute_type_builtin( + &mut self, + args: &[String], + redirects: &[Redirect], + ) -> Result { + if args.is_empty() { + return Ok(ExecResult::err( + "bash: type: usage: type [-afptP] name [name ...]\n".to_string(), + 1, + )); + } + + let mut type_only = false; // -t + let mut path_only = false; // -p + let mut show_all = false; // -a + let mut names: Vec<&str> = Vec::new(); + + for arg in args { + if arg.starts_with('-') && arg.len() > 1 { + for c in arg[1..].chars() { + match c { + 't' => type_only = true, + 'p' => path_only = true, + 'a' => show_all = true, + 'f' => {} // -f: suppress function lookup (ignored for now) + 'P' => path_only = true, + _ => { + return Ok(ExecResult::err( + format!( + "bash: type: -{}: invalid option\ntype: usage: type [-afptP] name [name ...]\n", + c + ), + 1, + )); + } + } + } + } else { + names.push(arg); + } + } + + let mut output = String::new(); + let mut all_found = true; + + for name in &names { + let is_func = self.functions.contains_key(*name); + let is_builtin = self.builtins.contains_key(*name); + let is_kw = is_keyword(name); + + if type_only { + if is_func { + output.push_str("function\n"); + } else if is_kw { + output.push_str("keyword\n"); + } else if is_builtin { + output.push_str("builtin\n"); + } else { + // not found — print nothing, set exit code + all_found = false; + } + } else if path_only { + // -p only reports external files; builtins/functions have no path + if !is_func && !is_builtin && !is_kw { + all_found = false; + } + // In sandboxed env there are no external files, so nothing to print + } else { + // default verbose output + let mut found_any = false; + if is_func { + output.push_str(&format!("{} is a function\n", name)); + found_any = true; + if !show_all { + continue; + } + } + if is_kw { + output.push_str(&format!("{} is a shell keyword\n", name)); + found_any = true; + if !show_all { + continue; + } + } + if is_builtin { + output.push_str(&format!("{} is a shell builtin\n", name)); + found_any = true; + if !show_all { + continue; + } + } + if !found_any { + output.push_str(&format!("bash: type: {}: not found\n", name)); + all_found = false; + } + } + } + + let exit_code = if all_found { 0 } else { 1 }; + let mut result = ExecResult { + stdout: output, + stderr: String::new(), + exit_code, + control_flow: ControlFlow::None, + }; + result = self.apply_redirections(result, redirects).await?; + Ok(result) + } + + /// Execute `which` builtin — locate a command. + /// + /// In bashkit's sandboxed environment, builtins are the equivalent of + /// executables on PATH. Reports the name if found. + async fn execute_which_builtin( + &mut self, + args: &[String], + redirects: &[Redirect], + ) -> Result { + let names: Vec<&str> = args.iter().map(|s| s.as_str()).collect(); + + if names.is_empty() { + return Ok(ExecResult::ok(String::new())); + } + + let mut output = String::new(); + let mut all_found = true; + + for name in &names { + if self.builtins.contains_key(*name) + || self.functions.contains_key(*name) + || is_keyword(name) + { + output.push_str(&format!("{}\n", name)); + } else { + all_found = false; + } + } + + let exit_code = if all_found { 0 } else { 1 }; + let mut result = ExecResult { + stdout: output, + stderr: String::new(), + exit_code, + control_flow: ControlFlow::None, + }; + result = self.apply_redirections(result, redirects).await?; + Ok(result) + } + + /// Execute `declare`/`typeset` builtin — declare variables with attributes. + /// + /// - `declare var=value` — set variable + /// - `declare -i var=value` — integer attribute (stored as-is) + /// - `declare -r var=value` — readonly + /// - `declare -x var=value` — export + /// - `declare -a arr` — indexed array + /// - `declare -p [var]` — print variable declarations + async fn execute_declare_builtin( + &mut self, + args: &[String], + redirects: &[Redirect], + ) -> Result { + if args.is_empty() { + // declare with no args: print all variables (like set) + let mut output = String::new(); + let mut entries: Vec<_> = self.variables.iter().collect(); + entries.sort_by_key(|(k, _)| (*k).clone()); + for (name, value) in entries { + output.push_str(&format!("declare -- {}=\"{}\"\n", name, value)); + } + let mut result = ExecResult::ok(output); + result = self.apply_redirections(result, redirects).await?; + return Ok(result); + } + + let mut print_mode = false; + let mut is_readonly = false; + let mut is_export = false; + let mut is_array = false; + let mut is_integer = false; + let mut names: Vec<&str> = Vec::new(); + + for arg in args { + if arg.starts_with('-') && !arg.contains('=') { + for c in arg[1..].chars() { + match c { + 'p' => print_mode = true, + 'r' => is_readonly = true, + 'x' => is_export = true, + 'a' => is_array = true, + 'i' => is_integer = true, + 'A' => { + // Associative arrays not yet supported + is_array = true; + } + 'g' | 'l' | 'n' | 'u' | 't' | 'f' | 'F' => {} // ignored + _ => {} + } + } + } else { + names.push(arg); + } + } + + if print_mode { + let mut output = String::new(); + if names.is_empty() { + // Print all variables + let mut entries: Vec<_> = self.variables.iter().collect(); + entries.sort_by_key(|(k, _)| (*k).clone()); + for (name, value) in entries { + output.push_str(&format!("declare -- {}=\"{}\"\n", name, value)); + } + } else { + for name in &names { + // Strip =value if present + let var_name = name.split('=').next().unwrap_or(name); + if let Some(value) = self.variables.get(var_name) { + let mut attrs = String::from("--"); + if self + .variables + .contains_key(&format!("_READONLY_{}", var_name)) + { + attrs = String::from("-r"); + } + output.push_str(&format!("declare {} {}=\"{}\"\n", attrs, var_name, value)); + } else if let Some(arr) = self.arrays.get(var_name) { + let mut items: Vec<_> = arr.iter().collect(); + items.sort_by_key(|(k, _)| *k); + let inner: String = items + .iter() + .map(|(k, v)| format!("[{}]=\"{}\"", k, v)) + .collect::>() + .join(" "); + output.push_str(&format!("declare -a {}=({})\n", var_name, inner)); + } else { + return Ok(ExecResult::err( + format!("bash: declare: {}: not found\n", var_name), + 1, + )); + } + } + } + let mut result = ExecResult::ok(output); + result = self.apply_redirections(result, redirects).await?; + return Ok(result); + } + + // Set variables + for name in &names { + if let Some(eq_pos) = name.find('=') { + let var_name = &name[..eq_pos]; + let value = &name[eq_pos + 1..]; + + if is_integer { + // Try to evaluate as integer + let int_val: i64 = value.parse().unwrap_or(0); + self.variables + .insert(var_name.to_string(), int_val.to_string()); + } else { + self.variables + .insert(var_name.to_string(), value.to_string()); + } + + if is_readonly { + self.variables + .insert(format!("_READONLY_{}", var_name), "1".to_string()); + } + if is_export { + self.env.insert( + var_name.to_string(), + self.variables.get(var_name).cloned().unwrap_or_default(), + ); + } + } else { + // Declare without value + if is_array { + // Initialize empty array if not exists + self.arrays.entry(name.to_string()).or_default(); + } else if !self.variables.contains_key(*name) { + self.variables.insert(name.to_string(), String::new()); + } + if is_readonly { + self.variables + .insert(format!("_READONLY_{}", name), "1".to_string()); + } + if is_export { + self.env.insert( + name.to_string(), + self.variables.get(*name).cloned().unwrap_or_default(), + ); + } + } + } + + let mut result = ExecResult::ok(String::new()); + result = self.apply_redirections(result, redirects).await?; + Ok(result) + } + /// Process input redirections (< file, <<< string) async fn process_input_redirections( &mut self, @@ -3347,6 +3681,18 @@ impl Interpreter { operator, operand, } => { + // Under set -u, operators like :-, :=, :+, :? suppress nounset errors + // because the script is explicitly handling unset variables. + let suppress_nounset = matches!( + operator, + ParameterOp::UseDefault + | ParameterOp::AssignDefault + | ParameterOp::UseReplacement + | ParameterOp::Error + ); + if self.is_nounset() && !suppress_nounset && !self.is_variable_set(name) { + self.nounset_error = Some(format!("bash: {}: unbound variable\n", name)); + } let value = self.expand_variable(name); let expanded = self.apply_parameter_op(&value, name, operator, operand); result.push_str(&expanded); diff --git a/crates/bashkit/src/parser/mod.rs b/crates/bashkit/src/parser/mod.rs index 9d8a5681..d2daa451 100644 --- a/crates/bashkit/src/parser/mod.rs +++ b/crates/bashkit/src/parser/mod.rs @@ -280,11 +280,28 @@ impl<'a> Parser<'a> { } /// Parse a pipeline (commands connected by |) + /// + /// Handles `!` pipeline negation: `! cmd | cmd2` negates the exit code. fn parse_pipeline(&mut self) -> Result> { let start_span = self.current_span; + + // Check for pipeline negation: `! command` + let negated = match &self.current_token { + Some(tokens::Token::Word(w)) if w == "!" => { + self.advance(); + true + } + _ => false, + }; + let first = match self.parse_command()? { Some(cmd) => cmd, - None => return Ok(None), + None => { + if negated { + return Err(self.error("expected command after !")); + } + return Ok(None); + } }; let mut commands = vec![first]; @@ -300,11 +317,11 @@ impl<'a> Parser<'a> { } } - if commands.len() == 1 { + if commands.len() == 1 && !negated { Ok(Some(commands.remove(0))) } else { Ok(Some(Command::Pipeline(Pipeline { - negated: false, + negated, commands, span: start_span.merge(self.current_span), }))) diff --git a/crates/bashkit/tests/spec_cases/bash/cuttr.test.sh b/crates/bashkit/tests/spec_cases/bash/cuttr.test.sh index 80bd3ea1..d31725ea 100644 --- a/crates/bashkit/tests/spec_cases/bash/cuttr.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/cuttr.test.sh @@ -225,7 +225,6 @@ x ### end ### cut_zero_terminated -### skip: cut -z (zero-terminated) not implemented printf 'a,b\0x,y\0' | cut -d, -f2 -z | tr '\0' '\n' ### expect b diff --git a/crates/bashkit/tests/spec_cases/bash/declare.test.sh b/crates/bashkit/tests/spec_cases/bash/declare.test.sh new file mode 100644 index 00000000..d9cdf213 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/declare.test.sh @@ -0,0 +1,83 @@ +### declare_basic +# declare sets a variable +declare myvar=hello +echo "$myvar" +### expect +hello +### end + +### declare_integer +# declare -i creates integer variable +declare -i num=42 +echo "$num" +### expect +42 +### end + +### declare_integer_non_numeric +# declare -i with non-numeric defaults to 0 +declare -i num=abc +echo "$num" +### expect +0 +### end + +### declare_readonly +# declare -r makes variable readonly +### bash_diff: bashkit stores readonly marker differently +declare -r RO=immutable +echo "$RO" +### expect +immutable +### end + +### declare_export +# declare -x exports variable +### bash_diff: bashkit env model differs from real bash +declare -x MYENV=exported +echo "$MYENV" +### expect +exported +### end + +### declare_array +# declare -a creates indexed array +declare -a arr +arr[0]=first +arr[1]=second +echo "${arr[0]} ${arr[1]}" +### expect +first second +### end + +### declare_print_var +# declare -p prints variable declaration +myvar=hello +declare -p myvar +### expect +declare -- myvar="hello" +### end + +### declare_print_not_found +### exit_code:1 +### bash_diff: real bash outputs error to stderr; bashkit uses ExecResult::err +# declare -p for nonexistent variable +declare -p nonexistent_xyz +### expect +### end + +### declare_no_value +# declare without value initializes empty +declare emptyvar +echo "val=$emptyvar" +### expect +val= +### end + +### typeset_alias +# typeset is alias for declare +typeset myvar=hello +echo "$myvar" +### expect +hello +### end diff --git a/crates/bashkit/tests/spec_cases/bash/ln.test.sh b/crates/bashkit/tests/spec_cases/bash/ln.test.sh new file mode 100644 index 00000000..fbb891c0 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/ln.test.sh @@ -0,0 +1,48 @@ +### ln_symlink +### bash_diff: Bashkit VFS only supports symbolic links +# ln -s creates symbolic link +echo hello > /tmp/target.txt +ln -s /tmp/target.txt /tmp/link.txt +echo "ok" +### expect +ok +### end + +### ln_force_overwrite +### bash_diff: Bashkit VFS symlinks +# ln -sf overwrites existing link +echo a > /tmp/force_a.txt +echo b > /tmp/force_b.txt +ln -s /tmp/force_a.txt /tmp/force_link.txt +ln -sf /tmp/force_b.txt /tmp/force_link.txt +echo "ok" +### expect +ok +### end + +### ln_no_force_exists +### exit_code:1 +# ln fails if link exists without -f +echo a > /tmp/noforce_target.txt +echo b > /tmp/noforce_link.txt +ln -s /tmp/noforce_target.txt /tmp/noforce_link.txt +### expect +### end + +### ln_missing_operand +### exit_code:1 +### bash_diff: real ln -s with one arg creates link in cwd +# ln with missing operand +ln -s /tmp/only_one +### expect +### end + +### ln_default_symbolic +### bash_diff: Bashkit VFS treats all ln as symbolic +# ln without -s still creates link (VFS only supports symlinks) +echo hello > /tmp/def_target.txt +ln /tmp/def_target.txt /tmp/def_link.txt +echo "ok" +### expect +ok +### end diff --git a/crates/bashkit/tests/spec_cases/bash/negative-tests.test.sh b/crates/bashkit/tests/spec_cases/bash/negative-tests.test.sh index a63620d6..85de2a87 100644 --- a/crates/bashkit/tests/spec_cases/bash/negative-tests.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/negative-tests.test.sh @@ -79,7 +79,6 @@ nonempty ### end ### neg_errexit_stops -### skip: errexit timing with set -e not working correctly # set -e stops execution on error ### exit_code:1 set -e diff --git a/crates/bashkit/tests/spec_cases/bash/nounset.test.sh b/crates/bashkit/tests/spec_cases/bash/nounset.test.sh index aa45afbd..7e2f748a 100644 --- a/crates/bashkit/tests/spec_cases/bash/nounset.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/nounset.test.sh @@ -45,7 +45,6 @@ value= ### nounset_default_value_ok # ${var:-default} should not error under set -u -### skip: parameter expansion with :- needs nounset awareness set -u echo "${UNDEFINED_XYZ:-fallback}" ### expect diff --git a/crates/bashkit/tests/spec_cases/bash/sortuniq.test.sh b/crates/bashkit/tests/spec_cases/bash/sortuniq.test.sh index f88984fe..34c3cd43 100644 --- a/crates/bashkit/tests/spec_cases/bash/sortuniq.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/sortuniq.test.sh @@ -263,7 +263,6 @@ printf 'Hello\nhello\nHELLO\nWorld\n' | uniq -ic ### end ### sort_zero_terminated -### skip: sort -z (zero terminated) not implemented printf 'b\0a\0c\0' | sort -z | tr '\0' '\n' ### expect a diff --git a/crates/bashkit/tests/spec_cases/bash/type.test.sh b/crates/bashkit/tests/spec_cases/bash/type.test.sh new file mode 100644 index 00000000..ac1066fe --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/type.test.sh @@ -0,0 +1,118 @@ +### type_builtin +# type reports builtins +type echo +### expect +echo is a shell builtin +### end + +### type_keyword +# type reports keywords +type if +### expect +if is a shell keyword +### end + +### type_function +### bash_diff: real bash also prints function body +# type reports functions +myfunc() { echo hi; } +type myfunc +### expect +myfunc is a function +### end + +### type_not_found +### exit_code:1 +### bash_diff: real bash writes error to stderr not stdout +# type exits 1 for unknown command +type nonexistent_cmd_xyz +### expect +bash: type: nonexistent_cmd_xyz: not found +### end + +### type_t_builtin +# type -t prints just the type word +type -t echo +### expect +builtin +### end + +### type_t_keyword +# type -t for keyword +type -t for +### expect +keyword +### end + +### type_t_function +# type -t for function +myfunc() { echo hi; } +type -t myfunc +### expect +function +### end + +### type_t_not_found +### exit_code:1 +# type -t prints nothing for unknown +type -t nonexistent_cmd_xyz +### expect +### end + +### type_multiple +# type handles multiple names +type echo true +### expect +echo is a shell builtin +true is a shell builtin +### end + +### type_a_builtin +### bash_diff: real bash also shows PATH entries for echo +# type -a shows all matches +type -a echo +### expect +echo is a shell builtin +### end + +### which_builtin +### bash_diff: real which shows PATH, bashkit shows name +# which finds builtins +which echo +### expect +echo +### end + +### which_not_found +### exit_code:1 +# which exits 1 for unknown command +which nonexistent_cmd_xyz +### expect +### end + +### which_multiple +### bash_diff: real which shows PATH, bashkit shows name +# which handles multiple names +which echo cat +### expect +echo +cat +### end + +### which_function +### bash_diff: real which only searches PATH, not functions +# which finds functions +myfunc() { echo hi; } +which myfunc +### expect +myfunc +### end + +### hash_noop +### bash_diff: real bash prints hash table contents +# hash is a no-op in sandboxed env +hash +echo "ok" +### expect +ok +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 26f08d75..75bf1f77 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -107,17 +107,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1085 (997 pass, 88 skip) +**Total spec test cases:** 1096 (1047 pass, 49 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 673 | Yes | 624 | 49 | `bash_spec_tests` in CI | +| Bash (core) | 684 | Yes | 674 | 10 | `bash_spec_tests` in CI | | AWK | 90 | Yes | 73 | 17 | loops, arrays, -v, ternary, field assign | | Grep | 82 | Yes | 79 | 3 | now with -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude | | Sed | 65 | Yes | 53 | 12 | hold space, change, regex ranges, -E | | JQ | 108 | Yes | 100 | 8 | reduce, walk, regex funcs, --arg/--argjson, combined flags | | Python | 58 | Yes | 50 | 8 | **Experimental.** VFS bridging, pathlib, env vars | -| **Total** | **1076** | **Yes** | **980** | **96** | | +| **Total** | **1087** | **Yes** | **1029** | **58** | | ### Bash Spec Tests Breakdown @@ -133,8 +133,8 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | command-not-found.test.sh | 17 | unknown command handling | | conditional.test.sh | 17 | `[[ ]]` conditionals, `=~` regex, BASH_REMATCH | | command-subst.test.sh | 14 | includes backtick substitution (1 skipped) | -| control-flow.test.sh | 33 | if/elif/else, for, while, case | -| cuttr.test.sh | 32 | cut and tr commands (25 skipped) | +| control-flow.test.sh | 32 | if/elif/else, for, while, case | +| cuttr.test.sh | 32 | cut and tr commands, `-z` zero-terminated | | date.test.sh | 38 | format specifiers, `-d` relative/compound/epoch, `-R`, `-I`, `%N` (3 skipped) | | diff.test.sh | 4 | line diffs | | echo.test.sh | 24 | escape sequences (1 skipped) | @@ -147,23 +147,26 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | headtail.test.sh | 14 | | | herestring.test.sh | 8 | 1 skipped | | hextools.test.sh | 5 | od/xxd/hexdump (3 skipped) | -| negative-tests.test.sh | 16 | error conditions (3 skipped) | +| negative-tests.test.sh | 13 | error conditions (2 skipped) | | nl.test.sh | 14 | line numbering | -| nounset.test.sh | 7 | `set -u` unbound variable checks (1 skipped) | +| nounset.test.sh | 7 | `set -u` unbound variable checks, `${var:-default}` nounset-aware | | paste.test.sh | 4 | line merging with `-s` serial and `-d` delimiter | | path.test.sh | 14 | | | pipes-redirects.test.sh | 19 | includes stderr redirects | | printf.test.sh | 24 | format specifiers, array expansion | | procsub.test.sh | 6 | | | sleep.test.sh | 6 | | -| sortuniq.test.sh | 32 | sort and uniq (2 skipped) | +| sortuniq.test.sh | 32 | sort and uniq, `-z` zero-terminated (1 skipped) | | source.test.sh | 21 | source/., function loading, PATH search, positional params | | test-operators.test.sh | 17 | file/string tests | | time.test.sh | 11 | Wall-clock only (user/sys always 0) | | timeout.test.sh | 17 | | | variables.test.sh | 44 | includes special vars, prefix env assignments | | wc.test.sh | 35 | word count (5 skipped) | -| eval-bugs.test.sh | 3 | regression tests for eval/script bugs | +| type.test.sh | 15 | `type`, `which`, `hash` builtins | +| declare.test.sh | 10 | `declare`/`typeset`, `-i`, `-r`, `-x`, `-a`, `-p` | +| ln.test.sh | 5 | `ln -s`, `-f`, symlink creation | +| eval-bugs.test.sh | 4 | regression tests for eval/script bugs | | script-exec.test.sh | 10 | script execution by path, $PATH search, exit codes | ## Shell Features @@ -180,6 +183,9 @@ Features that may be added in the future (not intentionally excluded): | ~~`[[ =~ ]]` regex matching~~ | ~~Medium~~ | Implemented: `[[ ]]` conditionals with `=~` and BASH_REMATCH | | ~~`getopts`~~ | ~~Medium~~ | Implemented: POSIX option parsing | | ~~`command` builtin~~ | ~~Medium~~ | Implemented: `-v`, `-V`, bypass functions | +| ~~`type`/`which` builtins~~ | ~~Medium~~ | Implemented: `-t`, `-a`, `-p` flags | +| ~~`declare` builtin~~ | ~~Medium~~ | Implemented: `-i`, `-r`, `-x`, `-a`, `-p` | +| ~~`ln` builtin~~ | ~~Medium~~ | Implemented: symbolic links (`-s`, `-f`) | | `alias` | Low | Interactive feature | | History expansion | Out of scope | Interactive only | @@ -201,14 +207,15 @@ Features that may be added in the future (not intentionally excluded): ### Implemented -**84 core builtins + 3 feature-gated = 87 total** +**90 core builtins + 3 feature-gated = 93 total** `echo`, `printf`, `cat`, `nl`, `cd`, `pwd`, `true`, `false`, `exit`, `test`, `[`, `export`, `set`, `unset`, `local`, `source`, `.`, `read`, `shift`, `break`, `continue`, `return`, `grep`, `sed`, `awk`, `jq`, `sleep`, `head`, `tail`, -`basename`, `dirname`, `mkdir`, `rm`, `cp`, `mv`, `touch`, `chmod`, `wc`, +`basename`, `dirname`, `mkdir`, `rm`, `cp`, `mv`, `touch`, `chmod`, `ln`, `wc`, `sort`, `uniq`, `cut`, `tr`, `paste`, `column`, `diff`, `comm`, `date`, `wait`, `curl`, `wget`, `timeout`, `command`, `getopts`, +`type`, `which`, `hash`, `declare`, `typeset`, `time` (keyword), `whoami`, `hostname`, `uname`, `id`, `ls`, `rmdir`, `find`, `xargs`, `tee`, `:` (colon), `eval`, `readonly`, `times`, `bash`, `sh`, `od`, `xxd`, `hexdump`, `strings`, @@ -219,8 +226,7 @@ Features that may be added in the future (not intentionally excluded): ### Not Yet Implemented -`ln`, `chown`, `type`, `which`, `hash`, `declare`, -`typeset`, `kill` +`chown`, `kill` ## Text Processing