diff --git a/crates/bashkit/src/builtins/arg_parser.rs b/crates/bashkit/src/builtins/arg_parser.rs index 093f77668..f7e432641 100644 --- a/crates/bashkit/src/builtins/arg_parser.rs +++ b/crates/bashkit/src/builtins/arg_parser.rs @@ -150,46 +150,6 @@ impl<'a> ArgParser<'a> { Ok(None) } - /// Try to consume a long option with a value, handling both - /// `--name=value` and `--name value` forms. - /// - /// Unlike [`flag_value`](Self::flag_value), the attached form requires an - /// explicit `=` separator (GNU long-option convention), so `--max-procs4` - /// is not mistaken for `--max-procs=4`. Returns `Ok(Some(value))` if - /// matched, `Err` if matched but no value follows, `Ok(None)` otherwise. - pub fn long_value( - &mut self, - name: &str, - cmd: &str, - ) -> std::result::Result, String> { - let arg = match self.args.get(self.pos) { - Some(a) => a.as_str(), - None => return Ok(None), - }; - - if arg == name { - // Separate form: --name value - self.pos += 1; - return match self.args.get(self.pos) { - Some(val) => { - self.pos += 1; - Ok(Some(val.as_str())) - } - None => Err(format!("{cmd}: {name} requires an argument")), - }; - } - - // Attached form: --name=value - if let Some(rest) = arg.strip_prefix(name) - && let Some(val) = rest.strip_prefix('=') - { - self.pos += 1; - return Ok(Some(val)); - } - - Ok(None) - } - /// Try to consume a flag with a value, silently returning None if /// the flag matches but no value is available (for lenient parsers). pub fn flag_value_opt(&mut self, name: &str) -> Option<&'a str> { @@ -232,26 +192,157 @@ impl<'a> ArgParser<'a> { .map(|s| s.starts_with('-') && s.len() > 1) .unwrap_or(false) } +} - /// Try to consume combined boolean short flags (e.g., `-rnuf`). - /// - /// If the current arg starts with `-` (not `--`), has length > 1, and - /// every character after `-` is in `allowed`, advances and returns the - /// matched chars. Otherwise returns an empty vec without advancing. - pub fn bool_flags(&mut self, allowed: &str) -> Vec { - if let Some(arg) = self.current() - && arg.starts_with('-') - && !arg.starts_with("--") - && arg.len() > 1 - { - let chars: Vec = arg[1..].chars().collect(); - if chars.iter().all(|c| allowed.contains(*c)) { - self.advance(); - return chars; +// Decision: `gnu_getopt` mirrors glibc `getopt_long` for builtins whose GNU +// counterparts accept bundled short options (`-sf1`), attached or separate +// values, `--long=VALUE`, unambiguous long prefixes, and (optionally) +// options after operands. Long options map onto a key char so callers +// handle `-d X` and `--delimiter=X` in one match arm. + +/// Whether an option takes an argument. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum OptArg { + No, + Required, + /// Only attached: `-xVAL` / `--long=VAL`. + Optional, +} + +/// One parsed option occurrence. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct ParsedOpt { + pub key: char, + pub value: Option, +} + +/// Parse `args` GNU-style. +/// +/// `short` uses getopt syntax: a letter, `:` after it for a required +/// value, `::` for an optional attached value. `longs` lists +/// `(name, arg, key)`. With `permute`, operands and options may interleave +/// (GNU default); without it, the first operand ends option parsing. +/// Errors are GNU-shaped messages with `code` as the exit status. +#[allow(clippy::result_large_err)] +pub(crate) fn gnu_getopt( + cmd: &str, + args: &[String], + short: &str, + longs: &[(&str, OptArg, char)], + permute: bool, + code: i32, +) -> std::result::Result<(Vec, Vec), crate::interpreter::ExecResult> { + use crate::interpreter::ExecResult; + let err = |msg: String| ExecResult::err(format!("{cmd}: {msg}\n"), code); + let short_arg = |c: char| -> Option { + let idx = short.find(c)?; + if c == ':' { + return None; + } + let rest = &short[idx + c.len_utf8()..]; + Some(if rest.starts_with("::") { + OptArg::Optional + } else if rest.starts_with(':') { + OptArg::Required + } else { + OptArg::No + }) + }; + + let mut opts = Vec::new(); + let mut operands = Vec::new(); + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + i += 1; + if arg == "--" { + operands.extend(args[i..].iter().cloned()); + break; + } + if arg == "-" || !arg.starts_with('-') { + operands.push(arg.clone()); + if !permute { + operands.extend(args[i..].iter().cloned()); + break; + } + continue; + } + if let Some(long) = arg.strip_prefix("--") { + let (name, inline) = match long.split_once('=') { + Some((n, v)) => (n, Some(v.to_string())), + None => (long, None), + }; + let hits: Vec<&(&str, OptArg, char)> = match longs.iter().find(|l| l.0 == name) { + Some(l) => vec![l], + None => longs.iter().filter(|l| l.0.starts_with(name)).collect(), + }; + let &(lname, kind, key) = match hits.as_slice() { + [one] => *one, + [] => return Err(err(format!("unrecognized option '--{name}'"))), + many => { + // Prefixes naming the same key are not ambiguous. + if many.iter().all(|l| l.2 == many[0].2 && l.1 == many[0].1) { + many[0] + } else { + return Err(err(format!("option '--{name}' is ambiguous"))); + } + } + }; + let value = match (kind, inline) { + (OptArg::No, Some(_)) => { + return Err(err(format!("option '--{lname}' doesn't allow an argument"))); + } + (OptArg::No, None) | (OptArg::Optional, None) => None, + (_, Some(v)) => Some(v), + (OptArg::Required, None) => { + if i < args.len() { + i += 1; + Some(args[i - 1].clone()) + } else { + return Err(err(format!("option '--{lname}' requires an argument"))); + } + } + }; + opts.push(ParsedOpt { key, value }); + continue; + } + let body = &arg[1..]; + for (pos, c) in body.char_indices() { + let Some(kind) = short_arg(c) else { + return Err(err(format!("invalid option -- '{c}'"))); + }; + let attached = &body[pos + c.len_utf8()..]; + match kind { + OptArg::No => opts.push(ParsedOpt { + key: c, + value: None, + }), + OptArg::Optional => { + opts.push(ParsedOpt { + key: c, + value: (!attached.is_empty()).then(|| attached.to_string()), + }); + break; + } + OptArg::Required => { + let value = if !attached.is_empty() { + attached.to_string() + } else if i < args.len() { + i += 1; + args[i - 1].clone() + } else { + return Err(err(format!("option requires an argument -- '{c}'"))); + }; + opts.push(ParsedOpt { + key: c, + value: Some(value), + }); + break; + } } } - Vec::new() } + Ok((opts, operands)) } #[cfg(test)] @@ -364,48 +455,6 @@ mod tests { assert!(p.flag_value_any(&["-o", "--output"], "cmd").is_err()); } - #[test] - fn test_long_value_attached() { - let a = args(&["--max-procs=4", "cmd"]); - let mut p = ArgParser::new(&a); - assert_eq!(p.long_value("--max-procs", "xargs").unwrap(), Some("4")); - assert_eq!(p.current(), Some("cmd")); - } - - #[test] - fn test_long_value_separate() { - let a = args(&["--process-slot-var", "SLOT", "cmd"]); - let mut p = ArgParser::new(&a); - assert_eq!( - p.long_value("--process-slot-var", "xargs").unwrap(), - Some("SLOT") - ); - assert_eq!(p.current(), Some("cmd")); - } - - #[test] - fn test_long_value_no_match() { - let a = args(&["-P", "4"]); - let mut p = ArgParser::new(&a); - assert_eq!(p.long_value("--max-procs", "xargs").unwrap(), None); - assert_eq!(p.current(), Some("-P")); - } - - #[test] - fn test_long_value_missing() { - let a = args(&["--max-procs"]); - let mut p = ArgParser::new(&a); - assert!(p.long_value("--max-procs", "xargs").is_err()); - } - - #[test] - fn test_long_value_attached_empty() { - // `--max-procs=` yields an empty value, not None. - let a = args(&["--max-procs="]); - let mut p = ArgParser::new(&a); - assert_eq!(p.long_value("--max-procs", "xargs").unwrap(), Some("")); - } - #[test] fn test_current() { let a = args(&["hello"]); @@ -433,48 +482,58 @@ mod tests { assert_eq!(p.rest().len(), 2); } - #[test] - fn test_bool_flags() { - let a = args(&["-rnuf", "file"]); - let mut p = ArgParser::new(&a); - let flags = p.bool_flags("rnufsz"); - assert_eq!(flags, vec!['r', 'n', 'u', 'f']); - assert_eq!(p.current(), Some("file")); - } - - #[test] - fn test_bool_flags_no_match_unknown_char() { - let a = args(&["-rxn", "file"]); - let mut p = ArgParser::new(&a); - let flags = p.bool_flags("rn"); // 'x' not allowed - assert!(flags.is_empty()); - assert_eq!(p.current(), Some("-rxn")); // not advanced + fn getopt(a: &[&str], permute: bool) -> (Vec<(char, Option)>, Vec) { + let (opts, ops) = gnu_getopt( + "t", + &args(a), + "ab:c::", + &[ + ("alpha", OptArg::No, 'a'), + ("bravo", OptArg::Required, 'b'), + ("charlie", OptArg::Optional, 'c'), + ], + permute, + 1, + ) + .unwrap(); + (opts.into_iter().map(|o| (o.key, o.value)).collect(), ops) } #[test] - fn test_bool_flags_long_flag_ignored() { - let a = args(&["--verbose"]); - let mut p = ArgParser::new(&a); - let flags = p.bool_flags("verbose"); - assert!(flags.is_empty()); + fn test_gnu_getopt_bundles_and_values() { + let (o, ops) = getopt(&["-ab1", "-b", "2", "-cX", "-c", "f"], true); + assert_eq!( + o, + vec![ + ('a', None), + ('b', Some("1".into())), + ('b', Some("2".into())), + ('c', Some("X".into())), + ('c', None) + ] + ); + assert_eq!(ops, vec!["f".to_string()]); } #[test] - fn test_bool_flags_single_dash_ignored() { - let a = args(&["-"]); - let mut p = ArgParser::new(&a); - let flags = p.bool_flags("abc"); - assert!(flags.is_empty()); - assert_eq!(p.current(), Some("-")); // not advanced + fn test_gnu_getopt_long_prefix_and_permute() { + let (o, ops) = getopt(&["x", "--br=v", "--alp", "--", "-a"], true); + assert_eq!(o, vec![('b', Some("v".into())), ('a', None)]); + assert_eq!(ops, vec!["x".to_string(), "-a".to_string()]); + let (o, ops) = getopt(&["x", "-a"], false); + assert!(o.is_empty()); + assert_eq!(ops, vec!["x".to_string(), "-a".to_string()]); } #[test] - fn test_bool_flags_single_char() { - let a = args(&["-v"]); - let mut p = ArgParser::new(&a); - let flags = p.bool_flags("v"); - assert_eq!(flags, vec!['v']); - assert!(p.is_done()); + fn test_gnu_getopt_errors() { + let e = gnu_getopt("t", &args(&["-z"]), "a", &[], true, 2).unwrap_err(); + assert_eq!(e.stderr, "t: invalid option -- 'z'\n"); + assert_eq!(e.exit_code, 2); + let e = gnu_getopt("t", &args(&["-b"]), "b:", &[], true, 1).unwrap_err(); + assert!(e.stderr.contains("requires an argument")); + let e = gnu_getopt("t", &args(&["--nope"]), "", &[], true, 1).unwrap_err(); + assert!(e.stderr.contains("unrecognized option '--nope'")); } #[test] diff --git a/crates/bashkit/src/builtins/base64.rs b/crates/bashkit/src/builtins/base64.rs index 510b6c2a3..3a7ec2d0c 100644 --- a/crates/bashkit/src/builtins/base64.rs +++ b/crates/bashkit/src/builtins/base64.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use base64::Engine; -use super::arg_parser::ArgParser; +use super::arg_parser::OptArg; use super::{Builtin, BuiltinHelper, Context}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -31,32 +31,43 @@ impl Builtin for Base64 { ) { return Ok(r); } + let (parsed, operands) = match super::arg_parser::gnu_getopt( + "base64", + ctx.args, + "diw:", + &[ + ("decode", OptArg::No, 'd'), + ("ignore-garbage", OptArg::No, 'i'), + ("wrap", OptArg::Required, 'w'), + ], + true, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; let mut decode = false; + let mut ignore_garbage = false; let mut wrap = 76usize; - let mut file: Option = None; - - let mut parser = ArgParser::new(ctx.args); - while !parser.is_done() { - if parser.flag_any(&["-d", "--decode"]) { - decode = true; - } else if let Some(val) = parser.current().and_then(|s| s.strip_prefix("--wrap=")) { - wrap = val.parse().unwrap_or(76); - parser.advance(); - } else if let Some(val) = match parser.flag_value("-w", "base64") { - Ok(v) => v, - Err(e) => return Ok(ExecResult::err(format!("{e}\n"), 1)), - } { - wrap = val.parse().unwrap_or(76); - } else if parser.flag_any(&["-i", "--ignore-garbage"]) { - // silently accept - } else if parser.is_flag() { - if let Some(s) = parser.positional() { - return Ok(Self::err(format!("invalid option -- '{}'", &s[1..]), 1)); + for o in parsed { + match o.key { + 'd' => decode = true, + 'i' => ignore_garbage = true, + _ => { + let val = o.value.unwrap_or_default(); + wrap = match val.parse() { + Ok(w) => w, + Err(_) => { + return Ok(Self::err(format!("invalid wrap size: '{val}'"), 1)); + } + }; } - } else if let Some(arg) = parser.positional() { - file = Some(arg.to_string()); } } + if operands.len() > 1 { + return Ok(Self::err(format!("extra operand '{}'", operands[1]), 1)); + } + let file = operands.into_iter().next(); // Byte streams and files share the same exact representation. let input = if let Some(ref path) = file { @@ -79,7 +90,13 @@ impl Builtin for Base64 { // Decode: strip ASCII whitespace, then decode. let cleaned: Vec = input .into_iter() - .filter(|byte| !byte.is_ascii_whitespace()) + .filter(|byte| { + if ignore_garbage { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=') + } else { + !byte.is_ascii_whitespace() + } + }) .collect(); match base64::engine::general_purpose::STANDARD.decode(&cleaned) { Ok(bytes) => Ok(ExecResult::ok_bytes(bytes)), @@ -88,19 +105,22 @@ impl Builtin for Base64 { } else { // Encode exact input bytes, including trailing newlines. let encoded = base64::engine::general_purpose::STANDARD.encode(input); + // GNU: a newline after every WRAP columns and after a final + // partial line; `-w 0` never wraps and adds no newline. let output = if wrap > 0 { - // Wrap at specified column width - let mut wrapped = String::new(); + let mut wrapped = String::with_capacity(encoded.len() * 2); for (i, ch) in encoded.chars().enumerate() { if i > 0 && i % wrap == 0 { wrapped.push('\n'); } wrapped.push(ch); } - wrapped.push('\n'); + if !encoded.is_empty() { + wrapped.push('\n'); + } wrapped } else { - format!("{encoded}\n") + encoded }; Ok(ExecResult::ok(output)) } @@ -158,7 +178,7 @@ mod tests { #[tokio::test] async fn test_encode_preserves_trailing_newline() { let result = run_base64(&["-w", "0"], Some("hello\n")).await; - assert_eq!(result.stdout, "aGVsbG8K\n"); + assert_eq!(result.stdout, "aGVsbG8K"); } #[tokio::test] @@ -175,7 +195,7 @@ mod tests { let result = Base64.execute(ctx).await.expect("base64 execute failed"); - assert_eq!(result.stdout, "/woK\n"); + assert_eq!(result.stdout, "/woK"); } #[tokio::test] diff --git a/crates/bashkit/src/builtins/comm.rs b/crates/bashkit/src/builtins/comm.rs index 94a0dd4d2..4def9d62b 100644 --- a/crates/bashkit/src/builtins/comm.rs +++ b/crates/bashkit/src/builtins/comm.rs @@ -1,7 +1,12 @@ //! comm builtin command - compare two sorted files line by line +//! +//! Decision: options follow GNU comm (`-123z`, `--output-delimiter`, +//! `--total`, `--zero-terminated`); `--check-order`/`--nocheck-order` are +//! accepted but order is never checked. use async_trait::async_trait; +use super::arg_parser::{OptArg, gnu_getopt}; use super::{Builtin, BuiltinHelper, Context, read_text_file}; use crate::error::Result; use crate::fs::vfs_join; @@ -9,12 +14,13 @@ use crate::interpreter::ExecResult; /// The comm builtin - compare two sorted files line by line. /// -/// Usage: comm [-123] FILE1 FILE2 +/// Usage: comm [-123z] [--output-delimiter=STR] [--total] FILE1 FILE2 /// /// Options: /// -1 Suppress lines unique to FILE1 /// -2 Suppress lines unique to FILE2 /// -3 Suppress lines that appear in both files +/// -z Line delimiter is NUL, not newline pub struct Comm; impl BuiltinHelper for Comm { @@ -22,47 +28,69 @@ impl BuiltinHelper for Comm { } struct CommOptions { - suppress_1: bool, - suppress_2: bool, - suppress_3: bool, + suppress: [bool; 3], + delimiter: String, + total: bool, + zero_terminated: bool, } #[allow(clippy::result_large_err)] fn parse_comm_args(args: &[String]) -> std::result::Result<(CommOptions, Vec), ExecResult> { + let (parsed, files) = gnu_getopt( + "comm", + args, + "123z", + &[ + ("check-order", OptArg::No, 'c'), + ("nocheck-order", OptArg::No, 'C'), + ("output-delimiter", OptArg::Required, 'o'), + ("total", OptArg::No, 't'), + ("zero-terminated", OptArg::No, 'z'), + ], + true, + 1, + )?; let mut opts = CommOptions { - suppress_1: false, - suppress_2: false, - suppress_3: false, + suppress: [false; 3], + delimiter: "\t".to_string(), + total: false, + zero_terminated: false, }; - let mut files = Vec::new(); - - for arg in args { - if arg.starts_with('-') && arg.len() > 1 && arg[1..].chars().all(|c| "123".contains(c)) { - for c in arg[1..].chars() { - match c { - '1' => opts.suppress_1 = true, - '2' => opts.suppress_2 = true, - '3' => opts.suppress_3 = true, - _ => {} - } + for o in parsed { + match o.key { + '1' => opts.suppress[0] = true, + '2' => opts.suppress[1] = true, + '3' => opts.suppress[2] = true, + 'z' => opts.zero_terminated = true, + 't' => opts.total = true, + 'o' => { + let d = o.value.unwrap_or_default(); + // GNU: an empty delimiter means a NUL byte. + opts.delimiter = if d.is_empty() { "\0".to_string() } else { d }; } - } else if arg.starts_with('-') && arg.len() > 1 && arg != "--" { - // Option-shaped token that isn't a -1/-2/-3 combination → reject. - return Err(super::invalid_option("comm", arg, 1)); - } else { - files.push(arg.clone()); + _ => {} } } - Ok((opts, files)) } +fn records(text: &str, sep: char) -> Vec { + if text.is_empty() { + return Vec::new(); + } + text.strip_suffix(sep) + .unwrap_or(text) + .split(sep) + .map(str::to_string) + .collect() +} + #[async_trait] impl Builtin for Comm { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = Self::check_help( ctx.args, - "Usage: comm [OPTION]... FILE1 FILE2\nCompare two sorted files line by line.\n\n -1\t\tsuppress column 1 (lines unique to FILE1)\n -2\t\tsuppress column 2 (lines unique to FILE2)\n -3\t\tsuppress column 3 (lines that appear in both files)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: comm [OPTION]... FILE1 FILE2\nCompare two sorted files line by line.\n\n -1\t\tsuppress column 1 (lines unique to FILE1)\n -2\t\tsuppress column 2 (lines unique to FILE2)\n -3\t\tsuppress column 3 (lines that appear in both files)\n --output-delimiter=STR\tseparate columns with STR\n --total\toutput a summary\n -z, --zero-terminated\tline delimiter is NUL, not newline\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("comm (bashkit) 0.1"), ) { return Ok(r); @@ -72,110 +100,89 @@ impl Builtin for Comm { Err(e) => return Ok(e), }; - if files.len() < 2 { - return Ok(Self::err("missing operand", 1)); + match files.len() { + 0 => return Ok(Self::err("missing operand", 1)), + 1 => { + return Ok(Self::err( + format!("missing operand after '{}'", files[0]), + 1, + )); + } + 2 => {} + _ => return Ok(Self::err(format!("extra operand '{}'", files[2]), 1)), } + let sep = if opts.zero_terminated { '\0' } else { '\n' }; - // Read both files - let lines1 = if files[0] == "-" { - ctx.stdin - .map(|s| s.lines().map(|l| l.to_string()).collect()) - .unwrap_or_default() - } else { - let path = if files[0].starts_with('/') { - std::path::PathBuf::from(&files[0]) + let mut inputs: Vec> = Vec::with_capacity(2); + for file in &files { + let text = if file == "-" { + ctx.stdin.map(ToString::to_string).unwrap_or_default() } else { - vfs_join(ctx.cwd, &files[0]) + let path = if file.starts_with('/') { + std::path::PathBuf::from(file) + } else { + vfs_join(ctx.cwd, file) + }; + match read_text_file(&*ctx.fs, &path, "comm").await { + Ok(text) => text, + Err(e) => return Ok(e), + } }; - match read_text_file(&*ctx.fs, &path, "comm").await { - Ok(text) => text.lines().map(|l| l.to_string()).collect(), - Err(e) => return Ok(e), - } - }; + inputs.push(records(&text, sep)); + } + let (lines1, lines2) = (&inputs[0], &inputs[1]); - let lines2 = if files[1] == "-" { - ctx.stdin - .map(|s| s.lines().map(|l| l.to_string()).collect()) - .unwrap_or_default() - } else { - let path = if files[1].starts_with('/') { - std::path::PathBuf::from(&files[1]) + let d = &opts.delimiter; + let prefixes = [ + String::new(), + if opts.suppress[0] { + String::new() } else { - vfs_join(ctx.cwd, &files[1]) - }; - match read_text_file(&*ctx.fs, &path, "comm").await { - Ok(text) => text.lines().map(|l| l.to_string()).collect(), - Err(e) => return Ok(e), - } - }; - - let lines1: Vec = lines1; - let lines2: Vec = lines2; - + d.clone() + }, + d.repeat(usize::from(!opts.suppress[0]) + usize::from(!opts.suppress[1])), + ]; + let mut counts = [0u64; 3]; let mut output = String::new(); - let mut i = 0; - let mut j = 0; - - // Determine column prefixes based on suppressed columns - let col1_prefix = ""; - let col2_prefix = if opts.suppress_1 { "" } else { "\t" }; - let col3_prefix = match (opts.suppress_1, opts.suppress_2) { - (false, false) => "\t\t", - (true, false) | (false, true) => "\t", - (true, true) => "", + let mut emit = |col: usize, line: &str, output: &mut String| { + counts[col] += 1; + if !opts.suppress[col] { + output.push_str(&prefixes[col]); + output.push_str(line); + output.push(sep); + } }; + let (mut i, mut j) = (0, 0); while i < lines1.len() && j < lines2.len() { match lines1[i].cmp(&lines2[j]) { std::cmp::Ordering::Less => { - // Only in file1 - if !opts.suppress_1 { - output.push_str(col1_prefix); - output.push_str(&lines1[i]); - output.push('\n'); - } + emit(0, &lines1[i], &mut output); i += 1; } std::cmp::Ordering::Greater => { - // Only in file2 - if !opts.suppress_2 { - output.push_str(col2_prefix); - output.push_str(&lines2[j]); - output.push('\n'); - } + emit(1, &lines2[j], &mut output); j += 1; } std::cmp::Ordering::Equal => { - // In both - if !opts.suppress_3 { - output.push_str(col3_prefix); - output.push_str(&lines1[i]); - output.push('\n'); - } + emit(2, &lines1[i], &mut output); i += 1; j += 1; } } } - - // Remaining lines from file1 - while i < lines1.len() { - if !opts.suppress_1 { - output.push_str(col1_prefix); - output.push_str(&lines1[i]); - output.push('\n'); - } - i += 1; + for line in &lines1[i..] { + emit(0, line, &mut output); + } + for line in &lines2[j..] { + emit(1, line, &mut output); } - // Remaining lines from file2 - while j < lines2.len() { - if !opts.suppress_2 { - output.push_str(col2_prefix); - output.push_str(&lines2[j]); - output.push('\n'); - } - j += 1; + if opts.total { + output.push_str(&format!( + "{}{d}{}{d}{}{d}total{sep}", + counts[0], counts[1], counts[2] + )); } Ok(ExecResult::ok(output)) @@ -370,4 +377,33 @@ mod tests { assert_eq!(result.exit_code, 1); assert!(result.stderr.contains("comm:")); } + + #[tokio::test] + async fn test_comm_zero_terminated() { + let result = run_comm( + &["-z", "/a", "/b"], + None, + &[("/a", b"a\0b\0"), ("/b", b"b\0c\0")], + ) + .await; + assert_eq!(result.stdout, "a\0\t\tb\0\tc\0"); + } + + #[tokio::test] + async fn test_comm_output_delimiter_and_total() { + let result = run_comm( + &["--output-delimiter=|", "--total", "/a", "/b"], + None, + &[("/a", b"a\nb\n"), ("/b", b"b\nc\n")], + ) + .await; + assert_eq!(result.stdout, "a\n||b\n|c\n1|1|1|total\n"); + } + + #[tokio::test] + async fn test_comm_extra_operand() { + let result = run_comm(&["/a", "/b", "/c"], None, &[]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("extra operand '/c'")); + } } diff --git a/crates/bashkit/src/builtins/cuttr.rs b/crates/bashkit/src/builtins/cuttr.rs index d171e584e..90c8f940a 100644 --- a/crates/bashkit/src/builtins/cuttr.rs +++ b/crates/bashkit/src/builtins/cuttr.rs @@ -53,53 +53,46 @@ impl Builtin for Cut { let mut only_delimited = false; let mut zero_terminated = false; let mut output_delimiter: Option = None; - let mut files = Vec::new(); - - // Parse arguments - let mut p = super::arg_parser::ArgParser::new(ctx.args); - while !p.is_done() { - if let Some(val) = p.flag_value_opt("-d") { - delimiter = val.chars().next().unwrap_or('\t'); - } else if let Some(val) = p.flag_value_opt("-f") { - spec = val.to_string(); - mode = CutMode::Fields; - } else if let Some(val) = p.flag_value_opt("-c") { - spec = val.to_string(); - mode = CutMode::Chars; - } else if let Some(val) = p.flag_value_opt("-b") { - spec = val.to_string(); - mode = CutMode::Chars; - } else if p.flag("-s") { - only_delimited = true; - } else if p.flag("-z") { - zero_terminated = true; - } else if p.flag("--complement") { - complement = true; - } else if let Some(val) = p - .current() - .and_then(|s| s.strip_prefix("--output-delimiter=")) - { - output_delimiter = Some(val.to_string()); - p.advance(); - } else if p.flag("--output-delimiter") { - if let Some(val) = p.positional() { - output_delimiter = Some(val.to_string()); + + use super::arg_parser::OptArg; + let (opts, files) = match super::arg_parser::gnu_getopt( + "cut", + ctx.args, + "b:c:d:f:nsz", + &[ + ("bytes", OptArg::Required, 'b'), + ("characters", OptArg::Required, 'c'), + ("complement", OptArg::No, 'C'), + ("delimiter", OptArg::Required, 'd'), + ("fields", OptArg::Required, 'f'), + ("only-delimited", OptArg::No, 's'), + ("output-delimiter", OptArg::Required, 'O'), + ("zero-terminated", OptArg::No, 'z'), + ], + true, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; + for o in opts { + let val = o.value.unwrap_or_default(); + match o.key { + // An empty delimiter means NUL, as in GNU cut. + 'd' => delimiter = val.chars().next().unwrap_or('\0'), + 'f' => { + spec = val; + mode = CutMode::Fields; } - } else if let Some(arg) = p.current().filter(|s| !s.starts_with('-')) { - files.push(arg.to_string()); - p.advance(); - } else if p.current() == Some("-") || p.current() == Some("--") { - // "-" stdin operand / "--" end-of-options: preserve handling. - p.advance(); - } else if p.is_flag() { - // Reject unknown options instead of dropping them silently. - return Ok(super::invalid_option( - "cut", - p.current().unwrap_or_default(), - 1, - )); - } else { - p.advance(); + 'b' | 'c' => { + spec = val; + mode = CutMode::Chars; + } + 's' => only_delimited = true, + 'z' => zero_terminated = true, + 'C' => complement = true, + 'O' => output_delimiter = Some(val), + _ => {} // -n: no-op, as in GNU cut } } @@ -133,9 +126,9 @@ impl Builtin for Cut { Some(selected.into_iter().collect()) } CutMode::Fields => { - // -s: skip lines without delimiter - if only_delimited && !line.contains(delimiter) { - return None; + // Lines without a delimiter print whole unless -s. + if !line.contains(delimiter) { + return (!only_delimited).then(|| line.to_string()); } let parts: Vec<&str> = line.split(delimiter).collect(); let total = parts.len(); @@ -163,10 +156,11 @@ impl Builtin for Cut { 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; - } + let body = text.strip_suffix(line_sep).unwrap_or(text); + if text.is_empty() { + return; + } + for line in body.split(line_sep) { if let Some(result) = process_line(line) { output.push_str(&result); output.push_str(out_sep); @@ -312,31 +306,31 @@ impl Builtin for Tr { let mut delete = false; let mut squeeze = false; let mut complement = false; + let mut truncate = false; - // Parse flags (can be combined like -ds, -cd) - let mut non_flag_args: Vec = Vec::new(); - let mut p = super::arg_parser::ArgParser::new(ctx.args); - while !p.is_done() { - let flags = p.bool_flags("dscC"); - if !flags.is_empty() { - for ch in flags { - match ch { - 'd' => delete = true, - 's' => squeeze = true, - 'c' | 'C' => complement = true, - _ => {} - } - } - } else if non_flag_args.is_empty() && p.is_flag() && p.current() != Some("--") { - // Reject unknown options only in the option phase (before any - // SET is read); once SETs are collected a leading `-` is an operand. - return Ok(super::invalid_option( - "tr", - p.current().unwrap_or_default(), - 1, - )); - } else if let Some(arg) = p.positional() { - non_flag_args.push(arg.to_string()); + // GNU tr stops option parsing at the first operand ("+cCdst"). + let (opts, non_flag_args) = match super::arg_parser::gnu_getopt( + "tr", + ctx.args, + "cCdst", + &[ + ("complement", super::arg_parser::OptArg::No, 'c'), + ("delete", super::arg_parser::OptArg::No, 'd'), + ("squeeze-repeats", super::arg_parser::OptArg::No, 's'), + ("truncate-set1", super::arg_parser::OptArg::No, 't'), + ], + false, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; + for o in opts { + match o.key { + 'd' => delete = true, + 's' => squeeze = true, + 't' => truncate = true, + _ => complement = true, } } @@ -355,6 +349,7 @@ impl Builtin for Tr { delete, squeeze, complement, + truncate, c_locale: ctx.env.get("LC_ALL").is_some_and(|locale| locale == "C"), }; @@ -397,6 +392,8 @@ struct TrSpec<'a> { delete: bool, squeeze: bool, complement: bool, + /// `-t`: truncate SET1 to the length of SET2 instead of extending SET2. + truncate: bool, /// `LC_ALL=C`: work on bytes, not characters. c_locale: bool, } @@ -407,6 +404,9 @@ impl TrSpec<'_> { let (non_flag_args, delete, squeeze, complement) = (self.args, self.delete, self.squeeze, self.complement); let mut set1 = expand_char_set(&non_flag_args[0])?; + if self.truncate && !delete && !complement && non_flag_args.len() >= 2 { + set1.truncate(expand_char_set(&non_flag_args[1])?.len()); + } let byte_mode = self.c_locale || stdin.text().is_err(); if byte_mode && set1.iter().all(|c| (*c as u32) <= u8::MAX as u32) { let set2 = if non_flag_args.len() >= 2 { @@ -548,124 +548,103 @@ fn squeeze_chars(s: &str, set: &[char]) -> String { result } +/// Decode GNU `tr` backslash escapes into `(char, escaped)` tokens so an +/// escaped `-`, `[` or `:` never acts as an operator. +fn tokenize_tr_set(spec: &str) -> Vec<(char, bool)> { + let chars: Vec = spec.chars().collect(); + let mut out = Vec::with_capacity(chars.len()); + let mut i = 0; + while i < chars.len() { + let c = chars[i]; + if c != '\\' || i + 1 == chars.len() { + // A trailing backslash stands for itself. + out.push((c, c == '\\')); + i += 1; + continue; + } + let n = chars[i + 1]; + i += 2; + let decoded = match n { + 'a' => '\x07', + 'b' => '\x08', + 'f' => '\x0c', + 'n' => '\n', + 'r' => '\r', + 't' => '\t', + 'v' => '\x0b', + '0'..='7' => { + // Up to three octal digits, never exceeding \377. + let mut val = n.to_digit(8).unwrap_or(0); + let mut digits = 1; + while digits < 3 && i < chars.len() { + let Some(d) = chars[i].to_digit(8) else { break }; + if val * 8 + d > 0o377 { + break; + } + val = val * 8 + d; + digits += 1; + i += 1; + } + char::from_u32(val).unwrap_or('\0') + } + other => other, + }; + out.push((decoded, true)); + } + out +} + /// Expand a character set specification like "a-z" into a list of characters. -/// Supports POSIX character classes: [:lower:], [:upper:], [:digit:], [:alpha:], [:alnum:], [:space:] +/// Supports backslash escapes (`\n`, `\NNN` octal, ...), ranges, `[=c=]` +/// and POSIX character classes like `[:lower:]`. fn expand_char_set(spec: &str) -> std::result::Result, String> { let mut chars = Vec::new(); - let char_vec: Vec = spec.chars().collect(); - let len = char_vec.len(); + let toks = tokenize_tr_set(spec); + let len = toks.len(); + let plain = |i: usize, c: char| i < len && toks[i] == (c, false); let mut i = 0; while i < len { - // Check for POSIX character class [:class:] - if char_vec[i] == '[' - && i + 1 < len - && char_vec[i + 1] == ':' - && let Some(end) = spec[spec - .char_indices() - .nth(i + 2) - .map_or(spec.len(), |(pos, _)| pos)..] - .find(":]") - { - let class_start = spec - .char_indices() - .nth(i + 2) - .map_or(spec.len(), |(pos, _)| pos); - let class_name = &spec[class_start..class_start + end]; - match class_name { - "lower" => push_char_range(&mut chars, 'a', 'z')?, - "upper" => push_char_range(&mut chars, 'A', 'Z')?, - "digit" => push_char_range(&mut chars, '0', '9')?, - "alpha" => { - push_char_range(&mut chars, 'a', 'z')?; - push_char_range(&mut chars, 'A', 'Z')?; - } - "alnum" => { - push_char_range(&mut chars, 'a', 'z')?; - push_char_range(&mut chars, 'A', 'Z')?; - push_char_range(&mut chars, '0', '9')?; - } - "space" => push_chars(&mut chars, [' ', '\t', '\n', '\r', '\x0b', '\x0c'])?, - "blank" => push_chars(&mut chars, [' ', '\t'])?, - "punct" => { - for code in 0x21u8..=0x7e { - let c = code as char; - if !c.is_ascii_alphanumeric() { + // POSIX character class [:class:] or equivalence class [=c=] + if plain(i, '[') && (plain(i + 1, ':') || plain(i + 1, '=')) { + let delim = toks[i + 1].0; + let close = + (i + 2..len.saturating_sub(1)).find(|&j| plain(j, delim) && plain(j + 1, ']')); + if let Some(close) = close { + let name: String = toks[i + 2..close].iter().map(|t| t.0).collect(); + let known = if delim == '=' { + let mut it = name.chars(); + match (it.next(), it.next()) { + (Some(c), None) => { push_char(&mut chars, c)?; + true } + _ => false, } - } - "xdigit" => { - push_char_range(&mut chars, '0', '9')?; - push_char_range(&mut chars, 'A', 'F')?; - push_char_range(&mut chars, 'a', 'f')?; - } - "print" => { - for code in 0x20u8..=0x7e { - push_char(&mut chars, code as char)?; - } - } - "graph" => { - for code in 0x21u8..=0x7e { - push_char(&mut chars, code as char)?; - } - } - "cntrl" => { - for code in 0u8..=0x1f { - push_char(&mut chars, code as char)?; - } - push_char(&mut chars, 0x7f as char)?; - } - _ => { - push_char(&mut chars, '[')?; - i += 1; + } else { + push_char_class(&mut chars, &name)? + }; + if known { + i = close + 2; continue; } } - // Count chars in the class spec to advance properly - let class_char_count = class_name.chars().count(); - i += 2 + class_char_count + 2; // skip past [: + class + :] - continue; } - let c = char_vec[i]; - // Check for range like a-z - if i + 2 < len && char_vec[i + 1] == '-' { - let end_char = char_vec[i + 2]; + let c = toks[i].0; + // Range like a-z (endpoints may be escapes) + if i + 2 < len && plain(i + 1, '-') { + let end_char = toks[i + 2].0; + if (end_char as u32) < (c as u32) { + return Err(format!( + "range-endpoints of '{}-{}' are in reverse collating sequence order", + c.escape_default(), + end_char.escape_default() + )); + } push_char_range(&mut chars, c, end_char)?; i += 3; - } else if i + 1 == len - 1 && char_vec[i + 1] == '-' { - // Trailing dash - push_char(&mut chars, c)?; - push_char(&mut chars, '-')?; - i += 2; } else { - // Handle escape sequences - if c == '\\' && i + 1 < len { - match char_vec[i + 1] { - 'n' => { - push_char(&mut chars, '\n')?; - i += 2; - continue; - } - 't' => { - push_char(&mut chars, '\t')?; - i += 2; - continue; - } - '0' => { - push_char(&mut chars, '\0')?; - i += 2; - continue; - } - '\\' => { - push_char(&mut chars, '\\')?; - i += 2; - continue; - } - _ => {} - } - } push_char(&mut chars, c)?; i += 1; } @@ -674,6 +653,57 @@ fn expand_char_set(spec: &str) -> std::result::Result, String> { Ok(chars) } +/// Push a POSIX class's members; `false` for an unknown class name. +fn push_char_class(chars: &mut Vec, class_name: &str) -> std::result::Result { + match class_name { + "lower" => push_char_range(chars, 'a', 'z')?, + "upper" => push_char_range(chars, 'A', 'Z')?, + "digit" => push_char_range(chars, '0', '9')?, + "alpha" => { + push_char_range(chars, 'a', 'z')?; + push_char_range(chars, 'A', 'Z')?; + } + "alnum" => { + push_char_range(chars, 'a', 'z')?; + push_char_range(chars, 'A', 'Z')?; + push_char_range(chars, '0', '9')?; + } + "space" => push_chars(chars, [' ', '\t', '\n', '\r', '\x0b', '\x0c'])?, + "blank" => push_chars(chars, [' ', '\t'])?, + "punct" => { + for code in 0x21u8..=0x7e { + let c = code as char; + if !c.is_ascii_alphanumeric() { + push_char(chars, c)?; + } + } + } + "xdigit" => { + push_char_range(chars, '0', '9')?; + push_char_range(chars, 'A', 'F')?; + push_char_range(chars, 'a', 'f')?; + } + "print" => { + for code in 0x20u8..=0x7e { + push_char(chars, code as char)?; + } + } + "graph" => { + for code in 0x21u8..=0x7e { + push_char(chars, code as char)?; + } + } + "cntrl" => { + for code in 0u8..=0x1f { + push_char(chars, code as char)?; + } + push_char(chars, 0x7f as char)?; + } + _ => return Ok(false), + } + Ok(true) +} + fn push_char(chars: &mut Vec, ch: char) -> std::result::Result<(), String> { if chars.len() >= MAX_TR_EXPANDED_SET_CHARS { return Err("character set expansion too large".to_string()); @@ -1089,4 +1119,38 @@ mod tests { assert_eq!(result.exit_code, 1); assert!(result.stderr.contains("invalid option -- 'Q'")); } + + #[test] + fn test_expand_char_set_escapes() { + assert_eq!(expanded("\\101"), vec!['A']); + assert_eq!(expanded("\\1018"), vec!['A', '8']); + assert_eq!(expanded("\\0-\\2"), vec!['\0', '\x01', '\x02']); + assert_eq!(expanded("a\\-"), vec!['a', '-']); + assert_eq!(expanded("x\\"), vec!['x', '\\']); + assert_eq!(expanded("[=a=]"), vec!['a']); + assert!(expand_char_set("z-a").is_err()); + } + + #[tokio::test] + async fn test_tr_truncate_and_long_complement() { + let result = run_tr(&["-t", "abcd", "xy"], Some("abcd\n")).await; + assert_eq!(result.stdout, "xycd\n"); + let result = run_tr(&["--complement", "-s", "a"], Some("aabbc\n")).await; + assert_eq!(result.stdout, "aabc\n"); + } + + #[tokio::test] + async fn test_cut_bundled_and_long_options() { + let result = run_cut(&["-sf1"], Some("a\tb\nc\n")).await; + assert_eq!(result.stdout, "a\n"); + let result = run_cut( + &["--delimiter=:", "--fields=2", "--complement"], + Some("a:b\n"), + ) + .await; + assert_eq!(result.stdout, "a\n"); + // Lines without the delimiter print whole; empty lines are kept. + let result = run_cut(&["-d:", "-f2"], Some("abc\n\nx:y\n")).await; + assert_eq!(result.stdout, "abc\n\ny\n"); + } } diff --git a/crates/bashkit/src/builtins/date.rs b/crates/bashkit/src/builtins/date.rs index dbf556f40..9de9d6398 100644 --- a/crates/bashkit/src/builtins/date.rs +++ b/crates/bashkit/src/builtins/date.rs @@ -8,6 +8,8 @@ //! caught and return graceful errors. use async_trait::async_trait; + +use super::arg_parser::OptArg; use chrono::format::{Item, StrftimeItems}; use chrono::{DateTime, Duration, LocalResult, NaiveDate, NaiveDateTime, TimeZone, Utc}; #[cfg(feature = "tzdata")] @@ -524,51 +526,46 @@ impl Builtin for Date { let mut rfc2822 = false; let mut iso8601: Option = None; - let mut p = super::arg_parser::ArgParser::new(ctx.args); - while !p.is_done() { - if p.flag_any(&["-u", "--utc"]) { - utc = true; - } else if let Some(val) = p.current().and_then(|s| s.strip_prefix("--date=")) { - date_str = Some(strip_surrounding_quotes(val).to_string()); - p.advance(); - } else if let Some(val) = p.flag_value_opt("-d") { - date_str = Some(val.to_string()); - } else if p.flag("--date") { - if let Some(val) = p.positional() { - date_str = Some(val.to_string()); - } - } else if let Some(val) = p.current().and_then(|s| s.strip_prefix("--reference=")) { - ref_file = Some(val.to_string()); - p.advance(); - } else if let Some(val) = p.flag_value_opt("-r") { - ref_file = Some(val.to_string()); - } else if p.flag("--reference") { - if let Some(val) = p.positional() { - ref_file = Some(val.to_string()); + // GNU getopt surface: bundles (`-ud@0`), attached values, long + // options with `=` or a separate value, unambiguous prefixes. + let (parsed, operands) = match super::arg_parser::gnu_getopt( + "date", + ctx.args, + "d:r:uRI::", + &[ + ("date", OptArg::Required, 'd'), + ("reference", OptArg::Required, 'r'), + ("utc", OptArg::No, 'u'), + ("universal", OptArg::No, 'u'), + ("rfc-2822", OptArg::No, 'R'), + ("rfc-email", OptArg::No, 'R'), + ("iso-8601", OptArg::Optional, 'I'), + ], + true, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; + for o in parsed { + let val = o.value.unwrap_or_default(); + match o.key { + 'd' => date_str = Some(strip_surrounding_quotes(&val).to_string()), + 'r' => ref_file = Some(val), + 'u' => utc = true, + 'R' => rfc2822 = true, + _ => { + iso8601 = Some(if val.is_empty() { + "date".to_string() + } else { + val + }) } - } else if p.flag_any(&["-R", "--rfc-2822", "--rfc-email"]) { - rfc2822 = true; - } else if let Some(val) = p.current().and_then(|s| s.strip_prefix("--iso-8601=")) { - iso8601 = Some(val.to_string()); - p.advance(); - } else if p.flag_any(&["-I", "--iso-8601"]) { - iso8601 = Some("date".to_string()); - } else if let Some(val) = p.current().and_then(|s| s.strip_prefix("-I")) { - iso8601 = Some(val.to_string()); - p.advance(); - } else if let Some(arg) = p.current().filter(|s| s.starts_with('+')) { - format_arg = Some(arg.to_string()); - p.advance(); - } else if let Some(arg) = p - .current() - .filter(|s| s.starts_with('-') && s.len() > 1 && *s != "--") - { - // Unknown option-shaped token → reject (date exits 1). - return Ok(super::invalid_option("date", arg, 1)); - } else { - p.advance(); } } + if let Some(fmt) = operands.iter().find(|s| s.starts_with('+')) { + format_arg = Some(fmt.clone()); + } // THREAT[TM-INF-018]: Resolve only the virtual environment's TZ. let selected_timezone = match SandboxTimezone::from_env(ctx.env.get("TZ")) { diff --git a/crates/bashkit/src/builtins/disk.rs b/crates/bashkit/src/builtins/disk.rs index 52d4c9518..e6c175269 100644 --- a/crates/bashkit/src/builtins/disk.rs +++ b/crates/bashkit/src/builtins/disk.rs @@ -1,8 +1,15 @@ //! Disk usage builtins - du and df +//! +//! Decision: `du` follows GNU's option surface and output shape (operand- +//! relative paths, directories only unless `-a`, `-d`/`-s` depth limits, +//! `-c` total, GNU ceiling rounding for `-h`/`--si`/`-B`). The VFS has no +//! block allocation, so "disk usage" is the apparent size; without `-b` it +//! is reported in ceil(bytes / 1024) blocks. use async_trait::async_trait; use std::path::Path; +use super::arg_parser::{OptArg, gnu_getopt}; use super::{Builtin, Context}; use crate::error::Result; use crate::fs::vfs_join; @@ -10,171 +17,288 @@ use crate::interpreter::ExecResult; /// The du builtin - estimate file space usage. /// -/// Usage: du [-s] [-h] [FILE...] -/// -/// Options: -/// -s Display only a total for each argument -/// -h Print sizes in human readable format +/// Usage: du [-abchkms] [-d N] [-B SIZE] [--si] [FILE...] /// /// If no FILE is specified, shows usage for current directory. pub struct Du; +#[derive(Clone, Copy, PartialEq)] +enum DuScale { + Blocks(u64), + Human(u64), +} + +struct DuOptions { + all: bool, + total: bool, + max_depth: Option, + scale: DuScale, +} + +fn parse_block_size(s: &str) -> Option { + let digits = s.bytes().take_while(u8::is_ascii_digit).count(); + let n: u64 = if digits == 0 { + 1 + } else { + s[..digits].parse().ok()? + }; + let unit = match &s[digits..] { + "" => 1, + u => { + let (letter, rest) = u.split_at(1); + let power = "KMGTPEZY".find(&letter.to_ascii_uppercase())? as u32 + 1; + let base: u64 = match rest { + "" | "iB" => 1024, + "B" => 1000, + _ => return None, + }; + base.checked_pow(power)? + } + }; + n.checked_mul(unit).filter(|v| *v > 0) +} + +#[allow(clippy::result_large_err)] +fn parse_du_args(args: &[String]) -> std::result::Result<(DuOptions, Vec), ExecResult> { + let (parsed, paths) = gnu_getopt( + "du", + args, + "abcd:hkmsB:LPxSl0", + &[ + ("all", OptArg::No, 'a'), + ("apparent-size", OptArg::No, 'A'), + ("bytes", OptArg::No, 'b'), + ("total", OptArg::No, 'c'), + ("max-depth", OptArg::Required, 'd'), + ("human-readable", OptArg::No, 'h'), + ("si", OptArg::No, 'H'), + ("summarize", OptArg::No, 's'), + ("block-size", OptArg::Required, 'B'), + ("dereference", OptArg::No, 'L'), + ("no-dereference", OptArg::No, 'P'), + ("one-file-system", OptArg::No, 'x'), + ("separate-dirs", OptArg::No, 'S'), + ("count-links", OptArg::No, 'l'), + ("null", OptArg::No, '0'), + ], + true, + 1, + )?; + let mut opts = DuOptions { + all: false, + total: false, + max_depth: None, + scale: DuScale::Blocks(1024), + }; + let mut summarize = false; + for o in parsed { + match o.key { + 'a' => opts.all = true, + 'b' => opts.scale = DuScale::Blocks(1), + 'c' => opts.total = true, + 'd' => { + let v = o.value.unwrap_or_default(); + match v.parse() { + Ok(n) => opts.max_depth = Some(n), + Err(_) => { + return Err(ExecResult::err( + format!("du: invalid maximum depth '{v}'\n"), + 1, + )); + } + } + } + 'h' => opts.scale = DuScale::Human(1024), + 'H' => opts.scale = DuScale::Human(1000), + 'k' => opts.scale = DuScale::Blocks(1024), + 'm' => opts.scale = DuScale::Blocks(1024 * 1024), + 's' => summarize = true, + 'B' => { + let v = o.value.unwrap_or_default(); + match parse_block_size(&v) { + Some(n) => opts.scale = DuScale::Blocks(n), + None => { + return Err(ExecResult::err( + format!("du: invalid -B argument '{v}'\n"), + 1, + )); + } + } + } + // Accepted for compatibility: the VFS has no devices or hard + // links to count, and sizes are always apparent sizes. + _ => {} + } + } + if summarize { + if opts.max_depth.is_some_and(|d| d != 0) { + return Err(ExecResult::err( + "du: cannot both summarize and show all entries\n".to_string(), + 1, + )); + } + opts.max_depth = Some(0); + } + if summarize && opts.all { + return Err(ExecResult::err( + "du: cannot both summarize and show all entries\n".to_string(), + 1, + )); + } + Ok((opts, paths)) +} + +/// GNU `human_readable` with ceiling rounding: one decimal below 10, +/// whole units otherwise, plain bytes below one unit. +fn human_size(n: u64, base: u64) -> String { + const SUFFIX: &[u8] = b"KMGTPEZY"; + let base = u128::from(base); + let n128 = u128::from(n); + if n128 < base { + return n.to_string(); + } + let mut e = 0usize; + let mut div = 1u128; + while e < SUFFIX.len() && n128 >= div * base { + div *= base; + e += 1; + } + let suffix = |e: usize| { + let c = SUFFIX[e - 1] as char; + if base == 1000 && c == 'K' { 'k' } else { c } + }; + let tenths = (n128 * 10).div_ceil(div); + if tenths < 100 { + return format!("{}.{}{}", tenths / 10, tenths % 10, suffix(e)); + } + let whole = n128.div_ceil(div); + if whole >= base && e < SUFFIX.len() { + return format!("1.0{}", suffix(e + 1)); + } + format!("{whole}{}", suffix(e)) +} + +fn format_size(bytes: u64, scale: DuScale) -> String { + match scale { + DuScale::Blocks(1) => bytes.to_string(), + DuScale::Blocks(n) => bytes.div_ceil(n).to_string(), + DuScale::Human(base) => human_size(bytes, base), + } +} + #[async_trait] impl Builtin for Du { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = super::check_help_version( ctx.args, - "Usage: du [-s] [-h] [FILE...]\n\ + "Usage: du [OPTION]... [FILE]...\n\ Estimate file space usage.\n\n\ - \x20 -s\tdisplay only a total for each argument\n\ - \x20 -h\tprint sizes in human readable format\n\ + \x20 -a, --all\twrite counts for all files, not just directories\n\ + \x20 -b, --bytes\tprint sizes in bytes\n\ + \x20 -B, --block-size=SIZE\tscale sizes by SIZE\n\ + \x20 -c, --total\tproduce a grand total\n\ + \x20 -d, --max-depth=N\tprint totals only N levels deep\n\ + \x20 -h, --human-readable\tprint sizes in human readable format\n\ + \x20 --si\tlike -h, but use powers of 1000\n\ + \x20 -k\tlike --block-size=1K\n\ + \x20 -m\tlike --block-size=1M\n\ + \x20 -s, --summarize\tdisplay only a total for each argument\n\ \x20 --help\tdisplay this help and exit\n\ \x20 --version\toutput version information and exit\n", Some("du (bashkit) 0.1"), ) { return Ok(r); } - let mut summary_only = false; - let mut human_readable = false; - let mut paths: Vec = Vec::new(); - - for arg in ctx.args { - if arg.starts_with('-') && arg.len() > 1 { - for c in arg[1..].chars() { - match c { - 's' => summary_only = true, - 'h' => human_readable = true, - _ => { - return Ok(ExecResult::err( - format!("du: invalid option -- '{}'\n", c), - 1, - )); - } - } - } - } else { - paths.push(arg.clone()); - } - } - - // Default to current directory + let (opts, mut paths) = match parse_du_args(ctx.args) { + Ok(v) => v, + Err(e) => return Ok(e), + }; if paths.is_empty() { paths.push(".".to_string()); } let mut output = String::new(); - - for path_str in &paths { - let path = if path_str.starts_with('/') { - std::path::PathBuf::from(path_str) - } else if path_str == "." { - ctx.cwd.clone() + let mut stderr = String::new(); + let mut grand_total = 0u64; + for operand in &paths { + let path = if operand.starts_with('/') { + std::path::PathBuf::from(operand) } else { - vfs_join(ctx.cwd, path_str) + vfs_join(ctx.cwd, operand) }; - - match calculate_size(&ctx, &path, summary_only, human_readable).await { - Ok(result) => output.push_str(&result), - Err(e) => { - return Ok(ExecResult::err(format!("du: {}: {}\n", path_str, e), 1)); - } + if ctx.fs.stat(&path).await.is_err() { + stderr.push_str(&format!( + "du: cannot access '{operand}': No such file or directory\n" + )); + continue; } + grand_total += walk(&ctx, &path, operand, 0, &opts, &mut output).await?; + } + if opts.total { + output.push_str(&format!( + "{}\ttotal\n", + format_size(grand_total, opts.scale) + )); } - Ok(ExecResult::ok(output)) - } -} - -/// Calculate size of a path recursively. -async fn calculate_size( - ctx: &Context<'_>, - path: &Path, - summary_only: bool, - human_readable: bool, -) -> Result { - let mut output = String::new(); - let total = - calculate_size_recursive(ctx, path, summary_only, human_readable, &mut output).await?; - - if summary_only { - let size_str = format_size(total, human_readable); - output.push_str(&format!("{}\t{}\n", size_str, path.display())); + Ok(ExecResult { + stdout: output.into(), + exit_code: i32::from(!stderr.is_empty()), + stderr: stderr.into(), + ..Default::default() + }) } - - Ok(output) } -/// Recursively calculate size. -fn calculate_size_recursive<'a>( +/// Sum `path` (displayed as `display`), printing entries GNU-style. +fn walk<'a>( ctx: &'a Context<'_>, path: &'a Path, - summary_only: bool, - human_readable: bool, + display: &'a str, + depth: usize, + opts: &'a DuOptions, output: &'a mut String, ) -> std::pin::Pin> + Send + 'a>> { Box::pin(async move { ctx.consume_budget_work(1)?; - let metadata = ctx.fs.stat(path).await?; - - if metadata.file_type.is_file() { - if !summary_only { - let size_str = format_size(metadata.size, human_readable); - output.push_str(&format!("{}\t{}\n", size_str, path.display())); + // Operands follow symlinks (GNU stats command-line args); nested + // entries count the link itself. + let metadata = if depth == 0 { + ctx.fs.stat(path).await? + } else { + ctx.fs.lstat(path).await? + }; + let shown = opts.max_depth.is_none_or(|d| depth <= d); + + if !metadata.file_type.is_dir() { + if shown && (opts.all || depth == 0) { + output.push_str(&format!( + "{}\t{display}\n", + format_size(metadata.size, opts.scale) + )); } return Ok(metadata.size); } - if metadata.file_type.is_dir() { - let mut total = 0u64; - let entries = ctx.fs.read_dir(path).await?; - ctx.consume_budget_work(u64::try_from(entries.len()).unwrap_or(u64::MAX))?; - - for entry in entries { - let child_path = vfs_join(path, &entry.name); - total += calculate_size_recursive( - ctx, - &child_path, - summary_only, - human_readable, - output, - ) - .await?; - } - - if !summary_only { - let size_str = format_size(total, human_readable); - output.push_str(&format!("{}\t{}\n", size_str, path.display())); - } - - return Ok(total); + let mut total = 0u64; + let entries = ctx.fs.read_dir(path).await?; + ctx.consume_budget_work(u64::try_from(entries.len()).unwrap_or(u64::MAX))?; + for entry in entries { + let child_path = vfs_join(path, &entry.name); + let child_display = if display.ends_with('/') { + format!("{display}{}", entry.name) + } else { + format!("{display}/{}", entry.name) + }; + total += walk(ctx, &child_path, &child_display, depth + 1, opts, output).await?; } - - Ok(0) + if shown { + output.push_str(&format!("{}\t{display}\n", format_size(total, opts.scale))); + } + Ok(total) }) } -/// Format size in bytes or human-readable format. -fn format_size(bytes: u64, human_readable: bool) -> String { - if !human_readable { - // Return size in 1K blocks (like real du) - return format!("{}", bytes.div_ceil(1024)); - } - - const KB: u64 = 1024; - const MB: u64 = 1024 * KB; - const GB: u64 = 1024 * MB; - - if bytes >= GB { - format!("{:.1}G", bytes as f64 / GB as f64) - } else if bytes >= MB { - format!("{:.1}M", bytes as f64 / MB as f64) - } else if bytes >= KB { - format!("{:.1}K", bytes as f64 / KB as f64) - } else { - format!("{}B", bytes) - } -} - /// The df builtin - report file system disk space usage. /// /// Usage: df [-h] @@ -241,9 +365,9 @@ impl Builtin for Df { // Data row let (total_str, used_str, avail_str) = if human_readable { ( - format_size(total, true), - format_size(used, true), - format_size(available, true), + format_size(total, DuScale::Human(1024)), + format_size(used, DuScale::Human(1024)), + format_size(available, DuScale::Human(1024)), ) } else { ( @@ -313,7 +437,7 @@ mod tests { let result = Du.execute(ctx).await.unwrap(); assert_eq!(result.exit_code, 0); assert!( - result.stdout.contains("/home/user/proj/src/main.rs"), + result.stdout.contains("\tproj/src\n"), "recursive descent path is not slash-separated:\n{}", result.stdout ); @@ -396,6 +520,63 @@ mod tests { assert_ne!(result.exit_code, 0); } + async fn run_du(args: &[&str], files: &[(&str, usize)]) -> ExecResult { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + fs.mkdir(&cwd.join("d/sub"), true).await.unwrap(); + for (name, size) in files { + fs.write_file(&cwd.join(name), &vec![b'x'; *size]) + .await + .unwrap(); + } + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None); + Du.execute(ctx).await.unwrap() + } + + #[test] + fn test_du_human_size_gnu_rounding() { + let cases = [ + (1, "1"), + (1023, "1023"), + (1024, "1.0K"), + (1025, "1.1K"), + (10239, "10K"), + (10241, "11K"), + (1047553, "1.0M"), + (1048577, "1.1M"), + (5000000, "4.8M"), + ]; + for (n, want) in cases { + assert_eq!(human_size(n, 1024), want, "n={n}"); + } + assert_eq!(human_size(1500, 1000), "1.5k"); + } + + #[tokio::test] + async fn test_du_operand_paths_and_all() { + let files = [("d/a", 10), ("d/sub/b", 2000)]; + let result = run_du(&["-b", "d"], &files).await; + assert_eq!(result.stdout, "2000\td/sub\n2010\td\n"); + let result = run_du(&["-ab", "d"], &files).await; + // Children come in read_dir order; the directory follows its children. + let mut lines: Vec<&str> = result.stdout.lines().collect(); + assert_eq!(lines.pop(), Some("2010\td")); + lines.sort_unstable(); + assert_eq!(lines, ["10\td/a", "2000\td/sub", "2000\td/sub/b"]); + } + + #[tokio::test] + async fn test_du_depth_total_and_missing() { + let files = [("d/a", 10), ("d/sub/b", 2000)]; + let result = run_du(&["-d0", "-c", "d/sub", "d/a"], &files).await; + assert_eq!(result.stdout, "2\td/sub\n1\td/a\n2\ttotal\n"); + let result = run_du(&["-s", "d", "nope"], &files).await; + assert_eq!(result.exit_code, 1); + assert_eq!(result.stdout, "2\td\n"); + assert!(result.stderr.contains("cannot access 'nope'")); + } + // ==================== df tests ==================== #[tokio::test] @@ -450,34 +631,37 @@ mod tests { // ==================== format_size tests ==================== + const H: DuScale = DuScale::Human(1024); + const K: DuScale = DuScale::Blocks(1024); + #[test] fn test_format_size_bytes() { - assert_eq!(format_size(500, true), "500B"); - assert_eq!(format_size(0, true), "0B"); + assert_eq!(format_size(500, H), "500"); + assert_eq!(format_size(0, H), "0"); } #[test] fn test_format_size_kb() { - assert_eq!(format_size(1024, true), "1.0K"); - assert_eq!(format_size(2048, true), "2.0K"); + assert_eq!(format_size(1024, H), "1.0K"); + assert_eq!(format_size(2048, H), "2.0K"); } #[test] fn test_format_size_mb() { - assert_eq!(format_size(1024 * 1024, true), "1.0M"); - assert_eq!(format_size(5 * 1024 * 1024, true), "5.0M"); + assert_eq!(format_size(1024 * 1024, H), "1.0M"); + assert_eq!(format_size(5 * 1024 * 1024, H), "5.0M"); } #[test] fn test_format_size_gb() { - assert_eq!(format_size(1024 * 1024 * 1024, true), "1.0G"); + assert_eq!(format_size(1024 * 1024 * 1024, H), "1.0G"); } #[test] fn test_format_size_blocks() { // Non-human-readable returns 1K blocks - assert_eq!(format_size(512, false), "1"); - assert_eq!(format_size(1024, false), "1"); - assert_eq!(format_size(2048, false), "2"); + assert_eq!(format_size(512, K), "1"); + assert_eq!(format_size(1024, K), "1"); + assert_eq!(format_size(2048, K), "2"); } } diff --git a/crates/bashkit/src/builtins/echo.rs b/crates/bashkit/src/builtins/echo.rs index d66e29fd0..59708fed9 100644 --- a/crates/bashkit/src/builtins/echo.rs +++ b/crates/bashkit/src/builtins/echo.rs @@ -23,7 +23,6 @@ impl Builtin for Echo { ) { return Ok(r); } - let mut output = String::new(); let mut add_newline = true; let mut interpret_escapes = false; let mut args_iter = ctx.args.iter().peekable(); @@ -61,101 +60,131 @@ impl Builtin for Echo { // Collect remaining arguments let remaining: Vec<&String> = args_iter.collect(); + let mut output: Vec = Vec::new(); for (i, arg) in remaining.iter().enumerate() { if i > 0 { - output.push(' '); + output.push(b' '); } if interpret_escapes { - output.push_str(&interpret_escape_sequences(arg)); + // `\c` stops all further output, the newline included. + if interpret_escape_sequences(arg, &mut output) { + return Ok(ExecResult::ok_bytes(output)); + } } else { - output.push_str(arg); + output.extend_from_slice(arg.as_bytes()); } } if add_newline { - output.push('\n'); + output.push(b'\n'); } - Ok(ExecResult::ok(output)) + Ok(ExecResult::ok_bytes(output)) } } -fn interpret_escape_sequences(s: &str) -> String { - let mut result = String::new(); +/// Expand bash `echo -e` escapes into `out` (octal/hex escapes are raw +/// bytes). Returns `true` when `\c` asked to stop all output. +fn interpret_escape_sequences(s: &str, out: &mut Vec) -> bool { let mut chars = s.chars().peekable(); + let mut buf = [0u8; 4]; while let Some(ch) = chars.next() { - if ch == '\\' { - match chars.next() { - Some('n') => result.push('\n'), - Some('t') => result.push('\t'), - Some('r') => result.push('\r'), - Some('\\') => result.push('\\'), - Some('a') => result.push('\x07'), // bell - Some('b') => result.push('\x08'), // backspace - Some('f') => result.push('\x0c'), // form feed - Some('v') => result.push('\x0b'), // vertical tab - Some('0') => { - // Octal escape \0nnn - let mut value = 0u8; - for _ in 0..3 { - if let Some(&digit) = chars.peek() { - if ('0'..='7').contains(&digit) { - value = value * 8 + (digit as u8 - b'0'); - chars.next(); - } else { - break; - } + if ch != '\\' { + out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); + continue; + } + let byte = match chars.next() { + Some('n') => b'\n', + Some('t') => b'\t', + Some('r') => b'\r', + Some('\\') => b'\\', + Some('a') => 0x07, + Some('b') => 0x08, + Some('e') | Some('E') => 0x1b, + Some('f') => 0x0c, + Some('v') => 0x0b, + Some('c') => return true, + Some('0') => { + // \0nnn: zero to three octal digits after the 0. + let mut value = 0u32; + for _ in 0..3 { + match chars.peek().and_then(|d| d.to_digit(8)) { + Some(d) => { + value = value * 8 + d; + chars.next(); } + None => break, } - result.push(value as char); } - Some('x') => { - // Hex escape \xHH - let mut value = 0u8; - for _ in 0..2 { - if let Some(&digit) = chars.peek() { - if digit.is_ascii_hexdigit() { - value = value * 16 - + digit.to_digit(16).expect( - "to_digit(16) valid: guarded by is_ascii_hexdigit()", - ) as u8; - chars.next(); - } else { - break; - } + (value & 0xff) as u8 + } + Some(c @ ('x' | 'u' | 'U')) => { + let max = match c { + 'x' => 2, + 'u' => 4, + _ => 8, + }; + let mut value = 0u32; + let mut digits = 0; + while digits < max { + match chars.peek().and_then(|d| d.to_digit(16)) { + Some(d) => { + value = value * 16 + d; + chars.next(); + digits += 1; } + None => break, } - result.push(value as char); } - Some('c') => { - // Stop output - break; + if digits == 0 { + // No hex digits: the escape stays as written. + out.push(b'\\'); + out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes()); + continue; } - Some(other) => { - result.push('\\'); - result.push(other); + if c == 'x' { + value as u8 + } else { + let ch = char::from_u32(value).unwrap_or('\u{fffd}'); + out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()); + continue; } - None => result.push('\\'), } - } else { - result.push(ch); - } + Some(other) => { + out.push(b'\\'); + out.extend_from_slice(other.encode_utf8(&mut buf).as_bytes()); + continue; + } + None => b'\\', + }; + out.push(byte); } - - result + false } #[cfg(test)] mod tests { use super::*; + fn esc(s: &str) -> (Vec, bool) { + let mut out = Vec::new(); + let stop = interpret_escape_sequences(s, &mut out); + (out, stop) + } + #[test] fn test_escape_sequences() { - assert_eq!(interpret_escape_sequences("hello\\nworld"), "hello\nworld"); - assert_eq!(interpret_escape_sequences("tab\\there"), "tab\there"); - assert_eq!(interpret_escape_sequences("\\\\backslash"), "\\backslash"); + assert_eq!(esc("hello\\nworld").0, b"hello\nworld"); + assert_eq!(esc("tab\\there").0, b"tab\there"); + assert_eq!(esc("\\\\backslash").0, b"\\backslash"); + // Unknown escapes and \x without digits stay as written. + assert_eq!(esc("\\z\\xg\\").0, b"\\z\\xg\\"); + // Octal/hex escapes are raw bytes, not Latin-1 characters. + assert_eq!(esc("\\0377\\xff").0, vec![0xff, 0xff]); + assert_eq!(esc("a\\cb"), (b"a".to_vec(), true)); + assert_eq!(esc("\\u00e9").0, "é".as_bytes()); } } diff --git a/crates/bashkit/src/builtins/expand.rs b/crates/bashkit/src/builtins/expand.rs index 04e0ee61e..191e1e07f 100644 --- a/crates/bashkit/src/builtins/expand.rs +++ b/crates/bashkit/src/builtins/expand.rs @@ -1,7 +1,14 @@ //! expand/unexpand builtin commands - convert between tabs and spaces +//! +//! Decision: tab-stop parsing and column tracking follow GNU +//! `expand-common.c` / `unexpand.c`: `-t` lists accumulate across options, +//! a trailing `/N` repeats every N columns, `+N` repeats N after the last +//! stop, past the last explicit stop expand emits one space and unexpand +//! stops converting, and backspace moves the column back. use async_trait::async_trait; +use super::arg_parser::{OptArg, gnu_getopt}; use super::limits::{ EXPAND_MAX_OUTPUT_BYTES as MAX_OUTPUT_BYTES, EXPAND_MAX_TAB_STOP as MAX_TAB_STOP, }; @@ -9,9 +16,162 @@ use super::{Builtin, BuiltinHelper, Context, read_text_file, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; +/// Parsed tab stops (GNU `tab_list` + `extend_size` + `increment_size`). +#[derive(Default, Debug)] +struct TabStops { + list: Vec, + extend: usize, + increment: usize, + /// Uniform tab size, or 0 when `list`/extend/increment apply. + size: usize, +} + +impl TabStops { + /// Add the stops in one `-t` argument (commas or blanks separate). + fn parse_into(&mut self, s: &str) -> std::result::Result<(), String> { + let bad = || format!("invalid tab size: '{s}'"); + let mut extend = false; + let mut increment = false; + let mut val: Option = None; + let flush = |val: &mut Option, + extend: bool, + increment: bool, + this: &mut Self| + -> std::result::Result<(), String> { + if let Some(v) = val.take() { + if v == 0 || v > MAX_TAB_STOP { + return Err(bad()); + } + if extend { + this.extend = v; + } else if increment { + this.increment = v; + } else { + this.list.push(v); + } + } + Ok(()) + }; + for c in s.chars() { + match c { + ',' | ' ' | '\t' => flush(&mut val, extend, increment, self)?, + '/' | '+' => { + if val.is_some() { + return Err(bad()); + } + extend = c == '/'; + increment = c == '+'; + } + '0'..='9' => { + let d = c as usize - '0' as usize; + let v = val.unwrap_or(0).saturating_mul(10).saturating_add(d); + val = Some(v.min(MAX_TAB_STOP + 1)); + } + _ => return Err(bad()), + } + } + flush(&mut val, extend, increment, self) + } + + /// GNU `finalize_tab_stops`: validate and pick the uniform size. + fn finalize(&mut self) -> std::result::Result<(), String> { + let mut prev = 0; + for &t in &self.list { + if t <= prev { + return Err("tab sizes must be ascending".to_string()); + } + prev = t; + } + self.size = if self.list.is_empty() { + if self.extend != 0 { + self.extend + } else if self.increment != 0 { + self.increment + } else { + 8 + } + } else if self.list.len() == 1 && self.extend == 0 && self.increment == 0 { + self.list[0] + } else { + 0 + }; + Ok(()) + } + + /// GNU `get_next_tab_column`; `None` means past the last tab stop. + fn next(&self, col: usize, idx: &mut usize) -> Option { + if self.size != 0 { + return Some(col + (self.size - col % self.size)); + } + while *idx < self.list.len() { + let tab = self.list[*idx]; + if col < tab { + return Some(tab); + } + *idx += 1; + } + if self.extend != 0 { + return Some(col + (self.extend - col % self.extend)); + } + if self.increment != 0 { + let end = self.list.last().copied().unwrap_or(0); + return Some(col + (self.increment - (col - end) % self.increment)); + } + None + } +} + +/// Split obsolete `-N[,N...]` tab-list arguments out of `args`. +fn split_obsolete_tabs(args: &[String]) -> (Vec, Vec) { + let mut rest = Vec::new(); + let mut lists = Vec::new(); + let mut done = false; + for a in args { + if done { + rest.push(a.clone()); + continue; + } + if a == "--" { + done = true; + rest.push(a.clone()); + } else if a.len() > 1 + && a.starts_with('-') + && a[1..].starts_with(|c: char| c.is_ascii_digit()) + && a[1..].chars().all(|c| c.is_ascii_digit() || c == ',') + { + lists.push(a[1..].to_string()); + } else { + rest.push(a.clone()); + } + } + (rest, lists) +} + +async fn read_inputs( + ctx: &Context<'_>, + files: &[String], +) -> std::result::Result { + if files.is_empty() { + return Ok(ctx.stdin.map(ToString::to_string).unwrap_or_default()); + } + let mut buf = String::new(); + for file in files { + if file == "-" { + buf.push_str(ctx.stdin.map(|s| &**s).unwrap_or_default()); + continue; + } + let path = resolve_path(ctx.cwd, file); + match read_text_file(ctx.fs.as_ref(), &path, H::NAME).await { + Ok(text) => buf.push_str(&text), + Err(_) => return Err(H::err_path(file, "No such file or directory", 1)), + } + } + Ok(buf) +} + /// The expand builtin command. /// -/// Usage: expand [-t N] [FILE...] +/// Usage: expand [-i] [-t LIST] [FILE...] /// /// Converts tabs to spaces. Default tab stop is 8. pub struct Expand; @@ -25,106 +185,89 @@ impl Builtin for Expand { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = Self::check_help( ctx.args, - "Usage: expand [OPTION]... [FILE]...\nConvert tabs to spaces.\n\n -t N\tuse N characters as tab size (default 8)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: expand [OPTION]... [FILE]...\nConvert tabs to spaces.\n\n -i, --initial\tdo not convert tabs after non blanks\n -t, --tabs=LIST\tuse comma separated list of tab positions\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("expand (bashkit) 0.1"), ) { return Ok(r); } - let mut tab_stops: Vec = vec![8]; - let mut files: Vec<&str> = Vec::new(); - - let mut i = 0; - while i < ctx.args.len() { - match ctx.args[i].as_str() { - "-t" => { - i += 1; - if i >= ctx.args.len() { - return Ok(Self::err("option requires an argument -- 't'", 1)); - } - tab_stops = match parse_tab_stops(&ctx.args[i]) { - Ok(stops) => stops, - Err(e) => return Ok(Self::err(e, 1)), - }; - } - s if s.starts_with("-t") && s.len() > 2 => { - tab_stops = match parse_tab_stops(&s[2..]) { - Ok(stops) => stops, - Err(e) => return Ok(Self::err(e, 1)), - }; - } - // Reject unknown options. `-N` (all-digit) is the obsolete - // tab-size form expand still accepts, so leave it to fall - // through; `--`/`-` and operands also fall through. - s if s.starts_with('-') - && s.len() > 1 - && s != "--" - && !s[1..].chars().all(|c| c.is_ascii_digit()) => - { - return Ok(super::invalid_option("expand", s, 1)); - } - _ => files.push(&ctx.args[i]), + let (args, obsolete) = split_obsolete_tabs(ctx.args); + let (opts, files) = match gnu_getopt( + "expand", + &args, + "it:", + &[ + ("initial", OptArg::No, 'i'), + ("tabs", OptArg::Required, 't'), + ], + true, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; + let mut stops = TabStops::default(); + let mut initial = false; + for spec in &obsolete { + if let Err(e) = stops.parse_into(spec) { + return Ok(Self::err(e, 1)); } - i += 1; } - - let input = if files.is_empty() { - ctx.stdin.map(ToString::to_string).unwrap_or_default() - } else { - let mut buf = String::new(); - for file in &files { - let path = resolve_path(ctx.cwd, file); - match read_text_file(ctx.fs.as_ref(), &path, "expand").await { - Ok(text) => buf.push_str(&text), - Err(_) => { - return Ok(Self::err_path(file, "No such file or directory", 1)); + for o in opts { + match o.key { + 'i' => initial = true, + _ => { + let spec = o.value.unwrap_or_default(); + if let Err(e) = stops.parse_into(&spec) { + return Ok(Self::err(e, 1)); } } } - buf + } + if let Err(e) = stops.finalize() { + return Ok(Self::err(e, 1)); + } + + let input = match read_inputs::(&ctx, &files).await { + Ok(t) => t, + Err(e) => return Ok(e), }; + let too_big = || Self::err(format!("output exceeds byte limit ({MAX_OUTPUT_BYTES})"), 1); let mut output = String::new(); - // Use split_terminator to avoid an extra empty segment (and trailing newline) when - // input ends with '\n'. - for line in input.split_terminator('\n') { - let mut col = 0; - for ch in line.chars() { - if ch == '\t' { - let next_stop = next_tab_stop(col, &tab_stops); - let spaces = next_stop - col; - if output.len().saturating_add(spaces) > MAX_OUTPUT_BYTES { - return Ok(Self::err( - format!("output exceeds byte limit ({MAX_OUTPUT_BYTES})"), - 1, - )); - } - output.extend(std::iter::repeat_n(' ', spaces)); - col = next_stop; - } else { - if output.len() >= MAX_OUTPUT_BYTES { - return Ok(Self::err( - format!("output exceeds byte limit ({MAX_OUTPUT_BYTES})"), - 1, - )); - } - output.push(ch); - col += 1; + let mut col = 0usize; + let mut idx = 0usize; + let mut convert = true; + for ch in input.chars() { + if convert && ch == '\t' { + let next = stops.next(col, &mut idx).unwrap_or(col + 1); + let spaces = next - col; + if output.len().saturating_add(spaces) > MAX_OUTPUT_BYTES { + return Ok(too_big()); } + output.extend(std::iter::repeat_n(' ', spaces)); + col = next; + continue; } if output.len() >= MAX_OUTPUT_BYTES { - return Ok(Self::err( - format!("output exceeds byte limit ({MAX_OUTPUT_BYTES})"), - 1, - )); + return Ok(too_big()); } - output.push('\n'); - } - - // Preserve trailing-newline behavior: split_terminator omits the final empty segment - // so we only have newlines after actual lines. If the input did NOT end with '\n', - // remove the last newline we added. - if !input.ends_with('\n') && output.ends_with('\n') { - output.pop(); + match ch { + '\n' => { + col = 0; + idx = 0; + convert = true; + } + '\x08' if convert => { + col = col.saturating_sub(1); + idx = idx.saturating_sub(1); + } + _ if convert => { + col += 1; + convert = !(initial && ch != ' ' && ch != '\t'); + } + _ => {} + } + output.push(ch); } Ok(ExecResult::ok(output)) @@ -133,7 +276,7 @@ impl Builtin for Expand { /// The unexpand builtin command. /// -/// Usage: unexpand [-a] [-t N] [FILE...] +/// Usage: unexpand [-a] [--first-only] [-t LIST] [FILE...] /// /// Converts spaces to tabs. By default, only converts leading spaces. pub struct Unexpand; @@ -147,176 +290,130 @@ impl Builtin for Unexpand { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = Self::check_help( ctx.args, - "Usage: unexpand [OPTION]... [FILE]...\nConvert spaces to tabs.\n\n -a, --all\tconvert all blanks, instead of just initial blanks\n -t N\t\tuse N characters as tab size (default 8)\n --help\t\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: unexpand [OPTION]... [FILE]...\nConvert spaces to tabs.\n\n -a, --all\tconvert all blanks, instead of just initial blanks\n --first-only\tconvert only leading sequences of blanks\n -t, --tabs=LIST\tuse comma separated list of tab positions (implies -a)\n --help\t\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("unexpand (bashkit) 0.1"), ) { return Ok(r); } - let mut tab_stops: Vec = vec![8]; + let (args, obsolete) = split_obsolete_tabs(ctx.args); + let (opts, files) = match gnu_getopt( + "unexpand", + &args, + "at:", + &[ + ("all", OptArg::No, 'a'), + ("first-only", OptArg::No, 'F'), + ("tabs", OptArg::Required, 't'), + ], + true, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; + let mut stops = TabStops::default(); let mut all = false; - let mut files: Vec<&str> = Vec::new(); - - let mut i = 0; - while i < ctx.args.len() { - match ctx.args[i].as_str() { - "-a" | "--all" => all = true, - "-t" => { - i += 1; - if i >= ctx.args.len() { - return Ok(Self::err("option requires an argument -- 't'", 1)); + let mut first_only = false; + for spec in &obsolete { + if let Err(e) = stops.parse_into(spec) { + return Ok(Self::err(e, 1)); + } + } + for o in opts { + match o.key { + 'a' => all = true, + 'F' => first_only = true, + _ => { + let spec = o.value.unwrap_or_default(); + if let Err(e) = stops.parse_into(&spec) { + return Ok(Self::err(e, 1)); } - let parsed_tab_stops = match parse_tab_stops(&ctx.args[i]) { - Ok(stops) => stops, - Err(_) => { - return Ok(Self::err( - format!("invalid tab size: '{}'", ctx.args[i]), - 1, - )); - } - }; - tab_stops = parsed_tab_stops; - all = true; // -t implies -a - } - s if s.starts_with("-t") && s.len() > 2 => { - // Attached form `-tN`; known option, not an unknown token. - tab_stops = match parse_tab_stops(&s[2..]) { - Ok(stops) => stops, - Err(_) => { - return Ok(Self::err(format!("invalid tab size: '{}'", &s[2..]), 1)); - } - }; all = true; // -t implies -a } - // Reject unknown options. unexpand takes no numeric operand, - // so `-N` is also unknown; `--`/`-` and operands fall through. - s if s.starts_with('-') && s.len() > 1 && s != "--" => { - return Ok(super::invalid_option("unexpand", s, 1)); - } - _ => files.push(&ctx.args[i]), } - i += 1; + } + if first_only { + all = false; + } + if let Err(e) = stops.finalize() { + return Ok(Self::err(e, 1)); } - let input = if files.is_empty() { - ctx.stdin.map(ToString::to_string).unwrap_or_default() - } else { - let mut buf = String::new(); - for file in &files { - let path = resolve_path(ctx.cwd, file); - match read_text_file(ctx.fs.as_ref(), &path, "unexpand").await { - Ok(text) => buf.push_str(&text), - Err(_) => { - return Ok(Self::err_path(file, "No such file or directory", 1)); - } - } - } - buf + let input = match read_inputs::(&ctx, &files).await { + Ok(t) => t, + Err(e) => return Ok(e), }; - let tab_size = tab_stops[0]; - let mut output = String::new(); + let mut output = String::with_capacity(input.len()); + for line in input.split_inclusive('\n') { + unexpand_line(line, &stops, all, &mut output); + } + Ok(ExecResult::ok(output)) + } +} - for line in input.split('\n') { - if all { - // Convert all sequences of spaces at tab stops - let mut col = 0; - let mut space_count = 0; - let mut result = String::new(); - - for ch in line.chars() { - if ch == ' ' { - space_count += 1; - col += 1; - if col % tab_size == 0 && space_count > 1 { - result.push('\t'); - space_count = 0; - } - } else { - for _ in 0..space_count { - result.push(' '); +/// GNU `unexpand` state machine for one line (including its newline). +fn unexpand_line(line: &str, stops: &TabStops, all: bool, out: &mut String) { + let mut convert = true; + let mut column = 0usize; + let mut idx = 0usize; + let mut pending: Vec = Vec::new(); + let mut one_blank_before_stop = false; + let mut prev_blank = true; + + // `None` stands for end of input so pending blanks get flushed. + for c in line.chars().map(Some).chain(std::iter::once(None)) { + let mut ch = c; + if convert { + let blank = matches!(c, Some(' ') | Some('\t')); + if blank { + match stops.next(column, &mut idx) { + None => convert = false, + Some(next) => { + if c == Some('\t') { + column = next; + if let Some(p) = pending.first_mut() { + *p = '\t'; + } + } else { + column += 1; + if !(prev_blank && column == next) { + if column == next { + one_blank_before_stop = true; + } + pending.push(' '); + prev_blank = true; + continue; + } + ch = Some('\t'); + if let Some(p) = pending.first_mut() { + *p = '\t'; + } } - space_count = 0; - result.push(ch); - col += 1; + // Keep only a single blank just before the previous stop. + pending.truncate(usize::from(one_blank_before_stop)); } } - for _ in 0..space_count { - result.push(' '); - } - output.push_str(&result); + } else if c == Some('\x08') { + column = column.saturating_sub(1); + idx = idx.saturating_sub(1); } else { - // Only convert leading spaces - let mut col = 0; - let chars: Vec = line.chars().collect(); - let mut pos = 0; - let mut result = String::new(); - - // Process leading spaces - while pos < chars.len() && chars[pos] == ' ' { - col += 1; - pos += 1; - if col % tab_size == 0 { - result.push('\t'); - } - } - // Add remaining leading spaces that didn't fill a tab - let remainder = col % tab_size; - if remainder > 0 && pos < chars.len() { - // We consumed some spaces but not enough for a tab - let tabs_written = col / tab_size; - let spaces_accounted = tabs_written * tab_size; - for _ in 0..(col - spaces_accounted) { - // These are already handled by the tab pushes above - } - } - // Append rest of line unchanged - for ch in &chars[pos..] { - result.push(*ch); - } - output.push_str(&result); + column += 1; } - output.push('\n'); - } - - if !input.ends_with('\n') && output.ends_with('\n') { - output.pop(); - } - - Ok(ExecResult::ok(output)) - } -} -fn parse_tab_stops(s: &str) -> std::result::Result, String> { - let mut stops = Vec::new(); - for part in s.split(',') { - let trimmed = part.trim(); - let Ok(stop) = trimmed.parse::() else { - return Err(format!("invalid tab size: '{s}'")); - }; - if stop == 0 || stop > MAX_TAB_STOP { - return Err(format!("invalid tab size: '{s}'")); - } - stops.push(stop); - } - if stops.is_empty() { - return Err(format!("invalid tab size: '{s}'")); - } - Ok(stops) -} - -fn next_tab_stop(col: usize, tab_stops: &[usize]) -> usize { - if tab_stops.len() == 1 { - let ts = tab_stops[0]; - ((col / ts) + 1) * ts - } else { - // Find the first tab stop > col - for &ts in tab_stops { - if ts > col { - return ts; + if !pending.is_empty() { + if pending.len() > 1 && one_blank_before_stop { + pending[0] = '\t'; + } + out.extend(pending.drain(..)); + one_blank_before_stop = false; } + prev_blank = blank; + convert &= all || blank; + } + if let Some(ch) = ch { + out.push(ch); } - // Past all tab stops, use last interval - col + 1 } } @@ -478,4 +575,29 @@ mod tests { assert_eq!(result.exit_code, 1); assert_eq!(result.stderr, "unexpand: invalid tab size: 'foo'\n"); } + + #[tokio::test] + async fn test_expand_tab_list_extensions() { + let r = run_expand(&["-t", "2,5,/3"], Some("a\tb\tc\td\te\n")).await; + assert_eq!(r.stdout, "a b c d e\n"); + let r = run_expand(&["-t", "2,5,+3"], Some("a\tb\tc\td\te\n")).await; + assert_eq!(r.stdout, "a b c d e\n"); + // Repeated -t accumulate; past the last stop a tab is one space. + let r = run_expand(&["-t", "2", "-t", "6"], Some("a\tb\tc\td\n")).await; + assert_eq!(r.stdout, "a b c d\n"); + let r = run_expand(&["-t", "4,2"], Some("a\n")).await; + assert_eq!(r.exit_code, 1); + } + + #[tokio::test] + async fn test_unexpand_gnu_blank_rules() { + let r = run_unexpand(&["-a"], Some("abcdefg h\n")).await; + assert_eq!(r.stdout, "abcdefg h\n"); + let r = run_unexpand(&["-a"], Some("abcdefg \tx\n")).await; + assert_eq!(r.stdout, "abcdefg\t\tx\n"); + let r = run_unexpand(&["-2,6"], Some(" a b c\n")).await; + assert_eq!(r.stdout, "\ta b c\n"); + let r = run_unexpand(&[], Some("x y")).await; + assert_eq!(r.stdout, "x y"); + } } diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index 634503499..13215e7e2 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -230,8 +230,10 @@ impl Builtin for Cp { return Ok(r); } - // Reject unknown options like GNU cp; accept-and-ignore the rest (cp - // copies non-recursively in the VFS regardless). '--' ends options. + // Reject unknown options like GNU cp. `-r`/`-R`/`-a` (and the long + // forms) copy directories recursively; the remaining flags are + // accepted and ignored. '--' ends options. + let mut recursive = false; let mut files: Vec<&String> = Vec::new(); let mut opts_done = false; for arg in ctx.args { @@ -240,7 +242,11 @@ impl Builtin for Cp { } else if arg == "--" { opts_done = true; } else if let Some(long) = arg.strip_prefix("--") { - match long.split('=').next().unwrap_or("") { + let name = long.split('=').next().unwrap_or(""); + if matches!(name, "archive" | "recursive") { + recursive = true; + } + match name { "archive" | "attributes-only" | "backup" @@ -271,8 +277,9 @@ impl Builtin for Cp { } else if arg.starts_with('-') && arg.len() > 1 { for c in arg[1..].chars() { match c { - 'a' | 'd' | 'f' | 'H' | 'i' | 'l' | 'L' | 'n' | 'P' | 'p' | 'r' | 'R' - | 's' | 't' | 'T' | 'u' | 'v' | 'x' | 'Z' => {} + 'a' | 'r' | 'R' => recursive = true, + 'd' | 'f' | 'H' | 'i' | 'l' | 'L' | 'n' | 'P' | 'p' | 's' | 't' | 'T' + | 'u' | 'v' | 'x' | 'Z' => {} _ => return Ok(super::invalid_option("cp", &format!("-{c}"), 1)), } } @@ -311,6 +318,7 @@ impl Builtin for Cp { )); } + let mut stderr = String::new(); for source in sources { let src_path = resolve_path(ctx.cwd, source); @@ -325,18 +333,128 @@ impl Builtin for Cp { dest_path.clone() }; + let src_is_dir = ctx + .fs + .stat(&src_path) + .await + .is_ok_and(|m| m.file_type.is_dir()); + if src_is_dir { + if !recursive { + stderr.push_str(&format!( + "cp: -r not specified; omitting directory '{source}'\n" + )); + continue; + } + if final_dest.starts_with(&src_path) { + let shown = if dest_is_dir { + let name = Path::new(source.as_str()) + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(); + format!("{}/{name}", dest.trim_end_matches('/')) + } else { + dest.to_string() + }; + stderr.push_str(&format!( + "cp: cannot copy a directory, '{source}', into itself, '{shown}'\n" + )); + continue; + } + if let Err(msg) = copy_tree(&ctx, &src_path, &final_dest).await? { + stderr.push_str(&format!("cp: {msg}\n")); + } + continue; + } + if let Err(e) = ctx.fs.copy(&src_path, &final_dest).await { - return Ok(ExecResult::err( - format!("cp: cannot copy '{}': {}\n", source, e), - 1, - )); + stderr.push_str(&format!("cp: cannot copy '{}': {}\n", source, e)); } } - Ok(ExecResult::ok(String::new())) + if stderr.is_empty() { + Ok(ExecResult::ok(String::new())) + } else { + Ok(ExecResult::err(stderr, 1)) + } } } +/// Future returned by [`copy_tree`]: outer `Err` aborts, inner `Err` is a +/// user-facing message. +type CopyTreeFuture<'a> = std::pin::Pin< + Box>> + Send + 'a>, +>; + +/// Recursively copy `src` to `dst` (GNU `cp -R` without `-L`: symlinks are +/// recreated, not followed). The outer error is cancellation/budget; the +/// inner one is a user-facing message for the first failed entry. +fn copy_tree<'a>(ctx: &'a Context<'_>, src: &'a Path, dst: &'a Path) -> CopyTreeFuture<'a> { + Box::pin(async move { + ctx.consume_budget_work(1)?; + let meta = match ctx.fs.lstat(src).await { + Ok(m) => m, + Err(e) => return Ok(Err(format!("cannot stat '{}': {e}", src.display()))), + }; + if meta.file_type.is_dir() { + match ctx.fs.stat(dst).await { + Ok(m) if m.file_type.is_dir() => {} + Ok(_) => { + return Ok(Err(format!( + "cannot overwrite non-directory '{}' with directory '{}'", + dst.display(), + src.display() + ))); + } + Err(_) => { + if let Err(e) = ctx.fs.mkdir(dst, false).await { + return Ok(Err(format!( + "cannot create directory '{}': {e}", + dst.display() + ))); + } + let _ = ctx.fs.chmod(dst, meta.mode).await; + } + } + let entries = match ctx.fs.read_dir(src).await { + Ok(e) => e, + Err(e) => return Ok(Err(format!("cannot access '{}': {e}", src.display()))), + }; + for entry in entries { + let from = vfs_join(src, &entry.name); + let to = vfs_join(dst, &entry.name); + if let Err(msg) = copy_tree(ctx, &from, &to).await? { + return Ok(Err(msg)); + } + } + return Ok(Ok(())); + } + if meta.file_type.is_symlink() { + let target = match ctx.fs.read_link(src).await { + Ok(t) => t, + Err(e) => { + return Ok(Err(format!( + "cannot read symbolic link '{}': {e}", + src.display() + ))); + } + }; + if ctx.fs.lstat(dst).await.is_ok() { + let _ = ctx.fs.remove(dst, false).await; + } + return Ok(ctx + .fs + .symlink(&target, dst) + .await + .map_err(|e| format!("cannot create symbolic link '{}': {e}", dst.display()))); + } + Ok(ctx + .fs + .copy(src, dst) + .await + .map_err(|e| format!("cannot copy '{}': {e}", src.display()))) + }) +} + /// The mv builtin - move (rename) files. /// /// Usage: mv SOURCE... DEST @@ -1007,26 +1125,41 @@ impl Builtin for Chown { /// -q, --quiet Suppress error diagnostics on failure. pub struct Mktemp; -fn mktemp_suffix_for_attempt(attempt: usize) -> String { +/// `len` random `[A-Za-z0-9]` characters, re-seeded per attempt. +fn mktemp_random(attempt: usize, len: usize) -> String { use std::collections::hash_map::RandomState; use std::hash::{BuildHasher, Hasher}; - let mut hasher = RandomState::new().build_hasher(); - hasher.write_usize(attempt); - let random = hasher.finish(); - format!("{:010x}", random % 0xFF_FFFF_FFFF) + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + let state = RandomState::new(); + (0..len) + .map(|i| { + let mut hasher = state.build_hasher(); + hasher.write_usize(attempt); + hasher.write_usize(i); + CHARS[(hasher.finish() % CHARS.len() as u64) as usize] as char + }) + .collect() } -fn mktemp_name(template: Option<&str>, suffix: &str) -> String { - if let Some(tmpl) = template { - if tmpl.contains("XXXXXX") { - tmpl.replacen("XXXXXX", &suffix[..6], 1) - } else { - format!("{}.{}", tmpl, &suffix[..6]) - } - } else { - format!("tmp.{}", &suffix[..10]) +/// Split TEMPLATE into (prefix, X count, suffix) the GNU way: without +/// `--suffix`, the suffix is whatever follows the last `X`. +fn mktemp_split<'a>( + template: &'a str, + suffix: Option<&'a str>, +) -> std::result::Result<(&'a str, usize, &'a str), String> { + let (body, suffix) = match suffix { + Some(suf) => (template, suf), + None => match template.rfind('X') { + Some(i) => (&template[..=i], &template[i + 1..]), + None => (template, ""), + }, + }; + let xs = body.bytes().rev().take_while(|b| *b == b'X').count(); + if xs < 3 { + return Err(format!("mktemp: too few X's in template '{template}'\n")); } + Ok((&body[..body.len() - xs], xs, suffix)) } // Cached `mktemp` arg surface: pre-built once, cloned per invocation. @@ -1061,93 +1194,114 @@ impl Builtin for Mktemp { let create_dir = matches.get_flag("directory"); let dry_run = matches.get_flag("dry-run"); let quiet = matches.get_flag("quiet"); - let use_tmpdir = matches.get_flag("t"); - // -p and --tmpdir parse to Option: empty value falls - // back to the default. clap stores the *outer* presence as the - // arg id, the typed value as Option. + let use_t = matches.get_flag("t"); + // -p and --tmpdir parse to Option; an empty value means + // "use $TMPDIR or /tmp". let p_value: Option<&Option> = matches.get_one("p"); let tmpdir_value: Option<&Option> = matches.get_one("tmpdir"); + let dir_arg: Option = match (tmpdir_value, p_value) { + (Some(Some(p)), _) | (_, Some(Some(p))) => Some(p.to_string_lossy().into_owned()), + _ => None, + } + .filter(|d| !d.is_empty()); let suffix_arg = matches .get_one::("suffix") .map(|s| s.to_string_lossy().into_owned()); - - let prefix_dir: String = match (tmpdir_value, p_value) { - (Some(Some(p)), _) | (_, Some(Some(p))) => p.to_string_lossy().into_owned(), - _ => "/tmp".to_string(), - }; - - let template: Option = matches + let template_arg: Option = matches .get_one::("template") .map(|s| s.to_string_lossy().into_owned()); - for attempt in 0..MKTEMP_MAX_ATTEMPTS { - let rand_part = mktemp_suffix_for_attempt(attempt); - let name_with_x = mktemp_name(template.as_deref(), &rand_part); - let name = match &suffix_arg { - Some(suf) => format!("{name_with_x}{suf}"), - None => name_with_x, - }; + let fail = |msg: String| ExecResult::err(if quiet { String::new() } else { msg }, 1); + + // GNU: no TEMPLATE implies --tmpdir; -t prefers $TMPDIR over -p. + let use_dest_dir = + template_arg.is_none() || p_value.is_some() || tmpdir_value.is_some() || use_t; + let template = template_arg.unwrap_or_else(|| "tmp.XXXXXXXXXX".to_string()); + if suffix_arg.as_deref().is_some_and(|s| s.contains('/')) { + return Ok(fail(format!( + "mktemp: invalid suffix '{}', contains directory separator\n", + suffix_arg.as_deref().unwrap_or_default() + ))); + } + let (prefix, xs, suffix) = match mktemp_split(&template, suffix_arg.as_deref()) { + Ok(parts) => parts, + Err(msg) => return Ok(fail(msg)), + }; + let env_tmpdir = ctx.env.get("TMPDIR").filter(|d| !d.is_empty()).cloned(); + let dest_dir = if !use_dest_dir { + None + } else if use_t { + Some(env_tmpdir.or(dir_arg).unwrap_or_else(|| "/tmp".to_string())) + } else { + Some(dir_arg.or(env_tmpdir).unwrap_or_else(|| "/tmp".to_string())) + }; + if dest_dir.is_some() && template.starts_with('/') { + return Ok(fail(format!( + "mktemp: invalid template, '{template}'; with --tmpdir, it may not be absolute\n" + ))); + } + if use_t && template.contains('/') { + return Ok(fail(format!( + "mktemp: invalid template, '{template}', contains directory separator\n" + ))); + } + // Sandbox convenience: a VFS without /tmp still gets the default + // directory (an explicit -p/TMPDIR directory must already exist). + if dest_dir.as_deref() == Some("/tmp") { + let tmp = std::path::Path::new("/tmp"); + if !ctx.fs.exists(tmp).await.unwrap_or(false) { + let _ = ctx.fs.mkdir(tmp, true).await; + } + } + let shown_template = match &dest_dir { + Some(dir) => format!("{}/{template}", dir.trim_end_matches('/')), + None => template.clone(), + }; - let path = if template.as_deref().is_some_and(|t| t.starts_with('/')) { - name - } else if use_tmpdir || template.is_none() || !name.contains('/') { - format!("{}/{}", prefix_dir, name) - } else { - let p = resolve_path(ctx.cwd, &name); - p.to_string_lossy().to_string() + for attempt in 0..MKTEMP_MAX_ATTEMPTS { + let name = format!("{prefix}{}{suffix}", mktemp_random(attempt, xs)); + // Shown as built (relative stays relative); created under cwd. + let shown = match &dest_dir { + Some(dir) => format!("{}/{name}", dir.trim_end_matches('/')), + None => name, }; - - let full_path = std::path::PathBuf::from(&path); - - if let Some(parent) = full_path.parent() - && !ctx.fs.exists(parent).await.unwrap_or(false) - { - let _ = ctx.fs.mkdir(parent, true).await; - } + let full_path = resolve_path(ctx.cwd, &shown); if ctx.fs.exists(&full_path).await.unwrap_or(false) { continue; } - if dry_run { - return Ok(ExecResult::ok(format!("{}\n", path))); + return Ok(ExecResult::ok(format!("{shown}\n"))); } - - if create_dir { - match ctx.fs.mkdir(&full_path, false).await { - Ok(_) => return Ok(ExecResult::ok(format!("{}\n", path))), - Err(_) if ctx.fs.exists(&full_path).await.unwrap_or(false) => continue, - Err(e) => { - let msg = if quiet { - String::new() - } else { - format!("mktemp: failed to create directory '{}': {}\n", path, e) - }; - return Ok(ExecResult::err(msg, 1)); - } - } + let created = if create_dir { + ctx.fs.mkdir(&full_path, false).await } else { - match ctx.fs.write_file(&full_path, &[]).await { - Ok(_) => return Ok(ExecResult::ok(format!("{}\n", path))), - Err(_) if ctx.fs.exists(&full_path).await.unwrap_or(false) => continue, - Err(e) => { - let msg = if quiet { - String::new() - } else { - format!("mktemp: failed to create file '{}': {}\n", path, e) - }; - return Ok(ExecResult::err(msg, 1)); - } + ctx.fs.write_file(&full_path, &[]).await + }; + match created { + Ok(_) => return Ok(ExecResult::ok(format!("{shown}\n"))), + Err(_) if ctx.fs.exists(&full_path).await.unwrap_or(false) => continue, + Err(_) => { + let what = if create_dir { "directory" } else { "file" }; + let parent_missing = match full_path.parent() { + Some(p) => !ctx.fs.exists(p).await.unwrap_or(false), + None => false, + }; + let reason = if parent_missing { + "No such file or directory" + } else { + "Permission denied" + }; + return Ok(fail(format!( + "mktemp: failed to create {what} via template '{shown_template}': {reason}\n" + ))); } } } - let msg = if quiet { - String::new() - } else { - "mktemp: failed to create unique temporary path after 64 attempts\n".to_string() - }; - Ok(ExecResult::err(msg, 1)) + Ok(fail(format!( + "mktemp: failed to create file via template '{shown_template}': File exists\n" + ))) } } #[cfg(test)] @@ -1536,15 +1690,22 @@ mod tests { } #[test] - fn test_mktemp_name_template_replaces_xxxxxx() { - let name = mktemp_name(Some("/tmp/myapp.XXXXXX"), "abcdef1234"); - assert_eq!(name, "/tmp/myapp.abcdef"); + fn test_mktemp_split_template() { + assert_eq!( + mktemp_split("/tmp/myapp.XXXXXX", None), + Ok(("/tmp/myapp.", 6, "")) + ); + assert_eq!(mktemp_split("aXXXb.txt", None), Ok(("a", 3, "b.txt"))); + assert_eq!(mktemp_split("aXXXX", Some(".c")), Ok(("a", 4, ".c"))); + assert!(mktemp_split("/tmp/myapp", None).is_err()); + assert!(mktemp_split("aXX", None).is_err()); } #[test] - fn test_mktemp_name_template_without_xxxxxx_appends_suffix() { - let name = mktemp_name(Some("/tmp/myapp"), "abcdef1234"); - assert_eq!(name, "/tmp/myapp.abcdef"); + fn test_mktemp_random_charset() { + let r = mktemp_random(0, 32); + assert_eq!(r.len(), 32); + assert!(r.bytes().all(|b| b.is_ascii_alphanumeric())); } async fn run_fileop(builtin: &B, args: &[&str]) -> ExecResult { diff --git a/crates/bashkit/src/builtins/find/mod.rs b/crates/bashkit/src/builtins/find/mod.rs index 322073d27..19220b999 100644 --- a/crates/bashkit/src/builtins/find/mod.rs +++ b/crates/bashkit/src/builtins/find/mod.rs @@ -20,7 +20,7 @@ //! so those read the modification time. //! - THREAT[TM-DOS-121]: `-L` loop detection, budget-charged listings, capped //! output, bounded `{} +` batches. -//! - `-ok`/`-okdir`, `-ls`, `-fprint*`, `-samefile`, `-inum`, `-links`, +//! - `-ok`/`-okdir`, `-samefile`, `-inum`, `-links`, //! `-fstype`, `-context` are not implemented (L-FIND-001). mod parse; @@ -262,6 +262,7 @@ struct Pending { enum Effect { Out(String), + FileOut(usize, String), ExecOut(usize), DeleteMsg(usize), Batch { @@ -315,7 +316,13 @@ type EvalFuture<'a> = std::pin::Pin< fn exec_path(entry: &Entry, dir: bool) -> String { if dir { - format!("./{}", entry.name()) + // GNU leaves the root `/` unprefixed (its base name is absolute). + let name = entry.name(); + if name.starts_with('/') { + name.to_string() + } else { + format!("./{name}") + } } else { entry.display.clone() } @@ -447,6 +454,16 @@ fn eval<'a, 'b: 'a>(e: &'a Expr, cx: &'a mut Cx<'b>) -> EvalFuture<'a> { .push(Effect::Out(printf::render(fmt, entry, start, &cx.cfg.fmt))); true } + Expr::ToFile { file, action } => { + let first = cx.effects.len(); + let ok = eval(action, cx).await?; + for effect in &mut cx.effects[first..] { + if let Effect::Out(text) = effect { + *effect = Effect::FileOut(*file, std::mem::take(text)); + } + } + ok + } Expr::Ls => { // GNU find's -ls layout (inode, 1K blocks, mode, links, owner, // group, size, mtime, path, `-> target` for symlinks). @@ -544,6 +561,8 @@ pub(super) struct FindRun { quit: bool, stdout: StreamData, stderr: StreamData, + /// `-fprint*`/`-fls` targets and the text buffered for each. + out_files: Vec<(PathBuf, String)>, own_bytes: usize, output_cap: usize, failed: bool, @@ -560,8 +579,18 @@ impl FindRun { paths, mut expr, opts, + out_files: out_names, .. } = parsed; + // GNU opens (creating/truncating) -fprint targets before the walk. + let mut out_files = Vec::with_capacity(out_names.len()); + for name in out_names { + let path = resolve_path(ctx.cwd, &name); + if let Err(e) = ctx.fs.write_file(&path, b"").await { + return Err(format!("find: '{name}': {e}\n")); + } + out_files.push((path, String::new())); + } // Resolve -newer reference files before traversal (GNU fails early). let mut refs: Vec = Vec::new(); expr.walk(&mut |e| { @@ -655,6 +684,7 @@ impl FindRun { quit: false, stdout: StreamData::new(), stderr: StreamData::new(), + out_files, own_bytes: 0, output_cap: FIND_MAX_OUTPUT_BYTES, failed: false, @@ -707,6 +737,17 @@ impl FindRun { self.own_bytes += text.len(); self.stdout.push_str(&text); } + Effect::FileOut(idx, text) => { + if self.own_bytes + text.len() > self.output_cap { + self.error(OUTPUT_CAP_MSG.to_string()); + self.quit = true; + return; + } + self.own_bytes += text.len(); + if let Some((_, buf)) = self.out_files.get_mut(idx) { + buf.push_str(&text); + } + } Effect::ExecOut(id) => { if let Some(Cached::Exec { out, err, .. }) = cache.get(&id) { self.stdout.append(out); @@ -1004,6 +1045,13 @@ impl PlanDriver for FindRun { } continue; } + for (path, buf) in std::mem::take(&mut self.out_files) { + if !buf.is_empty() + && let Err(e) = self.cfg.fs.write_file(&path, buf.as_bytes()).await + { + self.error(format!("find: '{}': {e}\n", path.display())); + } + } return Ok(PlanStep::Done(self.finish())); } } diff --git a/crates/bashkit/src/builtins/find/parse.rs b/crates/bashkit/src/builtins/find/parse.rs index a35255c9e..f074b964b 100644 --- a/crates/bashkit/src/builtins/find/parse.rs +++ b/crates/bashkit/src/builtins/find/parse.rs @@ -111,6 +111,12 @@ pub(super) enum Expr { Printf(String), /// `-ls`: GNU's `ls -dils` style line. Ls, + /// `-fprint`/`-fprint0`/`-fprintf`/`-fls`: the inner print action, with + /// its output redirected to `Parsed::out_files[file]`. + ToFile { + file: usize, + action: Box, + }, Delete { id: usize, }, @@ -137,7 +143,7 @@ impl Expr { a.walk(f); b.walk(f); } - Expr::Not(a) => a.walk(f), + Expr::Not(a) | Expr::ToFile { action: a, .. } => a.walk(f), _ => {} } } @@ -149,7 +155,7 @@ impl Expr { a.walk_mut(f); b.walk_mut(f); } - Expr::Not(a) => a.walk_mut(f), + Expr::Not(a) | Expr::ToFile { action: a, .. } => a.walk_mut(f), _ => {} } } @@ -179,6 +185,9 @@ pub(super) struct Parsed { pub expr: Expr, pub opts: Options, pub has_exec: bool, + /// `-fprint*`/`-fls` targets, truncated before the walk (as GNU opens + /// them while parsing). Repeated names share one entry. + pub out_files: Vec, } /// Identity the ownership tests compare against (the single virtual user). @@ -204,6 +213,7 @@ struct Parser<'a> { has_exec: bool, next_id: usize, ident: &'a Identity<'a>, + out_files: Vec, } type PResult = std::result::Result; @@ -245,6 +255,7 @@ pub(super) fn parse(args: &[String], ident: &Identity<'_>) -> PResult { has_exec: false, next_id: 0, ident, + out_files: Vec::new(), }; let expr = if p.toks.is_empty() { None @@ -269,6 +280,7 @@ pub(super) fn parse(args: &[String], ident: &Identity<'_>) -> PResult { expr, opts: p.opts, has_exec: p.has_exec, + out_files: p.out_files, }) } @@ -762,6 +774,28 @@ impl Parser<'_> { self.has_action = true; Expr::Ls } + "-fprint" | "-fprint0" | "-fprintf" | "-fls" => { + self.has_action = true; + let name = self.arg(tok)?; + let action = match tok { + "-fprintf" => Expr::Printf(self.arg(tok)?), + "-fls" => Expr::Ls, + _ => Expr::Print { + nul: tok == "-fprint0", + }, + }; + let file = match self.out_files.iter().position(|f| *f == name) { + Some(i) => i, + None => { + self.out_files.push(name); + self.out_files.len() - 1 + } + }; + Expr::ToFile { + file, + action: Box::new(action), + } + } "-delete" => { self.has_action = true; self.opts.depth_first = true; diff --git a/crates/bashkit/src/builtins/fold.rs b/crates/bashkit/src/builtins/fold.rs index 451989fb3..7889bc135 100644 --- a/crates/bashkit/src/builtins/fold.rs +++ b/crates/bashkit/src/builtins/fold.rs @@ -1,7 +1,13 @@ //! fold builtin command - wrap lines at specified width +//! +//! Decision: a port of GNU fold's column state machine. Backspace moves one +//! column left, CR resets to column 0, TAB advances to the next multiple of +//! 8, and a character that alone overflows an empty line is kept on it. Each +//! char counts one column (no wide-char widths); `-b` counts UTF-8 bytes. use async_trait::async_trait; +use super::arg_parser::{OptArg, gnu_getopt}; use super::{Builtin, BuiltinHelper, Context, read_text_file, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -20,126 +26,142 @@ impl BuiltinHelper for Fold { const NAME: &'static str = "fold"; } +const TAB_WIDTH: usize = 8; + +fn adjust_column(column: usize, c: char, count_bytes: bool) -> usize { + if count_bytes { + return column + c.len_utf8(); + } + match c { + '\x08' => column.saturating_sub(1), + '\r' => 0, + '\t' => column + TAB_WIDTH - column % TAB_WIDTH, + _ => column + 1, + } +} + +fn fold_text(text: &str, width: usize, break_spaces: bool, count_bytes: bool, out: &mut String) { + let mut line: Vec = Vec::new(); + let mut column = 0usize; + for c in text.chars() { + if c == '\n' { + out.extend(line.drain(..)); + out.push('\n'); + column = 0; + continue; + } + loop { + column = adjust_column(column, c, count_bytes); + if column <= width { + line.push(c); + break; + } + if break_spaces && let Some(blank) = line.iter().rposition(|&b| b == ' ' || b == '\t') { + let rest = line.split_off(blank + 1); + out.extend(line.drain(..)); + out.push('\n'); + line = rest; + column = line + .iter() + .fold(0, |col, &ch| adjust_column(col, ch, count_bytes)); + continue; + } + if line.is_empty() { + line.push(c); + break; + } + out.extend(line.drain(..)); + out.push('\n'); + column = 0; + } + } + out.extend(line); +} + +/// Rewrite the obsolete `-NUM` form into `-wNUM`. +fn normalize_args(args: &[String]) -> Vec { + let mut out = Vec::with_capacity(args.len()); + let mut done = false; + for a in args { + if !done && a == "--" { + done = true; + } + let digits = a.strip_prefix('-').filter(|d| !d.is_empty()); + match digits { + Some(d) if !done && d.bytes().all(|b| b.is_ascii_digit()) => { + out.push(format!("-w{d}")); + } + _ => out.push(a.clone()), + } + } + out +} + #[async_trait] impl Builtin for Fold { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = Self::check_help( ctx.args, - "Usage: fold [OPTION]... [FILE]...\nWrap each input line to fit in specified width.\n\n -b\t\tcount bytes rather than columns\n -s\t\tbreak at spaces\n -w WIDTH\tuse WIDTH columns instead of 80\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: fold [OPTION]... [FILE]...\nWrap each input line to fit in specified width.\n\n -b, --bytes\tcount bytes rather than columns\n -s, --spaces\tbreak at spaces\n -w, --width=WIDTH\tuse WIDTH columns instead of 80\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("fold (bashkit) 0.1"), ) { return Ok(r); } + let args = normalize_args(ctx.args); + let (opts, mut files) = match gnu_getopt( + "fold", + &args, + "bsw:", + &[ + ("bytes", OptArg::No, 'b'), + ("spaces", OptArg::No, 's'), + ("width", OptArg::Required, 'w'), + ], + true, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; let mut width: usize = 80; - let mut break_at_spaces = false; - let mut files: Vec<&str> = Vec::new(); - - let mut i = 0; - while i < ctx.args.len() { - match ctx.args[i].as_str() { - "-s" => break_at_spaces = true, - "-b" => { /* byte mode is default for us since we use chars */ } - "-w" => { - i += 1; - if i >= ctx.args.len() { - return Ok(Self::err("option requires an argument -- 'w'", 1)); - } - width = ctx.args[i].parse().unwrap_or(80); - } - s if s.starts_with("-w") && s.len() > 2 => { - width = s[2..].parse().unwrap_or(80); + let mut break_spaces = false; + let mut count_bytes = false; + for o in opts { + match o.key { + 'b' => count_bytes = true, + 's' => break_spaces = true, + _ => { + let val = o.value.unwrap_or_default(); + width = match val.parse::() { + Ok(w) if w > 0 => w, + _ => { + return Ok(Self::err(format!("invalid number of columns: '{val}'"), 1)); + } + }; } - // Reject unknown options; `-`/`--` and operands fall through. - s if s.starts_with('-') && s.len() > 1 && s != "--" => { - return Ok(super::invalid_option("fold", s, 1)); - } - _ => files.push(&ctx.args[i]), } - i += 1; } - - if width == 0 { - width = 1; // prevent infinite loop + if files.is_empty() { + files.push("-".to_string()); } - let input = if files.is_empty() { - ctx.stdin.map(ToString::to_string).unwrap_or_default() - } else { - let mut buf = String::new(); - for file in &files { - let path = resolve_path(ctx.cwd, file); - match read_text_file(ctx.fs.as_ref(), &path, "fold").await { - Ok(text) => buf.push_str(&text), - Err(_) => { - return Ok(Self::err_path(file, "No such file or directory", 1)); - } - } - } - buf - }; - let mut output = String::new(); - let lines: Vec<&str> = input.split('\n').collect(); - for (i, line) in lines.iter().enumerate() { - fold_line(line, width, break_at_spaces, &mut output); - if i < lines.len() - 1 { - output.push('\n'); - } - } - // Preserve trailing newline if input had one - if input.ends_with('\n') && !output.ends_with('\n') { - output.push('\n'); - } - - Ok(ExecResult::ok(output)) - } -} - -fn fold_line(line: &str, width: usize, break_at_spaces: bool, output: &mut String) { - if line.len() <= width { - output.push_str(line); - return; - } - - let chars: Vec = line.chars().collect(); - let mut pos = 0; - - while pos < chars.len() { - let remaining = chars.len() - pos; - if remaining <= width { - for ch in &chars[pos..] { - output.push(*ch); + for file in &files { + if file == "-" { + let stdin = ctx.stdin.map(ToString::to_string).unwrap_or_default(); + fold_text(&stdin, width, break_spaces, count_bytes, &mut output); + continue; } - break; - } - - let end = pos + width; - if break_at_spaces { - // Find last space within the width - let mut break_pos = end; - let mut found = false; - for j in (pos..end).rev() { - if chars[j] == ' ' { - break_pos = j + 1; - found = true; - break; + let path = resolve_path(ctx.cwd, file); + match read_text_file(ctx.fs.as_ref(), &path, "fold").await { + Ok(text) => fold_text(&text, width, break_spaces, count_bytes, &mut output), + Err(_) => { + return Ok(Self::err_path(file, "No such file or directory", 1)); } } - if !found { - break_pos = end; - } - for ch in &chars[pos..break_pos] { - output.push(*ch); - } - output.push('\n'); - pos = break_pos; - } else { - for ch in &chars[pos..end] { - output.push(*ch); - } - output.push('\n'); - pos = end; } + + Ok(ExecResult::ok(output)) } } @@ -225,4 +247,35 @@ mod tests { let result = run_fold(&[], Some("")).await; assert_eq!(result.exit_code, 0); } + + #[tokio::test] + async fn test_fold_tab_counts_to_next_stop() { + let result = run_fold(&["-w", "4"], Some("\tab\n")).await; + assert_eq!(result.stdout, "\t\nab\n"); + } + + #[tokio::test] + async fn test_fold_backspace_moves_left() { + let result = run_fold(&["-w", "3"], Some("\x08abcde\n")).await; + assert_eq!(result.stdout, "\x08abc\nde\n"); + } + + #[tokio::test] + async fn test_fold_spaces_breaks_after_blank() { + let result = run_fold(&["-s", "-w", "6"], Some("ab cd efgh\n")).await; + assert_eq!(result.stdout, "ab cd \nefgh\n"); + } + + #[tokio::test] + async fn test_fold_obsolete_width() { + let result = run_fold(&["-3"], Some("abcdef\n")).await; + assert_eq!(result.stdout, "abc\ndef\n"); + } + + #[tokio::test] + async fn test_fold_invalid_width() { + let result = run_fold(&["-w", "0"], Some("a\n")).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("invalid number of columns: '0'")); + } } diff --git a/crates/bashkit/src/builtins/grep.rs b/crates/bashkit/src/builtins/grep.rs index 911b8ede9..be669ee23 100644 --- a/crates/bashkit/src/builtins/grep.rs +++ b/crates/bashkit/src/builtins/grep.rs @@ -361,34 +361,25 @@ impl GrepOptions { } fn build_matcher(&self) -> Result { - // Build patterns for each -e pattern - let escaped_patterns: Vec = self - .patterns + // GNU grep treats each newline-separated piece as its own pattern. + let pieces: Vec<&str> = self.patterns.iter().flat_map(|p| p.split('\n')).collect(); + let escaped_patterns: Vec = pieces .iter() .map(|p| { - // Empty pattern matches everything (like .*) + // Empty pattern matches everything if p.is_empty() { - return ".*".to_string(); + return String::new(); } - let pat = if self.fixed_strings { + if self.fixed_strings { regex::escape(p) } else if self.perl_regex { // PCRE mode (-P): pass through to fancy-regex unchanged so // lookaround / backreferences are preserved. - p.clone() - } else if !self.extended_regex { - // BRE mode: convert to ERE for the regex crate - // In BRE: ( ) are literal, \( \) are groups - // In ERE/regex crate: ( ) are groups, \( \) are literal - bre_to_ere(p) + p.to_string() } else { - p.clone() - }; - // Wrap with word boundaries if -w flag is set - if self.word_regex { - format!(r"\b{}\b", pat) - } else { - pat + // POSIX BRE/ERE (shared with sed): positional `* ^ $`, + // literal `+ ? | ( ) { }` in BRE, bracket escaping. + super::sed::translate_posix_regex(p, self.extended_regex) } }) .collect(); @@ -404,6 +395,14 @@ impl GrepOptions { .join("|") }; + // -w: the match must not touch a word constituent on either side + // (GNU semantics; `\b` would accept `foo.` inside `foo.bar`). + let combined = if self.word_regex { + format!(r"(? Ok(Matcher::Standard(re)), + Err(e) if !self.fixed_strings && e.to_string().contains("backreference") => { + build_fancy_matcher(&final_pattern, self.ignore_case) + .map_err(|e| invalid(e.to_string())) + } + Err(e) => Err(invalid(e.to_string())), } } } @@ -518,43 +521,6 @@ fn process_content(content: Vec, _binary_as_text: bool) -> String { String::from_utf8_lossy(&content).into_owned() } -/// Convert a BRE (Basic Regular Expression) pattern to ERE for the regex crate. -/// In BRE: ( ) { } are literal; \( \) \{ \} \+ \? \| are metacharacters. -/// In ERE/regex crate: ( ) { } + ? | are metacharacters. -fn bre_to_ere(pattern: &str) -> String { - let mut result = String::with_capacity(pattern.len()); - let chars: Vec = pattern.chars().collect(); - let mut i = 0; - - while i < chars.len() { - if chars[i] == '\\' && i + 1 < chars.len() { - match chars[i + 1] { - // BRE escaped metacharacters → ERE unescaped - '(' | ')' | '{' | '}' | '+' | '?' | '|' => { - result.push(chars[i + 1]); - i += 2; - } - // Other escapes pass through - _ => { - result.push('\\'); - result.push(chars[i + 1]); - i += 2; - } - } - } else if chars[i] == '(' || chars[i] == ')' || chars[i] == '{' || chars[i] == '}' { - // BRE literal chars → escape them for ERE - result.push('\\'); - result.push(chars[i]); - i += 1; - } else { - result.push(chars[i]); - i += 1; - } - } - - result -} - #[async_trait] impl Builtin for Grep { async fn execute(&self, ctx: Context<'_>) -> Result { @@ -970,6 +936,10 @@ impl Builtin for Grep { let mut o_matches = 0usize; for (line_num, line) in lines.iter().enumerate() { matcher.for_each_range(line, |(start, end)| { + // GNU -o skips empty matches. + if start == end { + return true; + } if let Some(max) = opts.max_count && o_matches >= max { @@ -1297,16 +1267,12 @@ async fn try_indexed_search( return None; } - let pattern = if opts.extended_regex { - opts.patterns[0].clone() - } else { - bre_to_ere(&opts.patterns[0]) - }; - let pattern = if opts.word_regex { - format!(r"\b{}\b", pattern) - } else { - pattern - }; + // -w needs look-around and newlines split patterns: let the + // regular path handle both. + if opts.word_regex || opts.patterns[0].contains('\n') { + return None; + } + let pattern = super::sed::translate_posix_regex(&opts.patterns[0], opts.extended_regex); if opts.whole_line { format!("^(?:{})$", pattern) } else { @@ -1658,6 +1624,42 @@ mod tests { assert_eq!(result.stdout, ""); } + #[tokio::test] + async fn test_grep_word_match_checks_both_edges() { + let r = run_grep(&["-wF", "foo."], Some("foo.bar\nfoo.\n")) + .await + .unwrap(); + assert_eq!(r.stdout, "foo.\n"); + let r = run_grep(&["-wE", "a|b"], Some("ab\nxa\nb\n")) + .await + .unwrap(); + assert_eq!(r.stdout, "b\n"); + } + + #[tokio::test] + async fn test_grep_bre_semantics() { + let r = run_grep(&["a+b"], Some("a+b\nab\n")).await.unwrap(); + assert_eq!(r.stdout, "a+b\n"); + let r = run_grep(&["\\(ab\\)\\1"], Some("abab\nabba\n")) + .await + .unwrap(); + assert_eq!(r.stdout, "abab\n"); + let r = run_grep(&["*a"], Some("*a\nb\n")).await.unwrap(); + assert_eq!(r.stdout, "*a\n"); + let r = run_grep(&["-E", "-G", "a|b"], Some("a|b\na\n")) + .await + .unwrap(); + assert_eq!(r.stdout, "a|b\n"); + } + + #[tokio::test] + async fn test_grep_newline_separates_patterns_and_o_skips_empty() { + let r = run_grep(&["a\nc"], Some("a\nb\nc\n")).await.unwrap(); + assert_eq!(r.stdout, "a\nc\n"); + let r = run_grep(&["-o", "b*"], Some("aaa\n")).await.unwrap(); + assert_eq!(r.stdout, ""); + } + #[tokio::test] async fn test_grep_files_with_matches_stdin() { let result = run_grep(&["-l", "foo"], Some("foo\nbar")).await.unwrap(); @@ -2643,21 +2645,24 @@ mod tests { #[tokio::test] async fn test_grep_pattern_type_perl_then_extended_last_wins() { - // -P then -E: extended wins, so -E cleared perl_regex and the - // backreference is rejected by the linear-time engine (error). - let result = run_grep(&["-P", "-E", r"(.)\1"], Some("aa")).await; - assert!(result.is_err()); + // -P then -E: extended wins, so `\d` is POSIX (a literal `d`), + // not the PCRE digit class. + let result = run_grep(&["-P", "-E", r"\d"], Some("1\nd\n")) + .await + .unwrap(); + assert_eq!(result.stdout, "d\n"); } #[tokio::test] async fn test_grep_pattern_type_long_options_last_wins() { - // --perl-regexp then --extended-regexp: extended wins -> backref rejected. + // --perl-regexp then --extended-regexp: extended wins -> POSIX `\d`. let result = run_grep( - &["--perl-regexp", "--extended-regexp", r"(.)\1"], - Some("aa"), + &["--perl-regexp", "--extended-regexp", r"\d"], + Some("1\nd\n"), ) - .await; - assert!(result.is_err()); + .await + .unwrap(); + assert_eq!(result.stdout, "d\n"); } #[tokio::test] diff --git a/crates/bashkit/src/builtins/headtail.rs b/crates/bashkit/src/builtins/headtail.rs index 1700b0ea9..6ec177077 100644 --- a/crates/bashkit/src/builtins/headtail.rs +++ b/crates/bashkit/src/builtins/headtail.rs @@ -120,14 +120,24 @@ fn parse_selection(cmd: &str, val: &str, unit: Unit) -> std::result::Result NAME <==` headers. +#[derive(Clone, Copy, PartialEq)] +enum Headers { + Auto, + Never, + Always, +} + /// Parsed head/tail command line. struct Options { selection: Selection, unit: Unit, + headers: Headers, files: Vec, } -/// Parse head/tail arguments (`-n`, `-c`, obsolete `-NUM`, files). +/// Parse head/tail arguments: GNU getopt surface (`-n`, `-c`, `-q`, `-v`, +/// their long forms, bundles) plus the obsolete `-NUM` line count. #[allow(clippy::result_large_err)] fn parse_args(cmd: &str, args: &[String]) -> std::result::Result { let default = if cmd == "head" { @@ -138,45 +148,68 @@ fn parse_args(cmd: &str, args: &[String]) -> std::result::Result() + // Obsolete `-NUM` is rewritten to `-nNUM` before getopt sees it. + // A `-NUM` that is the value of a preceding `-n`/`-c` stays as is. + let wants_value = |prev: &str| { + matches!(prev, "--lines" | "--bytes") + || (prev.len() > 1 + && !prev.starts_with("--") + && prev.starts_with('-') + && prev.ends_with(['n', 'c']) + && prev[1..].bytes().all(|b| b"cnqv".contains(&b))) + }; + let mut normalized = Vec::with_capacity(args.len()); + let mut ended = false; + for (i, arg) in args.iter().enumerate() { + if !ended && arg == "--" { + ended = true; + } + let is_value = i > 0 && wants_value(&args[i - 1]); + match arg.strip_prefix('-') { + Some(num) + if !ended + && !is_value + && !num.is_empty() + && num.bytes().all(|b| b.is_ascii_digit()) => { - // Obsolete `-NUM` line-count form. - opts.selection = if cmd == "head" { - Selection::First(n) + normalized.push(format!("-n{num}")); + } + _ => normalized.push(arg.clone()), + } + } + let (parsed, files) = super::arg_parser::gnu_getopt( + cmd, + &normalized, + "c:n:qv", + &[ + ("bytes", super::arg_parser::OptArg::Required, 'c'), + ("lines", super::arg_parser::OptArg::Required, 'n'), + ("quiet", super::arg_parser::OptArg::No, 'q'), + ("silent", super::arg_parser::OptArg::No, 'q'), + ("verbose", super::arg_parser::OptArg::No, 'v'), + ], + true, + 1, + )?; + for o in parsed { + match o.key { + 'n' | 'c' => { + let unit = if o.key == 'n' { + Unit::Lines } else { - Selection::Last(n) + Unit::Bytes }; - opts.unit = Unit::Lines; - p.advance(); - } else if arg == "-" { - // "-" is the stdin operand. - opts.files.push(arg.to_string()); - p.advance(); - } else if arg == "--" { - // "--" ends options. - p.advance(); - } else { - // Unknown option-shaped token (e.g. -Q) → reject like GNU. - return Err(super::invalid_option(cmd, arg, 1)); + opts.selection = parse_selection(cmd, &o.value.unwrap_or_default(), unit)?; + opts.unit = unit; } - } else if let Some(arg) = p.positional() { - opts.files.push(arg.to_string()); + 'q' => opts.headers = Headers::Never, + _ => opts.headers = Headers::Always, } } - + opts.files = files; Ok(opts) } @@ -247,16 +280,18 @@ async fn run(cmd: &str, ctx: Context<'_>) -> Result { Err(e) => return Ok(e), }; + let mut opts = opts; if opts.files.is_empty() { - let data = ctx.stdin.map(|s| s.as_bytes().to_vec()).unwrap_or_default(); - return Ok(ExecResult::ok_bytes( - select(&data, opts.selection, opts.unit).to_vec(), - )); + opts.files.push("-".to_string()); } let mut out = Vec::new(); let mut stderr = String::new(); - let multiple_files = opts.files.len() > 1; + let multiple_files = match opts.headers { + Headers::Auto => opts.files.len() > 1, + Headers::Never => false, + Headers::Always => true, + }; let mut printed_any = false; for file in &opts.files { let data = if file == "-" { @@ -619,4 +654,23 @@ mod tests { assert_eq!(result.exit_code, 0); assert_eq!(result.stdout, "a\nb\nc\n"); } + + #[tokio::test] + async fn test_long_options_and_bundles() { + let result = run_tail(&["--bytes=2"], Some("a\nb\n")).await; + assert_eq!(result.stdout, "b\n"); + let result = run_head(&["--lines", "1"], Some("a\nb\n")).await; + assert_eq!(result.stdout, "a\n"); + let result = run_head(&["-vn1"], Some("a\nb\n")).await; + assert_eq!(result.stdout, "==> standard input <==\na\n"); + } + + #[tokio::test] + async fn test_obsolete_count_vs_option_value() { + let result = run_head(&["-2"], Some("a\nb\nc\n")).await; + assert_eq!(result.stdout, "a\nb\n"); + // `-1` here is the value of `-n`, not an obsolete count. + let result = run_head(&["-n", "-1"], Some("a\nb\nc\n")).await; + assert_eq!(result.stdout, "a\nb\n"); + } } diff --git a/crates/bashkit/src/builtins/hextools.rs b/crates/bashkit/src/builtins/hextools.rs index 8629c1a03..a6d5874dd 100644 --- a/crates/bashkit/src/builtins/hextools.rs +++ b/crates/bashkit/src/builtins/hextools.rs @@ -13,9 +13,9 @@ use crate::interpreter::ExecResult; /// Argument surface is generated from uutils/coreutils' `uu_app()` via /// the `bashkit-coreutils-port` codegen tool — see /// `generated/od_args.rs`. Behaviour is implemented locally against -/// the bashkit VFS. Subset honoured today: `-A RADIX`, `-t TYPE`, -/// `-N COUNT`, `-j SKIP`, `-w COLS`, plus `-x`/`-c`/`-d`/`-o` -/// shorthands. +/// the bashkit VFS: `-A RADIX`, repeated `-t TYPE` (a, c, d, o, u, x with +/// sizes and the `z` trailer), the integer/char shorthands, `-N`, `-j`, +/// `-w`, `-v` and `--endian`. Floating-point types are rejected. pub struct Od; /// The xxd builtin - make a hexdump or do the reverse. @@ -42,13 +42,22 @@ pub struct Xxd; pub struct Hexdump; // --- Od implementation --- +// +// Decision: a port of GNU od's block layout. Every `-t` spec (and shorthand +// such as `-x` = `-t x2`) prints its own line per block in command-line +// order; fields share GNU's width-per-block padding so specs stay aligned; a +// short final block is zero-filled; `z` appends a `>...<` trailer; repeated +// full blocks collapse to `*` unless `-v`. Floating-point types (`f`, `-e`, +// `-f`, `-F`) are rejected with an explicit error (not yet ported). struct OdOptions { addr_radix: AddrRadix, - output_type: OutputType, + specs: Vec, count: Option, skip: usize, - width: usize, + width: Option, + big_endian: bool, + show_duplicates: bool, } #[derive(Clone, Copy)] @@ -59,27 +68,155 @@ enum AddrRadix { None, } -#[derive(Clone, Copy)] -enum OutputType { +#[derive(Clone, Copy, PartialEq)] +enum OdKind { + Named, + Char, + Signed, + Unsigned, Octal, Hex, - Decimal, - Char, +} + +#[derive(Clone, Copy)] +struct OdSpec { + kind: OdKind, + size: usize, + trailer: bool, +} + +impl OdSpec { + /// GNU field width: the widest value of this type, without the separator. + fn field_width(&self) -> usize { + let idx = match self.size { + 1 => 0, + 2 => 1, + 4 => 2, + _ => 3, + }; + match self.kind { + OdKind::Named | OdKind::Char => 3, + OdKind::Signed => [4, 6, 11, 20][idx], + OdKind::Unsigned => [3, 5, 10, 20][idx], + OdKind::Octal => [3, 6, 11, 22][idx], + OdKind::Hex => [2, 4, 8, 16][idx], + } + } +} + +/// Parse one `-t` TYPE string, which may hold several specs (`-t ox1z`). +fn parse_od_type(s: &str) -> std::result::Result, String> { + let b = s.as_bytes(); + let mut specs = Vec::new(); + let mut i = 0; + while i < b.len() { + let c = b[i]; + i += 1; + let (kind, size) = match c { + b'a' => (OdKind::Named, 1), + b'c' => (OdKind::Char, 1), + b'd' | b'o' | b'u' | b'x' => { + let kind = match c { + b'd' => OdKind::Signed, + b'o' => OdKind::Octal, + b'u' => OdKind::Unsigned, + _ => OdKind::Hex, + }; + let size = match b.get(i) { + Some(b'C') => { + i += 1; + 1 + } + Some(b'S') => { + i += 1; + 2 + } + Some(b'I') => { + i += 1; + 4 + } + Some(b'L') => { + i += 1; + 8 + } + Some(d) if d.is_ascii_digit() => { + let n = b[i..].iter().take_while(|d| d.is_ascii_digit()).count(); + let size: usize = s[i..i + n].parse().unwrap_or(0); + i += n; + size + } + _ => 4, + }; + if !matches!(size, 1 | 2 | 4 | 8) { + return Err(format!( + "od: invalid type string '{s}';\nthis system doesn't provide a {size}-byte integral type" + )); + } + (kind, size) + } + b'f' => { + return Err(format!( + "od: floating-point type string '{s}' is not supported in bashkit" + )); + } + _ => { + return Err(format!( + "od: invalid character '{}' in type string '{s}'", + c as char + )); + } + }; + let trailer = b.get(i) == Some(&b'z'); + if trailer { + i += 1; + } + specs.push(OdSpec { + kind, + size, + trailer, + }); + } + if specs.is_empty() { + return Err(format!("od: invalid type string '{s}'")); + } + Ok(specs) +} + +/// GNU od number operand: `0x` hex, leading-`0` octal, or decimal, with an +/// optional `b`/`k`/`m` multiplier. +fn parse_od_num(s: &str, what: &str) -> std::result::Result { + let err = || format!("od: invalid {what} argument '{s}'"); + let (digits, mult) = match s.as_bytes().last() { + Some(b'b') if !s.starts_with("0x") && !s.starts_with("0X") => (&s[..s.len() - 1], 512), + Some(b'k' | b'K') => (&s[..s.len() - 1], 1024), + Some(b'm' | b'M') => (&s[..s.len() - 1], 1024 * 1024), + _ => (s, 1), + }; + let value = if let Some(hex) = digits + .strip_prefix("0x") + .or_else(|| digits.strip_prefix("0X")) + { + usize::from_str_radix(hex, 16).map_err(|_| err())? + } else if digits.len() > 1 && digits.starts_with('0') { + usize::from_str_radix(&digits[1..], 8).map_err(|_| err())? + } else { + digits.parse::().map_err(|_| err())? + }; + value.checked_mul(mult).ok_or_else(err) } /// Translate clap-parsed `od` matches into the local rendering struct. -/// The clap surface is generated from uutils; we only honour the subset -/// the bashkit VFS dump supports — other flags are accepted (so unknown -/// flags still error cleanly) but ignored. fn od_options_from_matches( matches: &clap::ArgMatches, ) -> std::result::Result<(OdOptions, Vec), String> { let mut opts = OdOptions { addr_radix: AddrRadix::Octal, - output_type: OutputType::Octal, + specs: Vec::new(), count: None, skip: 0, - width: 16, + width: None, + big_endian: false, + show_duplicates: matches.get_flag("output-duplicates"), }; if let Some(radix) = matches.get_one::("address-radix") { @@ -91,55 +228,67 @@ fn od_options_from_matches( other => return Err(format!("od: invalid address radix: '{}'", other)), }; } - if let Some(mut formats) = matches.get_many::("format") { - // -t accepts a stack of types upstream; bashkit only honours the - // last one, matching the behaviour of the original handwritten - // path. Unknown TYPE letters surface as an explicit error. - if let Some(t) = formats.next_back() { - opts.output_type = match t.chars().next() { - Some('o') => OutputType::Octal, - Some('x') => OutputType::Hex, - Some('d') => OutputType::Decimal, - Some('c') => OutputType::Char, - _ => return Err(format!("od: invalid output type: '{}'", t)), - }; + + // Specs apply in command-line order, so collect (index, TYPE) pairs from + // both `-t` and the single-letter shorthands, then sort. + let mut ordered: Vec<(usize, String)> = Vec::new(); + if let (Some(idx), Some(vals)) = ( + matches.indices_of("format"), + matches.get_many::("format"), + ) { + ordered.extend(idx.zip(vals.cloned())); + } + const SHORTHANDS: &[(&str, &str)] = &[ + ("a", "a"), + ("b", "o1"), + ("c", "c"), + ("d", "u2"), + ("D", "u4"), + ("o", "o2"), + ("I", "dL"), + ("L", "dL"), + ("i", "dI"), + ("l", "dL"), + ("x", "x2"), + ("h", "x2"), + ("O", "o4"), + ("s", "d2"), + ("X", "x4"), + ("H", "x4"), + ("e", "fD"), + ("f", "fF"), + ("F", "fD"), + ]; + for (id, ty) in SHORTHANDS { + if matches.try_get_one::(id).ok().flatten().copied() == Some(true) + && let Some(idx) = matches.index_of(id) + { + ordered.push((idx, (*ty).to_string())); } } - if let Some(s) = matches.get_one::("read-bytes") { - opts.count = Some( - s.parse() - .map_err(|_| format!("od: invalid count: '{}'", s))?, - ); + ordered.sort_by_key(|(idx, _)| *idx); + for (_, ty) in &ordered { + opts.specs.extend(parse_od_type(ty)?); } - if let Some(s) = matches.get_one::("skip-bytes") { - opts.skip = s - .parse() - .map_err(|_| format!("od: invalid skip: '{}'", s))?; - } - if let Some(s) = matches.get_one::("width") { - let parsed: usize = s - .parse() - .map_err(|_| format!("od: invalid width: '{}'", s))?; - if parsed > 0 { - opts.width = parsed; - } + if opts.specs.is_empty() { + opts.specs.push(OdSpec { + kind: OdKind::Octal, + size: 2, + trailer: false, + }); } - // Single-letter shorthands: clap exposes each as its own SetTrue - // flag (see `generated/od_args.rs`). Honour the four bashkit - // supports today; the rest are accepted-but-ignored, matching the - // documented coverage. - if matches.try_get_one::("x").ok().flatten().copied() == Some(true) { - opts.output_type = OutputType::Hex; + if let Some(s) = matches.get_one::("read-bytes") { + opts.count = Some(parse_od_num(s, "-N")?); } - if matches.try_get_one::("c").ok().flatten().copied() == Some(true) { - opts.output_type = OutputType::Char; + if let Some(s) = matches.get_one::("skip-bytes") { + opts.skip = parse_od_num(s, "-j")?; } - if matches.try_get_one::("d").ok().flatten().copied() == Some(true) { - opts.output_type = OutputType::Decimal; + if let Some(s) = matches.get_one::("width") { + opts.width = Some(parse_od_num(s, "-w")?); } - if matches.try_get_one::("o").ok().flatten().copied() == Some(true) { - opts.output_type = OutputType::Octal; + if let Some(e) = matches.get_one::("endian") { + opts.big_endian = e == "big"; } let files: Vec = matches @@ -154,71 +303,150 @@ fn format_od_addr(offset: usize, radix: AddrRadix) -> String { match radix { AddrRadix::Octal => format!("{:07o}", offset), AddrRadix::Decimal => format!("{:07}", offset), - AddrRadix::Hex => format!("{:07x}", offset), + AddrRadix::Hex => format!("{:06x}", offset), AddrRadix::None => String::new(), } } -fn format_od_byte(byte: u8, output_type: OutputType) -> String { - match output_type { - OutputType::Octal => format!(" {:03o}", byte), - OutputType::Hex => format!(" {:02x}", byte), - OutputType::Decimal => format!(" {:3}", byte), - OutputType::Char => { - let c = match byte { - 0 => "\\0".to_string(), - 7 => "\\a".to_string(), - 8 => "\\b".to_string(), - 9 => "\\t".to_string(), - 10 => "\\n".to_string(), - 11 => "\\v".to_string(), - 12 => "\\f".to_string(), - 13 => "\\r".to_string(), - 0x20..=0x7e => (byte as char).to_string(), - _ => format!("{:03o}", byte), - }; - // GNU right-aligns each char in a 4-column cell. - format!("{c:>4}") +const OD_NAMES: [&str; 33] = [ + "nul", "soh", "stx", "etx", "eot", "enq", "ack", "bel", "bs", "ht", "nl", "vt", "ff", "cr", + "so", "si", "dle", "dc1", "dc2", "dc3", "dc4", "nak", "syn", "etb", "can", "em", "sub", "esc", + "fs", "gs", "rs", "us", "sp", +]; + +fn format_od_field(spec: &OdSpec, bytes: &[u8], big_endian: bool) -> String { + match spec.kind { + OdKind::Named => { + let b = bytes[0] & 0x7f; + match b { + 0..=32 => OD_NAMES[b as usize].to_string(), + 127 => "del".to_string(), + _ => (b as char).to_string(), + } + } + OdKind::Char => match bytes[0] { + 0 => "\\0".to_string(), + 7 => "\\a".to_string(), + 8 => "\\b".to_string(), + 9 => "\\t".to_string(), + 10 => "\\n".to_string(), + 11 => "\\v".to_string(), + 12 => "\\f".to_string(), + 13 => "\\r".to_string(), + b @ 0x20..=0x7e => (b as char).to_string(), + b => format!("{:03o}", b), + }, + _ => { + let mut v: u64 = 0; + if big_endian { + for &b in bytes { + v = (v << 8) | u64::from(b); + } + } else { + for &b in bytes.iter().rev() { + v = (v << 8) | u64::from(b); + } + } + let w = spec.field_width(); + match spec.kind { + OdKind::Octal => format!("{:0w$o}", v), + OdKind::Hex => format!("{:0w$x}", v), + OdKind::Unsigned => v.to_string(), + _ => { + let shift = 64 - 8 * spec.size as u32; + (((v << shift) as i64) >> shift).to_string() + } + } } } } fn od_dump(data: &[u8], opts: &OdOptions) -> String { - let bytes_per_line = opts.width; - let mut output = String::new(); - - let data = if opts.skip < data.len() { - &data[opts.skip..] - } else { - &[] + let lcm = opts.specs.iter().fold(1usize, |acc, s| { + let (mut a, mut b) = (acc, s.size); + while b != 0 { + (a, b) = (b, a % b); + } + acc / a * s.size + }); + let bpb = match opts.width { + Some(w) if w > 0 && w % lcm == 0 => w, + Some(_) => lcm, + None => lcm * (16 / lcm).max(1), }; - + let width_per_block = opts + .specs + .iter() + .map(|s| (s.field_width() + 1) * (bpb / s.size)) + .max() + .unwrap_or(0); + let addr_pad = format_od_addr(0, opts.addr_radix).len(); + + let data = data.get(opts.skip..).unwrap_or(&[]); let data = match opts.count { Some(n) => &data[..data.len().min(n)], None => data, }; - for (chunk_idx, chunk) in data.chunks(bytes_per_line).enumerate() { - let offset = opts.skip + chunk_idx * bytes_per_line; - let addr = format_od_addr(offset, opts.addr_radix); - if !addr.is_empty() { - output.push_str(&addr); + let mut output = String::new(); + let mut prev: Option<&[u8]> = None; + let mut in_dup_run = false; + for (chunk_idx, chunk) in data.chunks(bpb).enumerate() { + let offset = opts.skip + chunk_idx * bpb; + if !opts.show_duplicates && chunk.len() == bpb && prev == Some(chunk) { + if !in_dup_run { + output.push_str("*\n"); + in_dup_run = true; + } + continue; } - - for byte in chunk { - output.push_str(&format_od_byte(*byte, opts.output_type)); + in_dup_run = false; + prev = Some(chunk); + + let mut block = chunk.to_vec(); + block.resize(bpb, 0); + for (si, spec) in opts.specs.iter().enumerate() { + if si == 0 { + output.push_str(&format_od_addr(offset, opts.addr_radix)); + } else { + output.push_str(&" ".repeat(addr_pad)); + } + let fields = bpb / spec.size; + let blank = (bpb - chunk.len()) / spec.size; + let width = spec.field_width(); + let pad = width_per_block - width * fields; + let mut pad_remaining = pad; + for i in (blank + 1..=fields).rev() { + let idx = fields - i; + let next_pad = pad * (i - 1) / fields; + let adjusted = pad_remaining - next_pad + width; + let field = &block[idx * spec.size..(idx + 1) * spec.size]; + let text = format_od_field(spec, field, opts.big_endian); + output.push_str(&format!("{text:>adjusted$}")); + pad_remaining = next_pad; + } + if spec.trailer { + let trailer_pad = blank * width + pad * blank / fields; + output.push_str(&" ".repeat(trailer_pad)); + output.push_str(" >"); + for &b in chunk { + output.push(if (0x20..0x7f).contains(&b) { + b as char + } else { + '.' + }); + } + output.push('<'); + } + output.push('\n'); } - output.push('\n'); } - // Final address line - if !data.is_empty() { - let final_offset = opts.skip + data.len(); - let addr = format_od_addr(final_offset, opts.addr_radix); - if !addr.is_empty() { - output.push_str(&addr); - output.push('\n'); - } + // GNU always ends with the final address (nothing for `-An`). + let addr = format_od_addr(opts.skip + data.len(), opts.addr_radix); + if !addr.is_empty() { + output.push_str(&addr); + output.push('\n'); } output @@ -813,8 +1041,8 @@ mod tests { async fn test_od_basic() { let result = run_od(&[], Some("AB")).await; assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("101")); // 'A' = octal 101 - assert!(result.stdout.contains("102")); // 'B' = octal 102 + // Default is `-t o2`: "AB" little-endian = 0x4241 = octal 041101. + assert!(result.stdout.contains("041101")); } #[tokio::test] @@ -844,7 +1072,7 @@ mod tests { async fn test_od_hex_addr() { let result = run_od(&["-A", "x"], Some("test")).await; assert_eq!(result.exit_code, 0); - assert!(result.stdout.starts_with("0000000")); + assert!(result.stdout.starts_with("000000 ")); } #[tokio::test] @@ -876,7 +1104,7 @@ mod tests { async fn test_od_empty_input() { let result = run_od(&[], Some("")).await; assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout, ""); + assert_eq!(result.stdout, "0000000\n"); } #[tokio::test] @@ -887,6 +1115,49 @@ mod tests { assert!(result.stdout.contains("41")); } + #[tokio::test] + async fn test_od_default_is_octal_shorts() { + let result = run_od(&[], Some("AB")).await; + assert_eq!(result.stdout, "0000000 041101\n0000002\n"); + } + + #[tokio::test] + async fn test_od_multiple_types_align() { + let result = run_od(&["-An", "-tx1", "-c"], Some("A\n")).await; + assert_eq!(result.stdout, " 41 0a\n A \\n\n"); + } + + #[tokio::test] + async fn test_od_hexl_trailer() { + let result = run_od(&["-t", "x2z"], Some("abc")).await; + assert_eq!( + result.stdout, + format!("0000000 6261 0063{} >abc<\n0000003\n", " ".repeat(30)) + ); + } + + #[tokio::test] + async fn test_od_duplicates_collapse() { + let data = "a".repeat(48); + let result = run_od(&["-An", "-tx1", "-w4"], Some(&data[..12])).await; + assert_eq!(result.stdout, " 61 61 61 61\n*\n"); + let result = run_od(&["-An", "-v", "-tx1", "-w4"], Some(&data[..8])).await; + assert_eq!(result.stdout, " 61 61 61 61\n 61 61 61 61\n"); + } + + #[tokio::test] + async fn test_od_named_and_signed() { + let result = run_od(&["-An", "-ta", "-td1"], Some("\x7f ")).await; + assert_eq!(result.stdout, " del sp\n 127 32\n"); + } + + #[tokio::test] + async fn test_od_float_rejected() { + let result = run_od(&["-tf"], Some("abcd")).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("not supported")); + } + // --- Xxd tests --- #[tokio::test] diff --git a/crates/bashkit/src/builtins/inspect.rs b/crates/bashkit/src/builtins/inspect.rs index 122b8ab84..abcc25df7 100644 --- a/crates/bashkit/src/builtins/inspect.rs +++ b/crates/bashkit/src/builtins/inspect.rs @@ -3,6 +3,7 @@ use super::clap_cache::cached_command; use async_trait::async_trait; +use super::ls::quoting::{QuotingStyle, quote_name}; use super::{Builtin, Context, resolve_path}; use crate::error::Result; use crate::fs::FileType; @@ -338,7 +339,17 @@ impl Builtin for Stat { }; if let Some(fmt) = &format { - output.push_str(&format_stat(file, &metadata, fmt)); + // %N shows a symlink's target; fetch it only when asked. + let target = if fmt.contains("%N") && metadata.file_type == FileType::Symlink { + ctx.fs + .read_link(&path) + .await + .ok() + .map(|p| p.to_string_lossy().into_owned()) + } else { + None + }; + output.push_str(&format_stat(file, &metadata, fmt, target.as_deref())); output.push('\n'); } else { output.push_str(&default_stat_format(file, &metadata)); @@ -350,7 +361,12 @@ impl Builtin for Stat { } /// Format stat output using format string -fn format_stat(name: &str, metadata: &crate::fs::Metadata, format: &str) -> String { +fn format_stat( + name: &str, + metadata: &crate::fs::Metadata, + format: &str, + link_target: Option<&str>, +) -> String { let mut result = String::new(); let mut chars = format.chars().peekable(); @@ -360,6 +376,14 @@ fn format_stat(name: &str, metadata: &crate::fs::Metadata, format: &str) -> Stri chars.next(); match next { 'n' => result.push_str(name), + // GNU default quoting for %N is shell-escape-always. + 'N' => { + result.push_str("e_name(name, QuotingStyle::ShellEscapeAlways)); + if let Some(target) = link_target { + result.push_str(" -> "); + result.push_str("e_name(target, QuotingStyle::ShellEscapeAlways)); + } + } 's' => result.push_str(&metadata.size.to_string()), 'a' => result.push_str(&format!("{:o}", metadata.mode & 0o777)), 'A' => result.push_str(&format_permissions(metadata)), diff --git a/crates/bashkit/src/builtins/ls/mod.rs b/crates/bashkit/src/builtins/ls/mod.rs index c35a65455..59056812a 100644 --- a/crates/bashkit/src/builtins/ls/mod.rs +++ b/crates/bashkit/src/builtins/ls/mod.rs @@ -2,7 +2,7 @@ pub(crate) mod glob; mod list; -mod quoting; +pub(crate) mod quoting; mod rmdir; pub(crate) use glob::{fnmatch, glob_match}; diff --git a/crates/bashkit/src/builtins/ls/quoting.rs b/crates/bashkit/src/builtins/ls/quoting.rs index 88f8041a4..e717011f9 100644 --- a/crates/bashkit/src/builtins/ls/quoting.rs +++ b/crates/bashkit/src/builtins/ls/quoting.rs @@ -7,7 +7,7 @@ use std::borrow::Cow; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(super) enum QuotingStyle { +pub(crate) enum QuotingStyle { #[default] Literal, Escape, @@ -19,7 +19,7 @@ pub(super) enum QuotingStyle { } impl QuotingStyle { - pub(super) fn from_name(name: &str) -> Option { + pub(crate) fn from_name(name: &str) -> Option { Some(match name { "literal" => Self::Literal, "escape" => Self::Escape, @@ -34,7 +34,7 @@ impl QuotingStyle { } /// Render `name` in `style`. -pub(super) fn quote_name(name: &str, style: QuotingStyle) -> Cow<'_, str> { +pub(crate) fn quote_name(name: &str, style: QuotingStyle) -> Cow<'_, str> { match style { QuotingStyle::Literal => Cow::Borrowed(name), QuotingStyle::Escape => Cow::Owned(c_escape(name, true, false)), diff --git a/crates/bashkit/src/builtins/ls/rmdir.rs b/crates/bashkit/src/builtins/ls/rmdir.rs index 3bce5f508..ed6d2658e 100644 --- a/crates/bashkit/src/builtins/ls/rmdir.rs +++ b/crates/bashkit/src/builtins/ls/rmdir.rs @@ -2,16 +2,18 @@ use async_trait::async_trait; +use crate::builtins::arg_parser::OptArg; use crate::builtins::{Builtin, Context, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; /// The rmdir builtin - remove empty directories. /// -/// Usage: rmdir [-p] DIRECTORY... +/// Usage: rmdir [-pv] [--ignore-fail-on-non-empty] DIRECTORY... /// /// Options: -/// -p Remove parent directories as well if they become empty +/// -p Remove DIRECTORY and then each of its operand prefixes (a/b/c, a/b, a) +/// -v Print a line for every directory processed pub struct Rmdir; #[async_trait] @@ -19,90 +21,97 @@ impl Builtin for Rmdir { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = crate::builtins::check_help_version( ctx.args, - "Usage: rmdir [OPTION]... DIRECTORY...\nRemove empty directories.\n\n -p\t\tremove DIRECTORY and its ancestors\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: rmdir [OPTION]... DIRECTORY...\nRemove empty directories.\n\n -p, --parents\tremove DIRECTORY and its ancestors\n -v, --verbose\toutput a diagnostic for every directory processed\n --ignore-fail-on-non-empty\tignore failures due to non-empty directories\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("rmdir (bashkit) 0.1"), ) { return Ok(r); } - if ctx.args.is_empty() { - return Ok(ExecResult::err("rmdir: missing operand\n".to_string(), 1)); - } - - let parents = ctx.args.iter().any(|a| a == "-p"); - let dirs: Vec<_> = ctx.args.iter().filter(|a| !a.starts_with('-')).collect(); - + let (parsed, dirs) = match crate::builtins::arg_parser::gnu_getopt( + "rmdir", + ctx.args, + "pv", + &[ + ("parents", OptArg::No, 'p'), + ("verbose", OptArg::No, 'v'), + ("ignore-fail-on-non-empty", OptArg::No, 'i'), + ], + true, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; + let parents = parsed.iter().any(|o| o.key == 'p'); + let verbose = parsed.iter().any(|o| o.key == 'v'); + let ignore_non_empty = parsed.iter().any(|o| o.key == 'i'); if dirs.is_empty() { return Ok(ExecResult::err("rmdir: missing operand\n".to_string(), 1)); } - for dir in dirs { - let path = resolve_path(ctx.cwd, dir); - - // Check if exists - if !ctx.fs.exists(&path).await.unwrap_or(false) { - return Ok(ExecResult::err( - format!( - "rmdir: failed to remove '{}': No such file or directory\n", - dir - ), - 1, - )); - } - - // Check if it's a directory - let metadata = ctx.fs.stat(&path).await?; - if !metadata.file_type.is_dir() { - return Ok(ExecResult::err( - format!("rmdir: failed to remove '{}': Not a directory\n", dir), - 1, - )); - } - - // Check if directory is empty - let entries = ctx.fs.read_dir(&path).await?; - if !entries.is_empty() { - return Ok(ExecResult::err( - format!("rmdir: failed to remove '{}': Directory not empty\n", dir), - 1, - )); - } - - // Remove the directory - if let Err(e) = ctx.fs.remove(&path, false).await { - return Ok(ExecResult::err( - format!("rmdir: failed to remove '{}': {}\n", dir, e), - 1, - )); - } - - // If -p, try to remove parent directories - if parents { - let mut current = path.parent(); - while let Some(parent) = current { - // Don't remove root or cwd - if parent.as_os_str().is_empty() || parent == ctx.cwd.as_path() { + let mut stdout = String::new(); + let mut stderr = String::new(); + for dir in &dirs { + // GNU -p walks the operand's own prefixes: a/b/c, a/b, a. + let mut target = dir.as_str(); + loop { + if verbose { + stdout.push_str(&format!("rmdir: removing directory, '{target}'\n")); + } + match remove_empty_dir(&ctx, target).await? { + Ok(()) => {} + Err(reason) => { + if !(ignore_non_empty && reason == "Directory not empty") { + stderr.push_str(&format!( + "rmdir: failed to remove '{target}': {reason}\n" + )); + } break; } - - // Check if parent is empty - if let Ok(entries) = ctx.fs.read_dir(parent).await { - if entries.is_empty() { - if ctx.fs.remove(parent, false).await.is_err() { - break; - } - } else { + } + if !parents { + break; + } + let trimmed = target.trim_end_matches('/'); + match trimmed.rfind('/') { + Some(i) => { + let parent = trimmed[..i].trim_end_matches('/'); + if parent.is_empty() { break; } - } else { - break; + target = parent; } - - current = parent.parent(); + None => break, } } } - Ok(ExecResult::ok(String::new())) + Ok(ExecResult { + stdout: stdout.into(), + exit_code: i32::from(!stderr.is_empty()), + stderr: stderr.into(), + ..Default::default() + }) + } +} + +/// Remove one empty directory; the inner error is GNU's reason text. +async fn remove_empty_dir( + ctx: &Context<'_>, + dir: &str, +) -> Result> { + let path = resolve_path(ctx.cwd, dir); + let Ok(metadata) = ctx.fs.lstat(&path).await else { + return Ok(Err("No such file or directory")); + }; + if !metadata.file_type.is_dir() { + return Ok(Err("Not a directory")); + } + if !ctx.fs.read_dir(&path).await?.is_empty() { + return Ok(Err("Directory not empty")); + } + if ctx.fs.remove(&path, false).await.is_err() { + return Ok(Err("Device or resource busy")); } + Ok(Ok(())) } diff --git a/crates/bashkit/src/builtins/nl.rs b/crates/bashkit/src/builtins/nl.rs index 5d2496412..2b035840e 100644 --- a/crates/bashkit/src/builtins/nl.rs +++ b/crates/bashkit/src/builtins/nl.rs @@ -1,7 +1,14 @@ //! nl builtin command - number lines of files +//! +//! Decision: follows GNU nl's logical-page model. A line that is exactly the +//! section delimiter repeated 3/2/1 times starts a header/body/footer section, +//! prints as an empty line, and (unless `-p`) resets the line number. The `-l` +//! blank-line counter is not reset by section changes, as in GNU. use async_trait::async_trait; +use regex::Regex; +use super::arg_parser::{OptArg, gnu_getopt}; use super::{Builtin, Context, MAX_FORMAT_WIDTH, read_text_file}; use crate::error::Result; use crate::fs::vfs_join; @@ -9,22 +16,25 @@ use crate::interpreter::ExecResult; /// The nl builtin - number lines of files. /// -/// Usage: nl [-b TYPE] [-n FORMAT] [-s SEP] [-i INCR] [-v START] [-w WIDTH] [FILE...] +/// Usage: nl [OPTION]... [FILE...] /// /// Options: -/// -b TYPE Body numbering type: a (all), t (non-empty, default), n (none) -/// -n FORMAT Number format: ln (left-justified), rn (right-justified, default), rz (right-justified, zero-padded) -/// -s SEP Separator string between number and line (default: TAB) -/// -i INCR Line number increment (default: 1) -/// -v START Starting line number (default: 1) -/// -w WIDTH Number width (default: 6) +/// -b/-h/-f STYLE Body/header/footer numbering: a, t, n, or pBRE +/// -d CC Section delimiter (default `\:`) +/// -i INCR Line number increment (default: 1) +/// -l NUMBER Group of NUMBER empty lines counted as one +/// -n FORMAT Number format: ln, rn (default), rz +/// -p Do not reset line numbers at sections +/// -s SEP Separator string between number and line (default: TAB) +/// -v START Starting line number (default: 1) +/// -w WIDTH Number width (default: 6) pub struct Nl; -#[derive(Clone, Copy, PartialEq)] -enum BodyType { +enum Style { All, NonEmpty, None, + Regex(Regex), } #[derive(Clone, Copy)] @@ -34,127 +44,234 @@ enum NumberFormat { RightZero, } +#[derive(Clone, Copy, PartialEq)] +enum Section { + Header, + Body, + Footer, +} + struct NlOptions { - body_type: BodyType, + body: Style, + header: Style, + footer: Style, + delimiter: String, format: NumberFormat, separator: String, - increment: usize, - start: usize, + increment: i64, + start: i64, width: usize, + join_blank: u64, + renumber: bool, } impl Default for NlOptions { fn default() -> Self { Self { - body_type: BodyType::NonEmpty, + body: Style::NonEmpty, + header: Style::None, + footer: Style::None, + delimiter: "\\:".to_string(), format: NumberFormat::RightJustified, separator: "\t".to_string(), increment: 1, start: 1, width: 6, + join_blank: 1, + renumber: true, } } } -fn parse_nl_args(args: &[String]) -> std::result::Result<(NlOptions, Vec), String> { - let mut opts = NlOptions::default(); - let mut files = Vec::new(); - let mut p = super::arg_parser::ArgParser::new(args); - - while !p.is_done() { - if let Some(val) = p.flag_value("-b", "nl")? { - opts.body_type = match val { - "a" => BodyType::All, - "t" => BodyType::NonEmpty, - "n" => BodyType::None, - other => return Err(format!("nl: invalid body numbering style: '{}'", other)), - }; - } else if let Some(val) = p.flag_value("-n", "nl")? { - opts.format = match val { - "ln" => NumberFormat::LeftJustified, - "rn" => NumberFormat::RightJustified, - "rz" => NumberFormat::RightZero, - other => return Err(format!("nl: invalid line numbering format: '{}'", other)), - }; - } else if let Some(val) = p.flag_value("-s", "nl")? { - opts.separator = val.to_string(); - } else if let Some(val) = p.flag_value("-i", "nl")? { - opts.increment = val - .parse() - .map_err(|_| format!("nl: invalid line number increment: '{}'", val))?; - } else if let Some(val) = p.flag_value("-v", "nl")? { - opts.start = val - .parse() - .map_err(|_| format!("nl: invalid starting line number: '{}'", val))?; - } else if let Some(val) = p.flag_value("-w", "nl")? { - opts.width = val - .parse() - .map_err(|_| format!("nl: invalid line number field width: '{}'", val))?; - if opts.width > MAX_FORMAT_WIDTH { - return Err(format!( - "nl: line number field width {} exceeds maximum ({})", - opts.width, MAX_FORMAT_WIDTH - )); +fn parse_style(val: &str, what: &str) -> std::result::Result { + match val { + "a" => Ok(Style::All), + "t" => Ok(Style::NonEmpty), + "n" => Ok(Style::None), + _ => { + if let Some(pat) = val.strip_prefix('p') { + let translated = super::sed::translate_posix_regex(pat, false); + return super::search_common::build_regex(&translated) + .map(Style::Regex) + .map_err(|_| format!("nl: invalid regular expression: '{pat}'")); } - } else if p.is_flag() && p.current() != Some("--") { - // Reject unknown options instead of treating them as files. - return Err(invalid_option_msg("nl", p.current().unwrap_or_default())); - } else if let Some(arg) = p.positional() { - files.push(arg.to_string()); + Err(format!("nl: invalid {what} numbering style: '{val}'")) } } +} - Ok((opts, files)) +fn parse_int(val: &str, what: &str) -> std::result::Result { + val.trim_start() + .parse() + .map_err(|_| format!("nl: invalid {what}: '{val}'")) } -/// Build an unrecognized-option message (no trailing newline; the caller -/// appends it via `format!("{}\n", e)`). Mirrors `super::invalid_option`. -fn invalid_option_msg(cmd: &str, arg: &str) -> String { - if let Some(long) = arg.strip_prefix("--") { - format!("{cmd}: unrecognized option '--{long}'") - } else { - let ch = arg - .strip_prefix('-') - .and_then(|s| s.chars().next()) - .unwrap_or('-'); - format!("{cmd}: invalid option -- '{ch}'") +#[allow(clippy::result_large_err)] +fn parse_nl_args(args: &[String]) -> std::result::Result<(NlOptions, Vec), ExecResult> { + let (parsed, files) = gnu_getopt( + "nl", + args, + "b:d:f:h:i:l:n:ps:v:w:", + &[ + ("body-numbering", OptArg::Required, 'b'), + ("section-delimiter", OptArg::Required, 'd'), + ("footer-numbering", OptArg::Required, 'f'), + ("header-numbering", OptArg::Required, 'h'), + ("line-increment", OptArg::Required, 'i'), + ("join-blank-lines", OptArg::Required, 'l'), + ("number-format", OptArg::Required, 'n'), + ("no-renumber", OptArg::No, 'p'), + ("number-separator", OptArg::Required, 's'), + ("starting-line-number", OptArg::Required, 'v'), + ("number-width", OptArg::Required, 'w'), + ], + true, + 1, + )?; + let fail = |msg: String| ExecResult::err(format!("{msg}\n"), 1); + let mut opts = NlOptions::default(); + for o in parsed { + let val = o.value.unwrap_or_default(); + match o.key { + 'b' => opts.body = parse_style(&val, "body").map_err(fail)?, + 'h' => opts.header = parse_style(&val, "header").map_err(fail)?, + 'f' => opts.footer = parse_style(&val, "footer").map_err(fail)?, + 'd' => { + // A single character keeps the default second character `:`. + let mut chars = val.chars(); + opts.delimiter = match (chars.next(), chars.next()) { + (Some(c), None) => format!("{c}:"), + _ => val, + }; + } + 'i' => opts.increment = parse_int(&val, "line number increment").map_err(fail)?, + 'l' => { + opts.join_blank = match parse_int(&val, "line number of blank lines") { + Ok(n) if n > 0 => n as u64, + _ => { + return Err(fail(format!( + "nl: invalid line number of blank lines: '{val}'" + ))); + } + } + } + 'n' => { + opts.format = match val.as_str() { + "ln" => NumberFormat::LeftJustified, + "rn" => NumberFormat::RightJustified, + "rz" => NumberFormat::RightZero, + other => { + return Err(fail(format!( + "nl: invalid line numbering format: '{other}'" + ))); + } + } + } + 'p' => opts.renumber = false, + 's' => opts.separator = val, + 'v' => opts.start = parse_int(&val, "starting line number").map_err(fail)?, + _ => { + let width = match parse_int(&val, "line number field width") { + Ok(n) if n > 0 => n as usize, + _ => { + return Err(fail(format!( + "nl: invalid line number field width: '{val}'" + ))); + } + }; + if width > MAX_FORMAT_WIDTH { + return Err(fail(format!( + "nl: line number field width {width} exceeds maximum ({MAX_FORMAT_WIDTH})" + ))); + } + opts.width = width; + } + } } + Ok((opts, files)) } -fn format_number(num: usize, format: NumberFormat, width: usize) -> String { +fn format_number(num: i64, format: NumberFormat, width: usize) -> String { match format { NumberFormat::LeftJustified => format!("{: format!("{:>width$}", num, width = width), - NumberFormat::RightZero => format!("{:0>width$}", num, width = width), + NumberFormat::RightZero => { + if num < 0 { + format!("-{:0>w$}", num.unsigned_abs(), w = width.saturating_sub(1)) + } else { + format!("{:0>width$}", num, width = width) + } + } } } -fn number_lines(text: &str, opts: &NlOptions, line_num: &mut usize) -> String { - let mut output = String::new(); +/// Numbering state carried across all input files. +struct NlState { + section: Section, + line_no: i64, + blank_lines: u64, +} + +fn number_lines(text: &str, opts: &NlOptions, st: &mut NlState, out: &mut String) { + let header = opts.delimiter.repeat(3); + let body = opts.delimiter.repeat(2); + let footer = &opts.delimiter; + let no_number = " ".repeat(opts.width + opts.separator.len()); for line in text.lines() { - let should_number = match opts.body_type { - BodyType::All => true, - BodyType::NonEmpty => !line.is_empty(), - BodyType::None => false, + if !opts.delimiter.is_empty() { + let next = if line == header { + Some(Section::Header) + } else if line == body { + Some(Section::Body) + } else if line == footer { + Some(Section::Footer) + } else { + None + }; + if let Some(section) = next { + st.section = section; + if opts.renumber { + st.line_no = opts.start; + } + out.push('\n'); + continue; + } + } + let style = match st.section { + Section::Header => &opts.header, + Section::Body => &opts.body, + Section::Footer => &opts.footer, }; - - if should_number { - output.push_str(&format_number(*line_num, opts.format, opts.width)); - output.push_str(&opts.separator); - output.push_str(line); - output.push('\n'); - *line_num += opts.increment; + let number = match style { + Style::All => { + if opts.join_blank > 1 && line.is_empty() { + st.blank_lines += 1; + if st.blank_lines == opts.join_blank { + st.blank_lines = 0; + true + } else { + false + } + } else { + st.blank_lines = 0; + true + } + } + Style::NonEmpty => !line.is_empty(), + Style::None => false, + Style::Regex(re) => re.is_match(line), + }; + if number { + out.push_str(&format_number(st.line_no, opts.format, opts.width)); + out.push_str(&opts.separator); + st.line_no = st.line_no.saturating_add(opts.increment); } else { - // No number: real nl uses spaces only (no separator) for unnumbered lines. - // The indent is width chars + 1 space (replacing the tab separator). - output.push_str(&" ".repeat(opts.width + 1)); - output.push_str(line); - output.push('\n'); + out.push_str(&no_number); } + out.push_str(line); + out.push('\n'); } - - output } #[async_trait] @@ -162,44 +279,42 @@ impl Builtin for Nl { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = super::check_help_version( ctx.args, - "Usage: nl [OPTION]... [FILE]...\nNumber lines of files.\n\n -b TYPE\tuse TYPE for numbering body lines (a=all, t=non-empty, n=none)\n -i NUMBER\tline number increment\n -n FORMAT\tinsert line numbers according to FORMAT (ln, rn, rz)\n -s STRING\tadd STRING after line number\n -v NUMBER\tfirst line number\n -w NUMBER\tuse NUMBER columns for line numbers\n --help\t\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: nl [OPTION]... [FILE]...\nNumber lines of files.\n\n -b, --body-numbering=STYLE\tuse STYLE for numbering body lines\n -d, --section-delimiter=CC\tuse CC for logical page delimiters\n -f, --footer-numbering=STYLE\tuse STYLE for numbering footer lines\n -h, --header-numbering=STYLE\tuse STYLE for numbering header lines\n -i, --line-increment=NUMBER\tline number increment at each line\n -l, --join-blank-lines=NUMBER\tgroup of NUMBER empty lines counted as one\n -n, --number-format=FORMAT\tinsert line numbers according to FORMAT (ln, rn, rz)\n -p, --no-renumber\tdo not reset line numbers for each section\n -s, --number-separator=STRING\tadd STRING after (possible) line number\n -v, --starting-line-number=NUMBER\tfirst line number for each section\n -w, --number-width=NUMBER\tuse NUMBER columns for line numbers\n --help\t\tdisplay this help and exit\n --version\toutput version information and exit\n\nSTYLE is one of: a (all lines), t (nonempty lines), n (no lines), pBRE (lines matching BRE)\n", Some("nl (bashkit) 0.1"), ) { return Ok(r); } - let (opts, files) = match parse_nl_args(ctx.args) { + let (opts, mut files) = match parse_nl_args(ctx.args) { Ok(v) => v, - Err(e) => return Ok(ExecResult::err(format!("{}\n", e), 1)), + Err(e) => return Ok(e), }; + if files.is_empty() { + files.push("-".to_string()); + } let mut output = String::new(); - let mut line_num = opts.start; - - if files.is_empty() { - // Read from stdin - if let Some(stdin) = ctx.stdin { - output.push_str(&number_lines(stdin, &opts, &mut line_num)); - } - } else { - for file in &files { - if file == "-" { - if let Some(stdin) = ctx.stdin { - output.push_str(&number_lines(stdin, &opts, &mut line_num)); - } - } else { - let path = if file.starts_with('/') { - std::path::PathBuf::from(file) - } else { - vfs_join(ctx.cwd, file) - }; - - let text = match read_text_file(&*ctx.fs, &path, "nl").await { - Ok(t) => t, - Err(e) => return Ok(e), - }; - output.push_str(&number_lines(&text, &opts, &mut line_num)); + let mut state = NlState { + section: Section::Body, + line_no: opts.start, + blank_lines: 0, + }; + for file in &files { + if file == "-" { + if let Some(stdin) = ctx.stdin { + number_lines(stdin, &opts, &mut state, &mut output); } + continue; } + let path = if file.starts_with('/') { + std::path::PathBuf::from(file) + } else { + vfs_join(ctx.cwd, file) + }; + let text = match read_text_file(&*ctx.fs, &path, "nl").await { + Ok(t) => t, + Err(e) => return Ok(e), + }; + number_lines(&text, &opts, &mut state, &mut output); } Ok(ExecResult::ok(output)) @@ -454,4 +569,40 @@ mod tests { assert_eq!(result.exit_code, 1); assert!(result.stderr.contains("invalid option -- 'Q'")); } + + #[tokio::test] + async fn test_nl_sections_reset_numbers() { + let input = "\\:\\:\\:\nh\n\\:\\:\nb1\nb2\n\\:\nf\n"; + let result = run_nl(&["-ha", "-fa"], Some(input)).await; + assert_eq!(result.exit_code, 0); + assert_eq!( + result.stdout, + "\n 1\th\n\n 1\tb1\n 2\tb2\n\n 1\tf\n" + ); + } + + #[tokio::test] + async fn test_nl_no_renumber() { + let result = run_nl(&["-p"], Some("a\n\\:\\:\nb\n")).await; + assert_eq!(result.stdout, " 1\ta\n\n 2\tb\n"); + } + + #[tokio::test] + async fn test_nl_regex_style() { + let result = run_nl(&["-bp^x"], Some("xa\nb\nxc\n")).await; + assert_eq!(result.stdout, " 1\txa\n b\n 2\txc\n"); + } + + #[tokio::test] + async fn test_nl_join_blank_lines() { + let result = run_nl(&["-ba", "-l2"], Some("\n\n\n")).await; + assert_eq!(result.stdout, " \n 1\t\n \n"); + } + + #[tokio::test] + async fn test_nl_invalid_join_count() { + let result = run_nl(&["-l", "0"], Some("a\n")).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("invalid line number of blank lines")); + } } diff --git a/crates/bashkit/src/builtins/paste.rs b/crates/bashkit/src/builtins/paste.rs index c28f82e32..4445f871a 100644 --- a/crates/bashkit/src/builtins/paste.rs +++ b/crates/bashkit/src/builtins/paste.rs @@ -1,7 +1,12 @@ //! paste builtin command - merge lines of files +//! +//! Decision: every `-` operand reads the one shared stdin stream, so +//! `paste - -` takes lines in turns and `paste -s - -` gives the second `-` +//! nothing, as GNU paste does. `\0` in a delimiter list means "no delimiter". use async_trait::async_trait; +use super::arg_parser::{OptArg, gnu_getopt}; use super::{Builtin, Context, read_text_file}; use crate::error::Result; use crate::fs::vfs_join; @@ -9,131 +14,114 @@ use crate::interpreter::ExecResult; /// The paste builtin - merge lines of files. /// -/// Usage: paste [-d DELIM] [-s] [FILE...] +/// Usage: paste [-s] [-z] [-d LIST] [FILE...] /// /// Options: -/// -d DELIM Use DELIM instead of TAB as delimiter (cycles through chars) +/// -d LIST Use LIST instead of TAB as delimiters (cycled) /// -s Paste one file at a time (serial mode) +/// -z Line delimiter is NUL, not newline pub struct Paste; struct PasteOptions { - delimiters: Vec, + delimiters: Vec, serial: bool, + zero_terminated: bool, } -fn parse_paste_args(args: &[String]) -> std::result::Result<(PasteOptions, Vec), String> { - let mut opts = PasteOptions { - delimiters: vec!['\t'], +#[allow(clippy::result_large_err)] +fn parse_paste_args( + args: &[String], +) -> std::result::Result<(PasteOptions, Vec), ExecResult> { + let (opts, files) = gnu_getopt( + "paste", + args, + "d:sz", + &[ + ("delimiters", OptArg::Required, 'd'), + ("serial", OptArg::No, 's'), + ("zero-terminated", OptArg::No, 'z'), + ], + true, + 1, + )?; + let mut out = PasteOptions { + delimiters: vec!["\t".to_string()], serial: false, + zero_terminated: false, }; - let mut files = Vec::new(); - let mut p = super::arg_parser::ArgParser::new(args); - - while !p.is_done() { - if let Some(val) = p.flag_value("-d", "paste")? { - opts.delimiters = parse_delim_spec(val); - } else if p.flag("-s") { - opts.serial = true; - } else if try_parse_combined_flags(&mut p, &mut opts)? { - // handled combined flags like -sd, or -sd , - } else if p.is_flag() && p.current() != Some("--") { - // Reject unknown options instead of treating them as files. - return Err(invalid_option_msg("paste", p.current().unwrap_or_default())); - } else if let Some(arg) = p.positional() { - files.push(arg.to_string()); + for o in opts { + match o.key { + 'd' => out.delimiters = parse_delim_spec(&o.value.unwrap_or_default()), + 's' => out.serial = true, + _ => out.zero_terminated = true, } } - - if opts.delimiters.is_empty() { - opts.delimiters = vec!['\t']; + if out.delimiters.is_empty() { + out.delimiters = vec![String::new()]; } - - Ok((opts, files)) + Ok((out, files)) } -/// Parse combined short flags like `-sd,` where `s` is a boolean flag -/// and `d` takes the rest of the string as its value. -fn try_parse_combined_flags( - p: &mut super::arg_parser::ArgParser<'_>, - opts: &mut PasteOptions, -) -> std::result::Result { - let arg = match p.current() { - Some(a) if a.starts_with('-') && !a.starts_with("--") && a.len() > 2 => a, - _ => return Ok(false), - }; - - let chars: Vec = arg[1..].chars().collect(); - let mut i = 0; - let mut serial = false; - let mut delimiters = None; - - while i < chars.len() { - match chars[i] { - 's' => { - serial = true; - i += 1; - } - 'd' => { - // 'd' consumes the attached remainder, or the next argv when trailing. - let rest: String = chars[i + 1..].iter().collect(); - if rest.is_empty() { - let Some(next) = p.rest().get(1) else { - return Err("paste: -d requires an argument".to_string()); - }; - delimiters = Some(parse_delim_spec(next)); - p.advance(); - } else { - delimiters = Some(parse_delim_spec(&rest)); - } - i = chars.len(); // consumed everything - } - _ => return Ok(false), // unknown flag char, bail out +fn parse_delim_spec(spec: &str) -> Vec { + let mut delims = Vec::new(); + let mut chars = spec.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + delims.push(match chars.next() { + Some('n') => "\n".to_string(), + Some('t') => "\t".to_string(), + Some('\\') => "\\".to_string(), + // `\0` is the empty delimiter. + Some('0') => String::new(), + Some(other) => other.to_string(), + None => "\\".to_string(), + }); + } else { + delims.push(c.to_string()); } } + delims +} - // All chars parsed successfully — apply and advance - if serial { - opts.serial = true; - } - if let Some(d) = delimiters { - opts.delimiters = d; +/// Split into records, keeping a final unterminated one. +fn records(text: &str, sep: char) -> Vec { + if text.is_empty() { + return Vec::new(); } - p.advance(); - Ok(true) + text.strip_suffix(sep) + .unwrap_or(text) + .split(sep) + .map(str::to_string) + .collect() } -/// Build an unrecognized-option message (no trailing newline; the caller -/// appends it via `format!("{msg}\n")`). Mirrors `super::invalid_option`. -fn invalid_option_msg(cmd: &str, arg: &str) -> String { - if let Some(long) = arg.strip_prefix("--") { - format!("{cmd}: unrecognized option '--{long}'") - } else { - let ch = arg - .strip_prefix('-') - .and_then(|s| s.chars().next()) - .unwrap_or('-'); - format!("{cmd}: invalid option -- '{ch}'") - } +/// An input: its own lines, or the shared stdin cursor. +enum Source { + Lines(Vec, usize), + Stdin, } -fn parse_delim_spec(spec: &str) -> Vec { - let mut delims = Vec::new(); - let mut chars = spec.chars(); - while let Some(c) = chars.next() { - if c == '\\' { - match chars.next() { - Some('n') => delims.push('\n'), - Some('t') => delims.push('\t'), - Some('\\') => delims.push('\\'), - Some('0') => delims.push('\0'), - Some(other) => delims.push(other), - None => delims.push('\\'), +struct Inputs { + sources: Vec, + stdin: Vec, + stdin_pos: usize, +} + +impl Inputs { + fn next(&mut self, idx: usize) -> Option { + match &mut self.sources[idx] { + Source::Lines(lines, pos) => { + let line = lines.get(*pos).cloned(); + *pos += 1; + line + } + Source::Stdin => { + let line = self.stdin.get(self.stdin_pos).cloned(); + self.stdin_pos += 1; + line } - } else { - delims.push(c); } } - delims } #[async_trait] @@ -141,96 +129,80 @@ impl Builtin for Paste { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = super::check_help_version( ctx.args, - "Usage: paste [OPTION]... [FILE]...\nMerge lines of files.\n\n -d LIST\tuse LIST as delimiters\n -s\t\tpaste one file at a time\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: paste [OPTION]... [FILE]...\nMerge lines of files.\n\n -d, --delimiters=LIST\treuse characters from LIST instead of TABs\n -s, --serial\tpaste one file at a time instead of in parallel\n -z, --zero-terminated\tline delimiter is NUL, not newline\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("paste (bashkit) 0.1"), ) { return Ok(r); } - let (opts, files) = match parse_paste_args(ctx.args) { + let (opts, mut files) = match parse_paste_args(ctx.args) { Ok(parsed) => parsed, - Err(msg) => return Ok(ExecResult::err(format!("{msg}\n"), 1)), + Err(e) => return Ok(e), }; - - // Collect input sources - let mut sources: Vec> = Vec::new(); - if files.is_empty() { - // Read from stdin - if let Some(stdin) = ctx.stdin { - sources.push(stdin.lines().map(|l| l.to_string()).collect()); - } - } else { - // Every `-` reads the one stdin: in parallel mode each takes the - // next line in turn (`paste - -` joins pairs), with -s the first - // one takes it all. - let stdin_lines: Vec = ctx - .stdin - .map(|s| s.lines().map(|l| l.to_string()).collect()) - .unwrap_or_default(); - let dashes = files.iter().filter(|f| *f == "-").count(); - let mut dash_idx = 0; - for file in &files { - if file == "-" { - let lines = if opts.serial { - if dash_idx == 0 { - stdin_lines.clone() - } else { - Vec::new() - } - } else { - stdin_lines - .iter() - .skip(dash_idx) - .step_by(dashes) - .cloned() - .collect() - }; - dash_idx += 1; - sources.push(lines); - } else { - let path = if file.starts_with('/') { - std::path::PathBuf::from(file) - } else { - vfs_join(ctx.cwd, file) - }; - - let text = match read_text_file(&*ctx.fs, &path, "paste").await { - Ok(t) => t, - Err(e) => return Ok(e), - }; - sources.push(text.lines().map(|l| l.to_string()).collect()); - } + files.push("-".to_string()); + } + let sep = if opts.zero_terminated { '\0' } else { '\n' }; + + let mut inputs = Inputs { + sources: Vec::new(), + stdin: records(ctx.stdin.map(|s| &**s).unwrap_or(""), sep), + stdin_pos: 0, + }; + for file in &files { + if file == "-" { + inputs.sources.push(Source::Stdin); + continue; } + let path = if file.starts_with('/') { + std::path::PathBuf::from(file) + } else { + vfs_join(ctx.cwd, file) + }; + let text = match read_text_file(&*ctx.fs, &path, "paste").await { + Ok(t) => t, + Err(e) => return Ok(e), + }; + inputs.sources.push(Source::Lines(records(&text, sep), 0)); } + let delims = &opts.delimiters; + let n = inputs.sources.len(); let mut output = String::new(); if opts.serial { - // Serial mode: each file becomes one line - for source in &sources { - for (j, line) in source.iter().enumerate() { - if j > 0 { - let delim = opts.delimiters[(j - 1) % opts.delimiters.len()]; - output.push(delim); + // Serial mode: each file becomes one line. + for idx in 0..n { + let mut k = 0; + let mut first = true; + while let Some(line) = inputs.next(idx) { + if !first { + output.push_str(&delims[k % delims.len()]); + k += 1; } - output.push_str(line); + first = false; + output.push_str(&line); } - output.push('\n'); + output.push(sep); } } else { - // Parallel mode: merge corresponding lines - let max_lines = sources.iter().map(|s| s.len()).max().unwrap_or(0); - for i in 0..max_lines { - for (j, source) in sources.iter().enumerate() { - if j > 0 { - let delim = opts.delimiters[(j - 1) % opts.delimiters.len()]; - output.push(delim); + // Parallel mode: merge corresponding lines until all are spent. + loop { + let mut line = String::new(); + let mut any = false; + for idx in 0..n { + if let Some(l) = inputs.next(idx) { + any = true; + line.push_str(&l); } - if let Some(line) = source.get(i) { - output.push_str(line); + if idx + 1 < n { + line.push_str(&delims[idx % delims.len()]); } } - output.push('\n'); + if !any { + break; + } + output.push_str(&line); + output.push(sep); } } @@ -431,7 +403,7 @@ mod tests { let result = run_paste(&["-sd"], Some("a\nb\nc\n")).await; assert_eq!(result.exit_code, 1); assert_eq!(result.stdout, ""); - assert!(result.stderr.contains("paste: -d requires an argument")); + assert!(result.stderr.contains("option requires an argument -- 'd'")); } #[tokio::test] @@ -439,7 +411,7 @@ mod tests { let result = run_paste(&["-d"], Some("a\nb\nc\n")).await; assert_eq!(result.exit_code, 1); assert_eq!(result.stdout, ""); - assert!(result.stderr.contains("paste: -d requires an argument")); + assert!(result.stderr.contains("option requires an argument -- 'd'")); } #[tokio::test] diff --git a/crates/bashkit/src/builtins/path.rs b/crates/bashkit/src/builtins/path.rs index ca2b816dc..0eb3e52cf 100644 --- a/crates/bashkit/src/builtins/path.rs +++ b/crates/bashkit/src/builtins/path.rs @@ -28,55 +28,87 @@ impl Builtin for Basename { ) { return Ok(r); } - if ctx.args.is_empty() { + let (parsed, operands) = match super::arg_parser::gnu_getopt( + "basename", + ctx.args, + "as:z", + &[ + ("multiple", super::arg_parser::OptArg::No, 'a'), + ("suffix", super::arg_parser::OptArg::Required, 's'), + ("zero", super::arg_parser::OptArg::No, 'z'), + ], + true, + 1, + ) { + Ok(v) => v, + Err(e) => return Ok(e), + }; + let mut multiple = false; + let mut suffix: Option = None; + let mut term = '\n'; + for o in parsed { + match o.key { + 'a' => multiple = true, + 's' => { + multiple = true; + suffix = o.value; + } + _ => term = '\0', + } + } + if operands.is_empty() { return Ok(ExecResult::err( "basename: missing operand\n".to_string(), 1, )); } - - let mut output = String::new(); - let mut args_iter = ctx.args.iter(); - - // Get the path argument - let path_arg = args_iter - .next() - .expect("args_iter.next() valid: guarded by is_empty() check above"); - let path = Path::new(path_arg); - - // Get the basename - let basename = path - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| { - // Handle special cases like "/" or empty - if path_arg == "/" { - "/".to_string() - } else if path_arg.is_empty() { - String::new() - } else { - path_arg.clone() + let names: &[String] = if multiple { + &operands + } else { + match operands.len() { + 1 => &operands, + 2 => { + suffix = Some(operands[1].clone()); + &operands[..1] + } + _ => { + return Ok(ExecResult::err( + format!("basename: extra operand '{}'\n", operands[2]), + 1, + )); } - }); - - // Check for suffix argument - let result = if let Some(suffix) = args_iter.next() { - if let Some(stripped) = basename.strip_suffix(suffix.as_str()) { - stripped.to_string() - } else { - basename } - } else { - basename }; - output.push_str(&result); - output.push('\n'); - + let mut output = String::new(); + for name in names { + output.push_str(&gnu_basename(name, suffix.as_deref())); + output.push(term); + } Ok(ExecResult::ok(output)) } } +/// GNU `basename`: drop trailing slashes, keep the last component, then +/// strip SUFFIX unless it is the whole remaining name. +fn gnu_basename(name: &str, suffix: Option<&str>) -> String { + let trimmed = name.trim_end_matches('/'); + if trimmed.is_empty() { + return if name.is_empty() { + String::new() + } else { + "/".to_string() + }; + } + let base = trimmed.rsplit('/').next().unwrap_or(trimmed); + match suffix { + Some(suf) if !suf.is_empty() && base != suf => { + base.strip_suffix(suf).unwrap_or(base).to_string() + } + _ => base.to_string(), + } +} + /// The dirname builtin - strip last component from file name. /// /// Usage: dirname NAME... @@ -570,6 +602,21 @@ mod tests { assert_eq!(result.stdout, "sort\n"); } + #[tokio::test] + async fn test_basename_gnu_edge_cases() { + assert_eq!(gnu_basename(".txt", Some(".txt")), ".txt"); + assert_eq!(gnu_basename("a/.", None), "."); + assert_eq!(gnu_basename("//", None), "/"); + assert_eq!(gnu_basename("", None), ""); + let result = run_basename(&["-a", "a/b", "c/d/"]).await; + assert_eq!(result.stdout, "b\nd\n"); + let result = run_basename(&["-s", ".c", "x.c", "y.h"]).await; + assert_eq!(result.stdout, "x\ny.h\n"); + let result = run_basename(&["a", "b", "c"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("extra operand 'c'")); + } + #[tokio::test] async fn test_basename_with_suffix() { let result = run_basename(&["file.txt", ".txt"]).await; diff --git a/crates/bashkit/src/builtins/pipeline.rs b/crates/bashkit/src/builtins/pipeline.rs index c0d5cd5db..ebe865827 100644 --- a/crates/bashkit/src/builtins/pipeline.rs +++ b/crates/bashkit/src/builtins/pipeline.rs @@ -9,17 +9,27 @@ use crate::interpreter::ExecResult; /// The xargs builtin - build and execute command lines from stdin. /// -/// Usage: xargs [-I REPLACE] [-n MAX-ARGS] [-d DELIM] [-P N] -/// [--process-slot-var=VAR] [COMMAND [ARGS...]] +/// Usage: xargs [OPTION]... [COMMAND [ARGS...]] /// -/// Options: -/// -I REPLACE Replace REPLACE with input (implies -n 1) -/// -n MAX-ARGS Use at most MAX-ARGS arguments per command -/// -d DELIM Use DELIM as delimiter instead of whitespace -/// -0 Use NUL as delimiter (same as -d '\0') +/// Options (GNU findutils surface): +/// -0, --null Items are NUL-terminated (quotes not special) +/// -a, --arg-file=FILE Read items from FILE instead of stdin +/// -d, --delimiter=DELIM Items are DELIM-terminated (char or `\` escape) +/// -E EOF, -e[EOF] Stop reading at the logical end-of-file word +/// -I REPLACE, -i[REPLACE] Run once per line, replacing REPLACE +/// -L N, -l[N] Use at most N input lines per command +/// -n N Use at most N arguments per command +/// -s N Limit command line length to N bytes +/// -r Do not run COMMAND when input is empty +/// -t, --verbose Print each command to stderr before running it +/// -x Exit if the size is exceeded /// -P N, --max-procs=N Allocate N parallel slots (see decision below) /// --process-slot-var=VAR Set VAR to this invocation's slot index (0..N-1) /// +/// Input parsing follows GNU `read_line`/`read_string`: blanks separate +/// items, single/double quotes group, backslash escapes, a NUL truncates the +/// item, and `-L` lines ending in a blank continue onto the next line. +/// /// Important decision (parallelism): bashkit runs a single `Bash` interpreter /// sequentially — even background `&` jobs execute synchronously for /// deterministic output (see `knowledge/foundations/parallel-execution.md` and @@ -32,11 +42,19 @@ use crate::interpreter::ExecResult; /// case while staying faithful to bashkit's no-hidden-concurrency model. pub struct Xargs; +/// Default `-s` limit (GNU caps the default at 128 KiB). +const XARGS_DEFAULT_MAX_CHARS: usize = 128 * 1024; + /// Parsed xargs options. struct XargsOptions { replace_str: Option, - max_args: Option, + max_args: usize, + max_lines: usize, + max_chars: usize, delimiter: Option, + eof_str: Option, + arg_file: Option, + verbose: bool, /// `-P N` / `--max-procs=N`: number of parallel slots. `Some(0)` means /// "as many as possible" (one slot per command). `None` means 1 slot. max_procs: Option, @@ -48,173 +66,581 @@ struct XargsOptions { command: Vec, } +fn xargs_err(msg: impl std::fmt::Display) -> ExecResult { + ExecResult::err(format!("xargs: {msg}\n"), 1) +} + +/// GNU `parse_num`: strtol-style (blanks, sign), then a lower bound. +#[allow(clippy::result_large_err)] +fn xargs_parse_num(s: &str, opt: char, min: i64) -> std::result::Result { + let t = s.trim_start(); + let (neg, digits) = match t.as_bytes().first() { + Some(b'-') => (true, &t[1..]), + Some(b'+') => (false, &t[1..]), + _ => (false, t), + }; + let val: i64 = match digits.parse::() { + Ok(v) if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) => { + if neg { + -v + } else { + v + } + } + _ => { + return Err(xargs_err(format!( + "invalid number \"{s}\" for -{opt} option" + ))); + } + }; + if val < min { + return Err(xargs_err(format!( + "value {s} for -{opt} option should be >= {min}" + ))); + } + Ok(val as usize) +} + +/// GNU `strtoul` prefix parse used for `-d '\xNN'` / `-d '\NNN'`. +fn strtoul_prefix(s: &str, base: u32) -> (u64, &str) { + let t = s.trim_start(); + let (neg, mut rest) = match t.as_bytes().first() { + Some(b'-') => (true, &t[1..]), + Some(b'+') => (false, &t[1..]), + _ => (false, t), + }; + if base == 16 + && (rest.starts_with("0x") || rest.starts_with("0X")) + && rest[2..].starts_with(|c: char| c.is_ascii_hexdigit()) + { + rest = &rest[2..]; + } + let n = rest.find(|c: char| !c.is_digit(base)).unwrap_or(rest.len()); + if n == 0 { + // No conversion: strtoul leaves the end pointer at the start. + return (0, s); + } + let v = u64::from_str_radix(&rest[..n], base).unwrap_or(u64::MAX); + (if neg { v.wrapping_neg() } else { v }, &rest[n..]) +} + +/// GNU `get_input_delimiter`. +#[allow(clippy::result_large_err)] +fn xargs_delimiter(s: &str) -> std::result::Result { + let mut chars = s.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => Ok(c), + (Some('\\'), Some(n)) => { + let simple = match n { + 'a' => Some('\x07'), + 'b' => Some('\x08'), + 'f' => Some('\x0c'), + 'n' => Some('\n'), + 'r' => Some('\r'), + 't' => Some('\t'), + 'v' => Some('\x0b'), + '\\' => Some('\\'), + _ => None, + }; + if let Some(c) = simple { + return Ok(c); + } + let (digits, base) = if n == 'x' { + (&s[2..], 16) + } else if n.is_ascii_digit() { + (&s[1..], 8) + } else { + return Err(xargs_err(format!( + "Invalid escape sequence {s} in input delimiter specification." + ))); + }; + let (val, rest) = strtoul_prefix(digits, base); + if val > 0xff { + return Err(xargs_err(format!( + "Invalid escape sequence {s} in input delimiter specification; character values must not exceed {}.", + if base == 16 { "0xff" } else { "0377" } + ))); + } + if !rest.is_empty() { + return Err(xargs_err(format!( + "Invalid escape sequence {s} in input delimiter specification; trailing characters {rest} not recognised." + ))); + } + Ok(char::from(val as u8)) + } + _ => Err(xargs_err(format!( + "Invalid input delimiter specification {s}: the delimiter must be either a single character or an escape sequence starting with \\." + ))), + } +} + /// Parse xargs arguments, returning options or an error ExecResult. #[allow(clippy::result_large_err)] fn parse_xargs_args(args: &[String]) -> std::result::Result { - let mut replace_str: Option = None; - let mut max_args: Option = None; - let mut delimiter: Option = None; - let mut max_procs: Option = None; - let mut process_slot_var: Option = None; - let mut no_run_if_empty = false; - let mut command: Vec = Vec::new(); - let mut p = super::arg_parser::ArgParser::new(args); - - while !p.is_done() { - if let Some(val) = p - .flag_value("-I", "xargs") - .map_err(|e| ExecResult::err(format!("{e}\n"), 1))? - { - replace_str = Some(val.to_string()); - max_args = Some(1); // -I implies -n 1 - } else if let Some(val) = p - .flag_value("-n", "xargs") - .map_err(|e| ExecResult::err(format!("{e}\n"), 1))? - { - match val.parse::() { - Ok(n) if n > 0 => max_args = Some(n), - _ => { - return Err(ExecResult::err( - format!("xargs: invalid number: '{}'\n", val), - 1, - )); + use super::arg_parser::OptArg; + let (opts, command) = super::arg_parser::gnu_getopt( + "xargs", + args, + "0a:E:e::i::I:l::L:n:oprs:txP:d:", + &[ + ("arg-file", OptArg::Required, 'a'), + ("delimiter", OptArg::Required, 'd'), + ("eof", OptArg::Optional, 'e'), + ("exit", OptArg::No, 'x'), + ("interactive", OptArg::No, 'p'), + ("max-args", OptArg::Required, 'n'), + ("max-chars", OptArg::Required, 's'), + ("max-lines", OptArg::Optional, 'l'), + ("max-procs", OptArg::Required, 'P'), + ("no-run-if-empty", OptArg::No, 'r'), + ("null", OptArg::No, '0'), + ("open-tty", OptArg::No, 'o'), + ("process-slot-var", OptArg::Required, 'V'), + ("replace", OptArg::Optional, 'i'), + ("show-limits", OptArg::No, 'S'), + ("verbose", OptArg::No, 't'), + ], + false, + 1, + )?; + + let mut o = XargsOptions { + replace_str: None, + max_args: 0, + max_lines: 0, + max_chars: XARGS_DEFAULT_MAX_CHARS, + delimiter: None, + eof_str: None, + arg_file: None, + verbose: false, + max_procs: None, + process_slot_var: None, + no_run_if_empty: false, + command, + }; + // Order matters: -I/-L/-n override each other as in GNU xargs.c. + for opt in opts { + let val = opt.value; + match opt.key { + '0' => o.delimiter = Some('\0'), + 'a' => o.arg_file = val, + 'd' => o.delimiter = Some(xargs_delimiter(&val.unwrap_or_default())?), + 'E' | 'e' => o.eof_str = val.filter(|v| !v.is_empty()), + 'I' | 'i' => { + o.replace_str = Some(val.unwrap_or_else(|| "{}".to_string())); + o.max_args = 0; + o.max_lines = 0; + } + 'L' | 'l' => { + o.max_lines = match val { + Some(v) => xargs_parse_num(&v, opt.key, 1)?, + None => 1, + }; + o.max_args = 0; + o.replace_str = None; + } + 'n' => { + o.max_lines = 0; + o.max_args = xargs_parse_num(&val.unwrap_or_default(), 'n', 1)?; + // GNU ignores -n1 after -i (savannah bug 57390). + if o.max_args == 1 && o.replace_str.is_some() { + o.max_args = 0; + } else { + o.replace_str = None; } } - } else if let Some(val) = p - .flag_value("-d", "xargs") - .map_err(|e| ExecResult::err(format!("{e}\n"), 1))? - { - delimiter = val.chars().next(); - } else if p.flag("-0") { - delimiter = Some('\0'); - } else if p.flag("-r") || p.flag("--no-run-if-empty") { - no_run_if_empty = true; - } else if let Some(val) = p - .flag_value("-P", "xargs") - .map_err(|e| ExecResult::err(format!("{e}\n"), 1))? - .or(p - .long_value("--max-procs", "xargs") - .map_err(|e| ExecResult::err(format!("{e}\n"), 1))?) - { - // -P 0 / --max-procs=0 means "as many as possible" (GNU). - match val.parse::() { - Ok(n) => max_procs = Some(n), - _ => { - return Err(ExecResult::err( - format!("xargs: invalid number for -P option: '{}'\n", val), - 1, - )); + 's' => o.max_chars = xargs_parse_num(&val.unwrap_or_default(), 's', 1)?, + 'P' => o.max_procs = Some(xargs_parse_num(&val.unwrap_or_default(), 'P', 0)?), + 'V' => { + let v = val.unwrap_or_default(); + if v.is_empty() { + return Err(xargs_err("--process-slot-var requires a variable name")); } + o.process_slot_var = Some(v); } - } else if let Some(val) = p - .long_value("--process-slot-var", "xargs") - .map_err(|e| ExecResult::err(format!("{e}\n"), 1))? - { - if val.is_empty() { - return Err(ExecResult::err( - "xargs: --process-slot-var requires a variable name\n".to_string(), - 1, - )); + 'r' => o.no_run_if_empty = true, + 't' => o.verbose = true, + 'p' => return Err(xargs_err("failed to open /dev/tty for reading")), + // -x, -o, --show-limits: nothing to enforce or show here. + _ => {} + } + } + + if o.command.is_empty() { + o.command.push("echo".to_string()); + } + Ok(o) +} + +/// Accumulates command lines the way GNU `buildcmd.c` does. +struct XargsBuilder<'a> { + opts: &'a XargsOptions, + initial_chars: usize, + cur: Vec, + cur_chars: usize, + commands: Vec>, +} + +impl<'a> XargsBuilder<'a> { + fn new(opts: &'a XargsOptions) -> Self { + let initial_chars = opts.command.iter().map(|a| a.len() + 1).sum(); + Self { + opts, + initial_chars, + cur: Vec::new(), + cur_chars: initial_chars, + commands: Vec::new(), + } + } + + fn exec(&mut self) { + let mut cmd = self.opts.command.clone(); + cmd.append(&mut self.cur); + self.commands.push(cmd); + self.cur_chars = self.initial_chars; + } + + fn push(&mut self, arg: String) -> std::result::Result<(), String> { + let len = arg.len() + 1; + if self.cur_chars + len > self.opts.max_chars { + if self.cur.is_empty() { + return Err("argument line too long".to_string()); } - process_slot_var = Some(val.to_string()); - } else if p.is_flag() && p.current() != Some("-") { - let Some(s) = p.current() else { - p.advance(); - continue; - }; - return Err(ExecResult::err( - format!("xargs: invalid option -- '{}'\n", &s[1..]), - 1, - )); - } else { - command.extend(p.rest().iter().cloned()); - break; + self.exec(); + if self.cur_chars + len > self.opts.max_chars { + return Err("argument line too long".to_string()); + } + } + self.cur.push(arg); + self.cur_chars += len; + if self.opts.max_args > 0 && self.cur.len() >= self.opts.max_args { + self.exec(); } + Ok(()) } - if command.is_empty() { - command.push("echo".to_string()); + /// `-I`: one command per item with every REPLACE substituted. + fn insert(&mut self, item: &str) -> std::result::Result<(), String> { + let repl = self.opts.replace_str.as_deref().unwrap_or("{}"); + let cmd: Vec = self + .opts + .command + .iter() + .map(|a| a.replace(repl, item)) + .collect(); + let size: usize = cmd.iter().map(|a| a.len() + 1).sum(); + if size > self.opts.max_chars { + return Err("command too long".to_string()); + } + self.commands.push(cmd); + Ok(()) } +} - Ok(XargsOptions { - replace_str, - max_args, - delimiter, - max_procs, - process_slot_var, - no_run_if_empty, - command, - }) +/// Command lines built from the input, plus a fatal error that stopped +/// reading (GNU still runs what it collected before dying). +struct XargsPlan { + commands: Vec>, + error: Option, } -/// Build the list of sub-commands from parsed options and stdin input. -fn build_xargs_commands(opts: &XargsOptions, input: &str) -> Vec { - let items: Vec<&str> = if let Some(delim) = opts.delimiter { - input.split(delim).filter(|s| !s.is_empty()).collect() - } else { - input.split_whitespace().collect() +/// Build command lines from parsed options and input bytes. +fn build_xargs_plan(opts: &XargsOptions, input: &str) -> XargsPlan { + let mut b = XargsBuilder::new(opts); + let mut error: Option = None; + // With -I GNU builds each command only once an item arrives, so an + // over-long template fails per item and empty input still succeeds. + if opts.replace_str.is_none() && b.initial_chars > opts.max_chars { + return XargsPlan { + commands: Vec::new(), + error: Some("argument list too long".to_string()), + }; + } + let replace = opts.replace_str.is_some(); + let mut lineno = 0usize; + // An item is complete: insert (-I) or append, then honour -L. + let take = |b: &mut XargsBuilder<'_>, + item: String, + push: bool, + lineno: &mut usize| + -> std::result::Result<(), String> { + if replace { + b.insert(&item)?; + } else if push { + b.push(item)?; + } + if opts.max_lines > 0 && *lineno >= opts.max_lines { + b.exec(); + *lineno = 0; + } + Ok(()) }; - if items.is_empty() { - // GNU runs the command once with no input arguments unless -r; - // with -I there is nothing to substitute, so nothing runs. - if opts.no_run_if_empty || opts.replace_str.is_some() { - return Vec::new(); + let result: std::result::Result<(), String> = (|| { + if let Some(delim) = opts.delimiter { + // GNU `read_string`: every delimiter ends an item, even empty. + let body = input; + let mut parts: Vec<&str> = body.split(delim).collect(); + let last = parts.pop().unwrap_or(""); + for part in parts { + lineno += 1; + take(&mut b, part.to_string(), true, &mut lineno)?; + } + if !last.is_empty() { + take(&mut b, last.to_string(), true, &mut lineno)?; + } + return Ok(()); } - return vec![SubCommand { - name: opts.command[0].clone(), - args: opts.command[1..].to_vec(), - stdin: None, - assignments: match opts.process_slot_var { - Some(ref var) => vec![(var.clone(), "0".to_string())], - None => Vec::new(), - }, - }]; + + // GNU `read_line` state machine. + #[derive(PartialEq, Clone, Copy)] + enum St { + Space, + Norm, + Quote(char), + Backslash, + } + let is_eof = |w: &str| opts.eof_str.as_deref() == Some(w); + let is_blank = |c: char| c == ' ' || c == '\t'; + let is_space = |c: char| is_blank(c) || matches!(c, '\n' | '\r' | '\x0c' | '\x0b'); + // C strings end at the first NUL. + let cstr = |w: &str| w.split('\0').next().unwrap_or("").to_string(); + + let mut chars = input.chars().peekable(); + let mut prev_c; + let mut c = '\0'; + 'calls: loop { + let mut state = St::Space; + let mut word = String::new(); + let mut first = true; + loop { + prev_c = c; + let Some(next) = chars.next() else { + // EOF + if word.is_empty() { + break 'calls; + } + if let St::Quote(q) = state { + let kind = if q == '"' { "double" } else { "single" }; + return Err(format!( + "unmatched {kind} quote; by default quotes are special to xargs unless you use the -0 option" + )); + } + let w = cstr(&word); + if first && is_eof(&w) { + break 'calls; + } + take(&mut b, w, true, &mut lineno)?; + break 'calls; + }; + c = next; + if state == St::Space { + if is_space(c) { + continue; + } + state = St::Norm; + } + match state { + St::Norm => { + if c == '\n' { + if !is_blank(prev_c) { + lineno += 1; + } + if word.is_empty() { + state = St::Space; + continue; + } + let w = cstr(&word); + if is_eof(&w) { + break 'calls; + } + take(&mut b, w, true, &mut lineno)?; + continue 'calls; + } + if !replace && is_space(c) { + let w = cstr(&word); + if is_eof(&w) { + break 'calls; + } + // Pushed mid-line: no -L check until the line ends. + b.push(w)?; + word.clear(); + state = St::Space; + first = false; + continue; + } + match c { + '\\' => { + state = St::Backslash; + continue; + } + '\'' | '"' => { + state = St::Quote(c); + continue; + } + _ => {} + } + } + St::Quote(q) => { + if c == '\n' { + let kind = if q == '"' { "double" } else { "single" }; + return Err(format!( + "unmatched {kind} quote; by default quotes are special to xargs unless you use the -0 option" + )); + } + if c == q { + state = St::Norm; + continue; + } + } + St::Backslash => state = St::Norm, + St::Space => {} + } + word.push(c); + } + } + Ok(()) + })(); + if let Err(e) = result { + error = Some(e); } - let chunk_size = opts.max_args.unwrap_or(items.len()); - let chunks: Vec> = items.chunks(chunk_size).map(|c| c.to_vec()).collect(); + // GNU runs pending arguments, and runs once on empty input unless -r. + if !replace + && (!b.cur.is_empty() || (b.commands.is_empty() && !opts.no_run_if_empty)) + && (error.is_none() || !b.cur.is_empty()) + { + b.exec(); + } + XargsPlan { + commands: b.commands, + error, + } +} - // Number of parallel slots for --process-slot-var assignment. `-P 0` - // ("as many as possible") gives every command a distinct slot; absent - // `-P`, GNU uses a single slot so the index is always 0. - let slot_count = match opts.max_procs { - Some(0) => chunks.len().max(1), - Some(n) => n, - None => 1, - }; +impl XargsOptions { + fn subcommands(&self, commands: Vec>) -> Vec { + // Number of parallel slots for --process-slot-var assignment. `-P 0` + // ("as many as possible") gives every command a distinct slot; absent + // `-P`, GNU uses a single slot so the index is always 0. + let slot_count = match self.max_procs { + Some(0) => commands.len().max(1), + Some(n) => n, + None => 1, + }; + commands + .into_iter() + .enumerate() + .map(|(idx, mut cmd)| { + let name = cmd.remove(0); + SubCommand { + name, + args: cmd, + stdin: None, + assignments: match self.process_slot_var { + Some(ref var) => vec![(var.clone(), (idx % slot_count).to_string())], + None => Vec::new(), + }, + } + }) + .collect() + } +} - chunks - .into_iter() - .enumerate() - .map(|(idx, chunk)| { - let cmd_args: Vec = if let Some(ref repl) = opts.replace_str { - let item = chunk.first().unwrap_or(&""); - opts.command - .iter() - .map(|arg| arg.replace(repl, item)) - .collect() - } else { - let mut full = opts.command.clone(); - full.extend(chunk.iter().map(|s| s.to_string())); - full - }; +/// Read the item source: `-a FILE` (or `-a -` for stdin) or stdin. +async fn xargs_input(ctx: &Context<'_>, opts: &XargsOptions) -> String { + match opts.arg_file.as_deref() { + None | Some("-") => ctx.stdin.map(|s| s.to_string()).unwrap_or_default(), + Some(file) => { + let path = resolve_path(ctx.cwd, file); + // A directory or unreadable file yields no items, as GNU's + // read error leaves the input empty. + match ctx.fs.read_file(&path).await { + Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(), + Err(_) => String::new(), + } + } + } +} - let assignments = match opts.process_slot_var { - Some(ref var) => vec![(var.clone(), (idx % slot_count).to_string())], - None => Vec::new(), - }; +/// Runs the planned commands in order, GNU-style: `-t` echoes each command +/// to stderr first, exit status 123 when any command failed (1-125), and a +/// command exiting 255 stops xargs with status 124. +struct XargsRun { + commands: std::collections::VecDeque, + verbose: bool, + announced: bool, + error: Option, + stdout: crate::StreamData, + stderr: crate::StreamData, + status: i32, +} - let name = cmd_args[0].clone(); - let args = cmd_args[1..].to_vec(); - SubCommand { - name, - args, - stdin: None, - assignments, +#[async_trait] +impl super::PlanDriver for XargsRun { + async fn next(&mut self, last: Option) -> Result { + if let Some(r) = last { + self.stdout.append(&r.stdout); + self.stderr.append(&r.stderr); + match r.exit_code { + 0 => {} + 255 => { + let name = self + .commands + .pop_front() + .map(|c| c.name) + .unwrap_or_default(); + self.commands.clear(); + self.error = Some(format!("{name}: exited with status 255; aborting")); + self.status = 124; + } + 126 | 127 => self.status = r.exit_code, + _ => { + if self.status == 0 { + self.status = 123; + } + } + } + if self.status != 124 { + self.commands.pop_front(); } - }) - .collect() + self.announced = false; + } + if let Some(cmd) = self.commands.front() { + if self.verbose && !self.announced { + self.announced = true; + let mut line = cmd.name.clone(); + for a in &cmd.args { + line.push(' '); + line.push_str(a); + } + line.push('\n'); + let err: crate::StreamData = line.into(); + self.stderr.append(&err); + return Ok(super::PlanStep::Emit { + stdout: crate::StreamData::new(), + stderr: err, + }); + } + return Ok(super::PlanStep::Run { + command: cmd.clone(), + cwd: None, + }); + } + if let Some(e) = self.error.take() { + let err: crate::StreamData = format!("xargs: {e}\n").into(); + self.stderr.append(&err); + if self.status == 0 { + self.status = 1; + } + } + Ok(super::PlanStep::Done(ExecResult { + stdout: std::mem::take(&mut self.stdout), + stderr: std::mem::take(&mut self.stderr), + exit_code: self.status, + ..Default::default() + })) + } } #[async_trait] @@ -222,7 +648,7 @@ impl Builtin for Xargs { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = super::check_help_version( ctx.args, - "Usage: xargs [OPTION]... [COMMAND [ARGS]...]\nBuild and execute command lines from standard input.\n\n -I REPLACE\treplace REPLACE with input (implies -n 1)\n -n MAX-ARGS\tuse at most MAX-ARGS arguments per command\n -d DELIM\tuse DELIM as delimiter instead of whitespace\n -0\tuse NUL as delimiter\n -r, --no-run-if-empty\tdo not run COMMAND when input is empty\n -P, --max-procs=N\tallocate N parallel slots (runs sequentially)\n --process-slot-var=VAR\tset VAR to the slot index (0..N-1)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: xargs [OPTION]... [COMMAND [ARGS]...]\nBuild and execute command lines from standard input.\n\n -0, --null\titems are separated by a null, not whitespace\n -a, --arg-file=FILE\tread arguments from FILE, not standard input\n -d, --delimiter=CHAR\titems are separated by CHAR\n -E END\tset logical EOF string\n -I R\treplace R in initial-arguments with names read from input\n -L, --max-lines=MAX-LINES\tuse at most MAX-LINES non-blank input lines per command line\n -n, --max-args=MAX-ARGS\tuse at most MAX-ARGS arguments per command line\n -r, --no-run-if-empty\tif there are no arguments, then do not run COMMAND\n -s, --max-chars=MAX-CHARS\tlimit length of command line to MAX-CHARS\n -t, --verbose\tprint commands before executing them\n -x, --exit\texit if the size (see -s) is exceeded\n -P, --max-procs=N\tallocate N parallel slots (runs sequentially)\n --process-slot-var=VAR\tset VAR to the slot index (0..N-1)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("xargs (bashkit) 0.1"), ) { return Ok(r); @@ -234,11 +660,9 @@ impl Builtin for Xargs { Err(e) => return Ok(e), }; - let input = ctx.stdin.map(|stdin| &**stdin).unwrap_or(""); - let commands = build_xargs_commands(&opts, input); - if commands.is_empty() { - return Ok(ExecResult::ok(String::new())); - } + let input = xargs_input(&ctx, &opts).await; + let plan = build_xargs_plan(&opts, &input); + let commands = opts.subcommands(plan.commands); // Fallback: output what would be run (for standalone builtin context). // Command-scoped assignments (e.g. the --process-slot-var index) are @@ -258,6 +682,11 @@ impl Builtin for Xargs { } output.push('\n'); } + if let Some(e) = plan.error { + let mut r = xargs_err(e); + r.stdout = output.into(); + return Ok(r); + } Ok(ExecResult::ok(output)) } @@ -267,13 +696,24 @@ impl Builtin for Xargs { Err(_) => return Ok(None), // Let execute() handle the error }; - let input = ctx.stdin.map(|stdin| &**stdin).unwrap_or(""); - let commands = build_xargs_commands(&opts, input); - if commands.is_empty() { - return Ok(None); + let input = xargs_input(ctx, &opts).await; + let plan = build_xargs_plan(&opts, &input); + let commands = opts.subcommands(plan.commands); + if !opts.verbose && plan.error.is_none() { + if commands.is_empty() { + return Ok(None); + } + return Ok(Some(ExecutionPlan::Batch { commands })); } - - Ok(Some(ExecutionPlan::Batch { commands })) + Ok(Some(ExecutionPlan::Driver(Box::new(XargsRun { + commands: commands.into(), + verbose: opts.verbose, + announced: false, + error: plan.error, + stdout: crate::StreamData::new(), + stderr: crate::StreamData::new(), + status: 0, + })))) } } @@ -597,6 +1037,67 @@ mod tests { // ==================== xargs tests ==================== + fn xargs_plan(args: &[&str], input: &str) -> (Vec>, Option) { + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let opts = parse_xargs_args(&args).unwrap_or_else(|e| panic!("{}", e.stderr)); + let plan = build_xargs_plan(&opts, input); + (plan.commands, plan.error) + } + + fn cmds(list: &[&[&str]]) -> Vec> { + list.iter() + .map(|c| c.iter().map(|s| s.to_string()).collect()) + .collect() + } + + #[test] + fn test_xargs_quotes_backslashes_and_nul() { + let (c, _) = xargs_plan(&["-n1"], "\"a b\" 'c' d\\ e x\0y\n"); + assert_eq!( + c, + cmds(&[ + &["echo", "a b"], + &["echo", "c"], + &["echo", "d e"], + &["echo", "x"] + ]) + ); + // An unmatched quote stops reading after running what was read. + let (c, e) = xargs_plan(&[], "a 'b\nc\n"); + assert_eq!(c, cmds(&[&["echo", "a"]])); + assert!(e.unwrap().contains("unmatched single quote")); + } + + #[test] + fn test_xargs_lines_eof_and_replace() { + let (c, _) = xargs_plan(&["-L1"], "a b \nc\nd\n"); + assert_eq!(c, cmds(&[&["echo", "a", "b", "c"], &["echo", "d"]])); + let (c, _) = xargs_plan(&["-E", "STOP"], "a STOP b\n"); + assert_eq!(c, cmds(&[&["echo", "a"]])); + let (c, _) = xargs_plan(&["-I{}", "-n1", "x", "[{}]"], " a b \n\nc\n"); + assert_eq!(c, cmds(&[&["x", "[a b ]"], &["x", "[c]"]])); + // -n after -I (other than 1) turns replacement off. + let (c, _) = xargs_plan(&["-I{}", "-n2", "x", "{}"], "a\nb\n"); + assert_eq!(c, cmds(&[&["x", "{}", "a", "b"]])); + } + + #[test] + fn test_xargs_delimiters_and_size_limit() { + let (c, _) = xargs_plan(&["-d", "\\x2c"], "a,,b"); + assert_eq!(c, cmds(&[&["echo", "a", "", "b"]])); + let (c, _) = xargs_plan(&["-0", "-n1"], "a b\0\0c\0"); + assert_eq!(c, cmds(&[&["echo", "a b"], &["echo", ""], &["echo", "c"]])); + let (c, _) = xargs_plan(&["-s", "9"], "a b c\n"); + assert_eq!(c, cmds(&[&["echo", "a", "b"], &["echo", "c"]])); + let (c, e) = xargs_plan(&["-I{}", "-s", "6", "echo", "x{}"], "abcde\n"); + assert!(c.is_empty() && e.is_some()); + // -I builds lazily: an over-long template is fine on empty input. + let (c, e) = xargs_plan(&["-I{}", "-s", "3", "echo", "{}"], ""); + assert!(c.is_empty() && e.is_none()); + assert!(xargs_delimiter("\\xg").is_err()); + assert!(xargs_delimiter("ab").is_err()); + } + #[tokio::test] async fn test_xargs_basic() { let (fs, mut cwd, mut variables) = create_test_ctx().await; @@ -945,7 +1446,11 @@ mod tests { let result = Xargs.execute(ctx).await.unwrap(); assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("invalid number for -P")); + assert!( + result + .stderr + .contains("invalid number \"abc\" for -P option") + ); } #[tokio::test] diff --git a/crates/bashkit/src/builtins/printf.rs b/crates/bashkit/src/builtins/printf.rs index 1c5c13096..9059d96b8 100644 --- a/crates/bashkit/src/builtins/printf.rs +++ b/crates/bashkit/src/builtins/printf.rs @@ -57,12 +57,22 @@ impl Builtin for Printf { var_name = Some(vname.clone()); } } + // `--` ends options; the next word is the format. + Some(arg) if arg == "--" => match args_iter.next() { + Some(f) => break f.clone(), + None => return Ok(ExecResult::ok(String::new())), + }, Some(arg) => break arg.clone(), None => return Ok(ExecResult::ok(String::new())), } }; let args: Vec = args_iter.cloned().collect(); + let format = escape_format_backslash_c(&format).into_owned(); + let (format, args) = match expand_quote_directives(&format, args) { + Ok(v) => v, + Err(err) => return Ok(ExecResult::err(err, 1)), + }; let (format, args) = match expand_time_directives(&format, args, |seconds, fmt| { self.clock.strftime(ctx.env.get("TZ"), seconds, fmt) }) { @@ -95,7 +105,10 @@ fn render_printf(format: &str, args: &[String]) -> std::result::Result std::result::Result, String> { +pub(super) fn render_printf_bytes( + format: &str, + args: &[String], +) -> std::result::Result, String> { let format = strip_zero_hex_escapes(format); let format = format.as_ref(); let values = format_arguments(args); @@ -122,6 +135,136 @@ fn render_printf_bytes(format: &str, args: &[String]) -> std::result::Result Cow<'_, str> { + if !format.contains("\\c") { + return Cow::Borrowed(format); + } + let mut out = String::with_capacity(format.len() + 4); + let mut chars = format.chars(); + while let Some(c) = chars.next() { + if c != '\\' { + out.push(c); + continue; + } + match chars.next() { + Some('c') => out.push_str("\\\\c"), + Some(n) => { + out.push('\\'); + out.push(n); + } + None => out.push('\\'), + } + } + Cow::Owned(out) +} + +/// One `%b` / `%q` / `%Q` directive the uutils formatter cannot handle. +struct QuoteSlot { + conv: u8, + precision: Option, +} + +/// Rewrite `%[flags][width][.prec]{b,q,Q}` that the uutils formatter rejects +/// (any flag/width/precision, and every `%Q`) into `%[flags][width]s`, +/// pre-rendering the matching arguments. +/// +/// Decision: bash pads `%b`/`%q` itself and never with zeros, applies the +/// precision to the expanded/quoted text, while `%Q` applies it to the +/// argument before quoting. Directives using `*` are left to the formatter. +fn expand_quote_directives( + format: &str, + mut args: Vec, +) -> std::result::Result<(String, Vec), String> { + let bytes = format.as_bytes(); + let mut out = String::with_capacity(format.len()); + let mut slots: Vec> = Vec::new(); + let mut i = 0; + let mut copied = 0; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + b'%' if bytes.get(i + 1) == Some(&b'%') => i += 2, + b'%' => { + let start = i; + i += 1; + let flags_start = i; + while i < bytes.len() && b"-+ #0'".contains(&bytes[i]) { + i += 1; + } + let flags = &format[flags_start..i]; + let width_start = i; + let mut star = false; + if bytes.get(i) == Some(&b'*') { + slots.push(None); + star = true; + i += 1; + } else { + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + } + let width = &format[width_start..i]; + let mut precision = None; + if bytes.get(i) == Some(&b'.') { + i += 1; + if bytes.get(i) == Some(&b'*') { + slots.push(None); + star = true; + i += 1; + } else { + let p = i; + while i < bytes.len() && bytes[i].is_ascii_digit() { + i += 1; + } + precision = Some(format[p..i].parse().unwrap_or(0)); + } + } + let Some(&conv) = bytes.get(i) else { break }; + i += 1; + let decorated = i - start > 2; + if !star && (conv == b'Q' || (matches!(conv, b'b' | b'q') && decorated)) { + out.push_str(&format[copied..start]); + out.push('%'); + out.push_str(&flags.replace('0', "")); + out.push_str(width); + out.push('s'); + copied = i; + slots.push(Some(QuoteSlot { conv, precision })); + } else { + slots.push(None); + } + } + _ => i += 1, + } + } + if !slots.iter().any(Option::is_some) { + return Ok((format.to_string(), args)); + } + out.push_str(&format[copied..]); + + let n = slots.len(); + let truncate = |s: String, p: Option| match p { + Some(p) => s.chars().take(p).collect(), + None => s, + }; + let render = |spec: &str, arg: &str| -> std::result::Result { + render_printf_bytes(spec, &[arg.to_string()]) + .map(|b| String::from_utf8_lossy(&b).into_owned()) + }; + for (idx, arg) in args.iter_mut().enumerate() { + if let Some(slot) = &slots[idx % n] { + *arg = match slot.conv { + b'b' => truncate(render("%b", arg)?, slot.precision), + b'q' => truncate(render("%q", arg)?, slot.precision), + _ => render("%q", &truncate(arg.clone(), slot.precision))?, + }; + } + } + Ok((out, args)) +} + /// One argument-consuming slot of a format pass. enum Slot { Plain, @@ -790,4 +933,21 @@ mod tests { crate::testing::assert_no_leak(&result, "printf_format_error_variant", &[]); } } + + #[test] + fn format_backslash_c_is_literal() { + let f = escape_format_backslash_c("a\\cb\\\\c"); + assert_eq!(render_printf(&f, &[]).unwrap(), "a\\cb\\c"); + } + + #[test] + fn padded_b_q_and_upper_q_directives() { + let args = |v: &[&str]| v.iter().map(|s| s.to_string()).collect::>(); + let (f, a) = + expand_quote_directives("[%05b|%05q|%05Q]", args(&["a\\tb", "c d", "e"])).unwrap(); + assert_eq!(render_printf(&f, &a).unwrap(), "[ a\tb| c\\ d| e]"); + let (f, a) = + expand_quote_directives("[%.2q|%.2Q|%-6q]", args(&["abc", "a bc", "x"])).unwrap(); + assert_eq!(render_printf(&f, &a).unwrap(), "[ab|a\\ |x ]"); + } } diff --git a/crates/bashkit/src/builtins/sed/mod.rs b/crates/bashkit/src/builtins/sed/mod.rs index 907b6a010..b6979998c 100644 --- a/crates/bashkit/src/builtins/sed/mod.rs +++ b/crates/bashkit/src/builtins/sed/mod.rs @@ -17,6 +17,9 @@ mod exec; mod pattern; + +// Shared with `grep`: one POSIX BRE/ERE -> `regex` crate translator. +pub(crate) use pattern::translate as translate_posix_regex; mod script; #[cfg(test)] diff --git a/crates/bashkit/src/builtins/sed/pattern.rs b/crates/bashkit/src/builtins/sed/pattern.rs index 3409d693f..3bec53fd9 100644 --- a/crates/bashkit/src/builtins/sed/pattern.rs +++ b/crates/bashkit/src/builtins/sed/pattern.rs @@ -19,7 +19,7 @@ use regex::{Regex, RegexBuilder}; use crate::builtins::search_common::{REGEX_DFA_SIZE_LIMIT, REGEX_SIZE_LIMIT}; /// Translate a POSIX BRE (or ERE, when `extended`) into `regex` crate syntax. -pub(super) fn translate(pattern: &str, extended: bool) -> String { +pub(crate) fn translate(pattern: &str, extended: bool) -> String { let chars: Vec = pattern.chars().collect(); let mut out = String::with_capacity(pattern.len() + 8); let mut i = 0; @@ -116,7 +116,8 @@ pub(super) fn translate(pattern: &str, extended: bool) -> String { branch_start = false; } '*' => { - if !extended && branch_start { + // A leading `*` is literal in BRE and (GNU) ERE alike. + if branch_start { out.push_str("\\*"); } else { out.push('*'); diff --git a/crates/bashkit/src/builtins/sed/script.rs b/crates/bashkit/src/builtins/sed/script.rs index df3074f10..b6d13ebb2 100644 --- a/crates/bashkit/src/builtins/sed/script.rs +++ b/crates/bashkit/src/builtins/sed/script.rs @@ -486,7 +486,10 @@ impl Parser { match self.bump() { // `\` is the delimiter as an ordinary character. Some(n) if n == delim => { - if n.is_ascii_alphanumeric() { + // In a regex GNU drops the backslash, so the + // character keeps its plain regex meaning: `\|` + // with `|` as delimiter is a literal `|` in BRE. + if bracket_aware || n.is_ascii_alphanumeric() { out.push(n); } else { out.push('\\'); @@ -578,6 +581,8 @@ impl Parser { }; Ok(Kind::List(width)) } + 'a' if self.bare_backslash_text() => Ok(Kind::Nop), + 'i' if self.bare_backslash_text() => Ok(Kind::Nop), 'a' => Ok(Kind::Append(self.text())), 'i' => Ok(Kind::Insert(self.text())), 'c' => Ok(Kind::Change(self.text())), @@ -645,6 +650,17 @@ impl Parser { /// Text argument of `a`, `i` and `c`, in both the POSIX `a\` + newline form /// and the GNU one-liner form. + /// `a\` / `i\` ending the script: GNU adds no line at all. + fn bare_backslash_text(&mut self) -> bool { + let save = self.pos; + self.skip_blanks(); + if self.eat('\\') && self.peek().is_none() { + return true; + } + self.pos = save; + false + } + fn text(&mut self) -> String { self.skip_blanks(); if self.eat('\\') { diff --git a/crates/bashkit/src/builtins/sed/tests.rs b/crates/bashkit/src/builtins/sed/tests.rs index bd4fa6760..6d97dcacf 100644 --- a/crates/bashkit/src/builtins/sed/tests.rs +++ b/crates/bashkit/src/builtins/sed/tests.rs @@ -800,6 +800,19 @@ async fn no_leak_invalid_regex() { ); } +#[tokio::test] +async fn escaped_delimiter_in_a_regex_is_the_plain_character() { + // `\|` with `|` as delimiter is a literal `|` in BRE, as in GNU sed. + assert_eq!(out(&["s|a\\|b|X|"], "a|b\n").await, "X\n"); + // ... and keeps its regex meaning when the character is special. + assert_eq!(out(&["s.a\\.b.X."], "axb\n").await, "X\n"); +} + +#[tokio::test] +async fn bare_append_backslash_adds_nothing() { + assert_eq!(out(&["a\\"], "a\n").await, "a\n"); +} + #[tokio::test] async fn gnu_character_escapes_are_literal() { assert_eq!(out(&["s/z/\\x5cA/"], "z\n").await, "\\A\n"); diff --git a/crates/bashkit/src/builtins/seq.rs b/crates/bashkit/src/builtins/seq.rs index 4daf0036e..eed9e67e1 100644 --- a/crates/bashkit/src/builtins/seq.rs +++ b/crates/bashkit/src/builtins/seq.rs @@ -1,4 +1,10 @@ //! seq builtin - print a sequence of numbers +//! +//! Decision: output formatting follows GNU `seq.c`: the default format is +//! `%.Pf` with P the larger fraction-digit count of FIRST and INCREMENT +//! (LAST does not count), `-w` zero-pads to the wider of FIRST/LAST in that +//! format, `-f` goes through the printf formatter, and each value is +//! computed as FIRST + i*INCREMENT so steps do not accumulate rounding. use async_trait::async_trait; @@ -14,10 +20,95 @@ use crate::interpreter::ExecResult; /// seq [OPTION]... FIRST INCREMENT LAST /// /// Options: +/// -f FORMAT Use printf-style floating-point FORMAT /// -s STRING Use STRING as separator (default: newline) /// -w Equalize width by padding with leading zeroes pub struct Seq; +/// A parsed operand with GNU's precision/width bookkeeping. +struct Operand { + value: f64, + /// Digits after the decimal point; `None` when not representable + /// as fixed-point (hex, inf, nan). + precision: Option, + width: usize, +} + +fn parse_operand(arg: &str) -> Option { + let t = arg.trim_start(); + let value: f64 = { + let lower = t.to_ascii_lowercase(); + let body = lower.trim_start_matches(['+', '-']); + if let Some(hex) = body.strip_prefix("0x") { + let neg = lower.starts_with('-'); + let v = u64::from_str_radix(hex, 16).ok()? as f64; + if neg { -v } else { v } + } else { + t.parse().ok()? + } + }; + if value.is_nan() { + return None; + } + let plain = !t.to_ascii_lowercase().contains(['x', 'i', 'n']); + let precision = plain.then(|| { + let mantissa_end = t.find(['e', 'E']).unwrap_or(t.len()); + let frac = t[..mantissa_end] + .split_once('.') + .map_or(0, |(_, f)| f.len()); + let exp: i64 = t + .get(mantissa_end + 1..) + .and_then(|e| e.parse().ok()) + .unwrap_or(0); + if exp < 0 { + frac + exp.unsigned_abs() as usize + } else { + frac.saturating_sub(exp as usize) + } + }); + Some(Operand { + value, + precision, + width: t.len(), + }) +} + +/// GNU `get_default_format`, as (zero-pad width, precision). +fn default_format( + first: &Operand, + step: &Operand, + last: &Operand, + equal_width: bool, +) -> Option<(usize, usize)> { + let prec = first.precision?.max(step.precision?); + let last_prec = last.precision?; + if !equal_width { + return Some((0, prec)); + } + let first_prec = first.precision?; + let mut first_width = first.width + (prec - first_prec); + let mut last_width = (last.width + prec).saturating_sub(last_prec); + if last_prec > 0 && prec == 0 { + last_width = last_width.saturating_sub(1); + } + if last_prec == 0 && prec > 0 { + last_width += 1; + } + if first_prec == 0 && prec > 0 { + first_width += 1; + } + Some((first_width.max(last_width), prec)) +} + +fn invalid_arg(arg: &str) -> ExecResult { + ExecResult::err( + format!( + "seq: invalid floating point argument: '{arg}'\nTry 'seq --help' for more information.\n" + ), + 1, + ) +} + /// Bytes per write when streaming into a pipeline (one pipe's capacity). const STREAM_CHUNK_BYTES: usize = 4 * 1024; @@ -26,120 +117,157 @@ impl Builtin for Seq { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = super::check_help_version( ctx.args, - "Usage: seq [OPTION]... LAST\n or: seq [OPTION]... FIRST LAST\n or: seq [OPTION]... FIRST INCREMENT LAST\nPrint numbers from FIRST to LAST, in steps of INCREMENT.\n\n -s STRING\tuse STRING to separate numbers (default: newline)\n -w\tequalize width by padding with leading zeroes\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: seq [OPTION]... LAST\n or: seq [OPTION]... FIRST LAST\n or: seq [OPTION]... FIRST INCREMENT LAST\nPrint numbers from FIRST to LAST, in steps of INCREMENT.\n\n -f, --format=FORMAT\tuse printf style floating-point FORMAT\n -s, --separator=STRING\tuse STRING to separate numbers (default: \\n)\n -w, --equal-width\tequalize width by padding with leading zeroes\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("seq (bashkit) 0.1"), ) { return Ok(r); } + // GNU checks each option position: an argument that looks like a + // negative number ends option parsing, so `seq -3 -1` counts while + // `seq -s -1 1 2` still takes `-1` as the separator. let mut separator = "\n".to_string(); let mut equal_width = false; + let mut format: Option = None; let mut nums: Vec = Vec::new(); - + let args = ctx.args; let mut i = 0; - while i < ctx.args.len() { - match ctx.args[i].as_str() { - "-s" => { - i += 1; - if i < ctx.args.len() { - separator = ctx.args[i].clone(); - } + while i < args.len() { + let a = args[i].as_str(); + i += 1; + let b = a.as_bytes(); + if b.len() > 1 && b[0] == b'-' && (b[1].is_ascii_digit() || b[1] == b'.') { + nums.extend(args[i - 1..].iter().cloned()); + break; + } + if a == "--" { + nums.extend(args[i..].iter().cloned()); + break; + } + if !a.starts_with('-') || a == "-" { + nums.push(a.to_string()); + continue; + } + if let Some(long) = a.strip_prefix("--") { + let (name, inline) = match long.split_once('=') { + Some((n, v)) => (n, Some(v.to_string())), + None => (long, None), + }; + let key = ["equal-width", "format", "separator"] + .into_iter() + .find(|n| !name.is_empty() && n.starts_with(name)); + let Some(key) = key else { + return Ok(super::invalid_option("seq", a, 1)); + }; + if key == "equal-width" { + equal_width = true; + continue; } - "-w" => equal_width = true, - arg if arg.starts_with("-s") => { - // -sSEP (no space) - separator = arg[2..].to_string(); + let value = match inline { + Some(v) => v, + None if i < args.len() => { + i += 1; + args[i - 1].clone() + } + None => { + return Ok(ExecResult::err( + format!("seq: option '--{key}' requires an argument\n"), + 1, + )); + } + }; + if key == "format" { + format = Some(value); + } else { + separator = value; } - _ => { - nums.push(ctx.args[i].clone()); + continue; + } + for (pos, c) in a[1..].char_indices() { + match c { + 'w' => equal_width = true, + 'f' | 's' => { + let attached = &a[1 + pos + 1..]; + let value = if !attached.is_empty() { + attached.to_string() + } else if i < args.len() { + i += 1; + args[i - 1].clone() + } else { + return Ok(ExecResult::err( + format!("seq: option requires an argument -- '{c}'\n"), + 1, + )); + }; + if c == 'f' { + format = Some(value); + } else { + separator = value; + } + break; + } + _ => return Ok(super::invalid_option("seq", &format!("-{c}"), 1)), } } - i += 1; + } + if format.is_some() && equal_width { + return Ok(ExecResult::err( + "seq: format string may not be specified when printing equal width strings\n" + .to_string(), + 1, + )); } if nums.is_empty() { return Ok(ExecResult::err("seq: missing operand\n".to_string(), 1)); } - - let (first, increment, last) = match nums.len() { - 1 => { - let last: f64 = match nums[0].parse() { - Ok(v) => v, - Err(_) => { - return Ok(ExecResult::err( - format!("seq: invalid floating point argument: '{}'\n", nums[0]), - 1, - )); - } - }; - (1.0_f64, 1.0_f64, last) + if nums.len() > 3 { + return Ok(ExecResult::err( + format!("seq: extra operand '{}'\n", nums[3]), + 1, + )); + } + let mut parsed = Vec::with_capacity(3); + for n in &nums { + match parse_operand(n) { + Some(op) => parsed.push(op), + None => return Ok(invalid_arg(n)), } + } + let one = || Operand { + value: 1.0, + precision: Some(0), + width: 1, + }; + let (first, step, last) = match parsed.len() { + 1 => (one(), one(), parsed.remove(0)), 2 => { - let first: f64 = match nums[0].parse() { - Ok(v) => v, - Err(_) => { - return Ok(ExecResult::err( - format!("seq: invalid floating point argument: '{}'\n", nums[0]), - 1, - )); - } - }; - let last: f64 = match nums[1].parse() { - Ok(v) => v, - Err(_) => { - return Ok(ExecResult::err( - format!("seq: invalid floating point argument: '{}'\n", nums[1]), - 1, - )); - } - }; - (first, 1.0, last) + let last = parsed.remove(1); + (parsed.remove(0), one(), last) } _ => { - let first: f64 = match nums[0].parse() { - Ok(v) => v, - Err(_) => { - return Ok(ExecResult::err( - format!("seq: invalid floating point argument: '{}'\n", nums[0]), - 1, - )); - } - }; - let increment: f64 = match nums[1].parse() { - Ok(v) => v, - Err(_) => { - return Ok(ExecResult::err( - format!("seq: invalid floating point argument: '{}'\n", nums[1]), - 1, - )); - } - }; - let last: f64 = match nums[2].parse() { - Ok(v) => v, - Err(_) => { - return Ok(ExecResult::err( - format!("seq: invalid floating point argument: '{}'\n", nums[2]), - 1, - )); - } - }; - (first, increment, last) + let last = parsed.remove(2); + let step = parsed.remove(1); + (parsed.remove(0), step, last) } }; - if increment == 0.0 { + if step.value == 0.0 { return Ok(ExecResult::err("seq: zero increment\n".to_string(), 1)); } - // Determine if all values are integers - let all_integer = first.fract() == 0.0 && increment.fract() == 0.0 && last.fract() == 0.0; - - // Calculate width for -w flag - let width = if equal_width && all_integer { - let first_w = format!("{}", first as i64).len(); - let last_w = format!("{}", last as i64).len(); - first_w.max(last_w) - } else { - 0 + let fixed = default_format(&first, &step, &last, equal_width); + let render = |x: f64| -> std::result::Result { + if let Some(fmt) = &format { + let bytes = super::printf::render_printf_bytes(fmt, &[format!("{x}")])?; + return Ok(String::from_utf8_lossy(&bytes).into_owned()); + } + Ok(match fixed { + Some((width, prec)) => format!("{x:0width$.prec$}"), + None => { + let bytes = super::printf::render_printf_bytes("%g", &[format!("{x}")])?; + String::from_utf8_lossy(&bytes).into_owned() + } + }) }; let mut output = String::new(); @@ -147,23 +275,16 @@ impl Builtin for Seq { // `seq 1000000 | head -1` stops early with SIGPIPE. let stream = ctx.stdout_stream(); let mut flushed = 0usize; - let mut current = first; - let mut first_item = true; - // THREAT[TM-DOS-058]: Limit iterations and output size to prevent memory // exhaustion; THREAT[TM-DOS-109]: hitting either cap is reported. - let mut count = 0; let mut capped = None; - + let mut i: u64 = 0; loop { - if increment > 0.0 && current > last + f64::EPSILON { + let x = first.value + (i as f64) * step.value; + if (step.value > 0.0 && x > last.value) || (step.value < 0.0 && x < last.value) { break; } - if increment < 0.0 && current < last - f64::EPSILON { - break; - } - count += 1; - if count > SEQ_MAX_LINES { + if i as usize >= SEQ_MAX_LINES { capped = Some(("line", SEQ_MAX_LINES)); break; } @@ -181,30 +302,20 @@ impl Builtin for Seq { capped = Some(("output byte", SEQ_MAX_OUTPUT_BYTES)); break; } - - if !first_item { + if i > 0 { output.push_str(&separator); } - first_item = false; - - if all_integer { - let val = current as i64; - if equal_width { - output.push_str(&format!("{:0>width$}", val, width = width)); - } else { - output.push_str(&format!("{}", val)); - } - } else { - // Format float, removing trailing zeros - let formatted = format!("{:.10}", current); - let trimmed = formatted.trim_end_matches('0').trim_end_matches('.'); - output.push_str(trimmed); + match render(x) { + Ok(s) => output.push_str(&s), + Err(e) => return Ok(ExecResult::err(e.replacen("printf:", "seq:", 1), 1)), + } + i += 1; + if !x.is_finite() { + break; } - - current += increment; } - if !first_item { + if i > 0 { output.push('\n'); } if let Some(stream) = &stream { @@ -395,4 +506,29 @@ mod tests { let result = Seq.execute(ctx).await.unwrap(); assert_eq!(result.stdout, "-3\n-2\n-1\n0\n"); } + + async fn run(args: &[&str]) -> ExecResult { + let (fs, mut cwd, mut variables) = setup().await; + let env = HashMap::new(); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + let ctx = Context::new_for_test(&args, &env, &mut variables, &mut cwd, fs.clone(), None); + Seq.execute(ctx).await.unwrap() + } + + #[tokio::test] + async fn seq_precision_follows_first_and_step() { + assert_eq!(run(&["1", "0.5", "2"]).await.stdout, "1.0\n1.5\n2.0\n"); + assert_eq!(run(&["1", "2.50"]).await.stdout, "1\n2\n"); + assert_eq!( + run(&["-w", "1", "-0.5", "-1"]).await.stdout, + "01.0\n00.5\n00.0\n-0.5\n-1.0\n" + ); + } + + #[tokio::test] + async fn seq_format_and_separator_value() { + assert_eq!(run(&["-f", "%03g", "9", "10"]).await.stdout, "009\n010\n"); + assert_eq!(run(&["-s", "-1", "1", "2"]).await.stdout, "1-12\n"); + assert_eq!(run(&["-f", "%e", "1"]).await.stdout, "1.000000e+00\n"); + } } diff --git a/crates/bashkit/src/builtins/sleep.rs b/crates/bashkit/src/builtins/sleep.rs index d706f179d..36169716b 100644 --- a/crates/bashkit/src/builtins/sleep.rs +++ b/crates/bashkit/src/builtins/sleep.rs @@ -36,25 +36,34 @@ impl Builtin for Sleep { ) { return Ok(r); } - let seconds = match ctx.args.first() { - Some(arg) => match arg.parse::() { - Ok(s) if s < 0.0 => { + // GNU sleep: only `--` ends options; every operand is a C strtod + // number (blanks, hex floats, `inf` allowed) plus an optional single + // s/m/h/d suffix, and the durations are summed. + // getopt permutes, so any `-X` before `--` (even `-1`) is an option. + let mut operands: Vec<&String> = Vec::new(); + let mut args = ctx.args.iter(); + while let Some(arg) = args.next() { + if arg == "--" { + operands.extend(args.by_ref()); + } else if arg.len() > 1 && arg.starts_with('-') { + return Ok(super::invalid_option("sleep", arg, 1)); + } else { + operands.push(arg); + } + } + if operands.is_empty() { + return Ok(Self::err("missing operand", 1)); + } + let mut seconds = 0.0f64; + for arg in operands { + match parse_interval(arg) { + Some(s) => seconds += s, + None => { return Ok(Self::err(format!("invalid time interval '{}'", arg), 1)); } - Ok(s) => s.min(MAX_SLEEP_SECONDS), - Err(_) => { - // Try parsing as integer for better error messages - if arg.parse::().is_ok() { - arg.parse::().unwrap_or(0.0).min(MAX_SLEEP_SECONDS) - } else { - return Ok(Self::err(format!("invalid time interval '{}'", arg), 1)); - } - } - }, - None => { - return Ok(Self::err("missing operand", 1)); } - }; + } + let seconds = seconds.min(MAX_SLEEP_SECONDS); if seconds > 0.0 { let duration = Duration::from_secs_f64(seconds); @@ -81,6 +90,22 @@ impl Builtin for Sleep { } } +/// Parse one GNU sleep operand into seconds; `None` when invalid or negative. +fn parse_interval(arg: &str) -> Option { + let (value, used) = super::sortuniq::strtod(arg.as_bytes())?; + let multiplier = match &arg[used..] { + "" | "s" => 1.0, + "m" => 60.0, + "h" => 3600.0, + "d" => 86400.0, + _ => return None, + }; + if value.is_nan() || value < 0.0 { + return None; + } + Some(value * multiplier) +} + #[cfg(any( test, all( @@ -179,6 +204,33 @@ mod tests { async fn test_sleep_negative() { let result = run_sleep(&["-1"]).await; assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("invalid time interval")); + assert!(result.stderr.contains("invalid option -- '1'")); + } + + #[test] + fn test_parse_interval_gnu_forms() { + assert_eq!(parse_interval("1"), Some(1.0)); + assert_eq!(parse_interval("1.5m"), Some(90.0)); + assert_eq!(parse_interval("2h"), Some(7200.0)); + assert_eq!(parse_interval("1d"), Some(86400.0)); + assert_eq!(parse_interval("0x10"), Some(16.0)); + assert_eq!(parse_interval("0x.8p1"), Some(1.0)); + assert_eq!(parse_interval(" 3"), Some(3.0)); + assert_eq!(parse_interval(".5"), Some(0.5)); + assert_eq!(parse_interval("inf"), Some(f64::INFINITY)); + assert_eq!(parse_interval("1x"), None); + assert_eq!(parse_interval("1ss"), None); + assert_eq!(parse_interval("3 "), None); + assert_eq!(parse_interval("nan"), None); + assert_eq!(parse_interval(""), None); + } + + #[tokio::test] + async fn test_sleep_sums_operands_and_dash_dash() { + let result = run_sleep(&["--", "0", "0s", "0m"]).await; + assert_eq!(result.exit_code, 0); + let result = run_sleep(&["0", "bad"]).await; + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("invalid time interval 'bad'")); } } diff --git a/crates/bashkit/src/builtins/sortuniq.rs b/crates/bashkit/src/builtins/sortuniq.rs index 8ee9402f0..352cbaf98 100644 --- a/crates/bashkit/src/builtins/sortuniq.rs +++ b/crates/bashkit/src/builtins/sortuniq.rs @@ -1,4 +1,13 @@ //! Sort and uniq builtins - sort lines and filter duplicates +//! +//! Decision: `sort` mirrors GNU coreutils `sort.c` semantics in the C locale +//! (byte comparison): fields keep their leading blanks unless `-b`, a key +//! with any ordering option does not inherit global ones, `-u`/`-s` disable +//! the whole-line last-resort comparison, and `-u` keeps the first line of +//! each equal run of a stable sort. Character positions in `-k` count UTF-8 +//! characters (not bytes) so keys never split a multibyte character. + +use std::cmp::Ordering; use async_trait::async_trait; @@ -9,320 +18,691 @@ use crate::interpreter::ExecResult; /// The sort builtin - sort lines of text. /// -/// Usage: sort [-cfhnMruVs] [-t DELIM] [-k KEYDEF] [-o FILE] [FILE...] +/// Usage: sort [OPTION]... [FILE]... /// -/// Options: -/// -f Fold lower case to upper case characters (case insensitive) -/// -n Compare according to string numerical value -/// -r Reverse the result of comparisons -/// -u Output only unique lines (like sort | uniq) -/// -V Natural sort of version numbers -/// -t Field delimiter character -/// -k Sort key definition (e.g., -k2 or -k2,2) -/// -s Stable sort (preserve input order for equal keys) -/// -c Check if input is sorted; exit 1 if not -/// -h Human numeric sort (1K, 2M, 3G) -/// -M Month sort (JAN < FEB < ... < DEC) -/// -o Write output to FILE +/// Ordering options (global or per `-k` key): `-b -d -f -g -h -i -M -n -R -r -V`. +/// Other options: `-c -C -m -o FILE -s -t SEP -k KEYDEF -u -z`, plus the +/// GNU long forms. `-S`, `-T`, `--parallel`, `--compress-program` and +/// `--batch-size` are accepted and ignored (no external resources). pub struct Sort; +/// Ordering options shared by the global key and per-field keys. +#[derive(Clone, Default, Debug)] +struct KeyOpts { + skip_sblanks: bool, + skip_eblanks: bool, + dictionary: bool, + nonprinting: bool, + fold: bool, + numeric: bool, + general: bool, + human: bool, + month: bool, + version: bool, + random: bool, + reverse: bool, +} + +impl KeyOpts { + /// GNU `default_key_compare`: no ordering option other than `-r`. + fn is_default(&self) -> bool { + !(self.skip_sblanks + || self.skip_eblanks + || self.dictionary + || self.nonprinting + || self.fold + || self.numeric + || self.general + || self.human + || self.month + || self.version + || self.random) + } + + /// Apply one ordering letter. `end` selects which side `b` applies to + /// (`None` = both, as for a global option). + fn set(&mut self, c: char, end: Option) -> bool { + match c { + 'b' => match end { + None => { + self.skip_sblanks = true; + self.skip_eblanks = true; + } + Some(false) => self.skip_sblanks = true, + Some(true) => self.skip_eblanks = true, + }, + 'd' => self.dictionary = true, + 'f' => self.fold = true, + 'g' => self.general = true, + 'h' => self.human = true, + 'i' => self.nonprinting = true, + 'M' => self.month = true, + 'n' => self.numeric = true, + 'R' => self.random = true, + 'r' => self.reverse = true, + 'V' => self.version = true, + _ => return false, + } + true + } +} + /// A parsed sort key definition from `-k KEYDEF`. -/// Format: `START[.CHAR][FLAGS][,END[.CHAR][FLAGS]]` +/// Format: `F[.C][OPTS][,F[.C][OPTS]]`. Words and chars are 0-based here. #[derive(Clone, Debug)] struct KeySpec { - start_field: usize, - start_char: usize, // 0 = whole field - end_field: usize, // 0 = end of line - end_char: usize, // 0 = end of field - numeric: bool, - reverse: bool, - fold_case: bool, - human_numeric: bool, - month_sort: bool, - #[allow(dead_code)] // Used when combined with sort -V feature - version_sort: bool, + sword: usize, + schar: usize, + /// `None` = key extends to end of line. + eword: Option, + /// 0 = end of the end field. + echar: usize, + opts: KeyOpts, } impl KeySpec { - /// Parse a KEYDEF string like "2", "2,3", "2.3,3.4", "2n,2", "2nr" - fn parse(spec: &str) -> Self { - let (start_part, end_part) = if let Some(comma) = spec.find(',') { - (&spec[..comma], Some(&spec[comma + 1..])) - } else { - (spec, None) + /// Parse a KEYDEF string like "2", "2,3", "2.3,3.4", "2n,2", "2nr". + fn parse(spec: &str) -> std::result::Result { + let bad = |why: &str| format!("sort: invalid field specification '{spec}': {why}\n"); + let (start, end) = match spec.split_once(',') { + Some((s, e)) => (s, Some(e)), + None => (spec, None), }; + let mut opts = KeyOpts::default(); + + let (f, c, rest) = split_pos(start); + let f: usize = f + .parse() + .map_err(|_| bad("invalid number at field start"))?; + if f == 0 { + return Err(bad("field number is zero")); + } + let schar = match c { + Some(c) => { + let c: usize = c.parse().map_err(|_| bad("invalid number after '.'"))?; + if c == 0 { + return Err(bad("character offset is zero")); + } + c - 1 + } + None => 0, + }; + for ch in rest.chars() { + if !opts.set(ch, Some(false)) { + return Err(format!("sort: stray character in field spec: '{spec}'\n")); + } + } - let (start_field, start_char, start_flags) = Self::parse_field_spec(start_part); - let (end_field, end_char, end_flags) = if let Some(ep) = end_part { - let (f, c, fl) = Self::parse_field_spec(ep); - (f, c, fl) - } else { - // No comma: -k2 means "field 2 only" (same as -k2,2) - (start_field, 0, String::new()) + let (eword, echar) = match end { + None => (None, 0), + Some(e) => { + let (f, c, rest) = split_pos(e); + let f: usize = f.parse().map_err(|_| bad("invalid number after ','"))?; + if f == 0 { + return Err(bad("field number is zero")); + } + let echar = match c { + Some(c) => c.parse().map_err(|_| bad("invalid number after '.'"))?, + None => 0, + }; + for ch in rest.chars() { + if !opts.set(ch, Some(true)) { + return Err(format!("sort: stray character in field spec: '{spec}'\n")); + } + } + (Some(f - 1), echar) + } }; - // Merge flags from both start and end parts - let all_flags: String = format!("{}{}", start_flags, end_flags); + Ok(KeySpec { + sword: f - 1, + schar, + eword, + echar, + opts, + }) + } + /// The global options as a whole-line key. + fn whole_line(opts: KeyOpts) -> Self { KeySpec { - start_field, - start_char, - end_field, - end_char, - numeric: all_flags.contains('n'), - reverse: all_flags.contains('r'), - fold_case: all_flags.contains('f'), - human_numeric: all_flags.contains('h'), - month_sort: all_flags.contains('M'), - version_sort: all_flags.contains('V'), + sword: 0, + schar: 0, + eword: None, + echar: 0, + opts, } } +} - /// Parse "FIELD[.CHAR][FLAGS]" → (field, char_pos, flags_string) - fn parse_field_spec(s: &str) -> (usize, usize, String) { - let mut i = 0; - let chars: Vec = s.chars().collect(); - // Parse field number - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - let field: usize = s[..i].parse().unwrap_or(0); +/// Split "F[.C]OPTS" into (F, Some(C), OPTS). +fn split_pos(s: &str) -> (&str, Option<&str>, &str) { + let f_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); + let (f, rest) = s.split_at(f_end); + if let Some(after) = rest.strip_prefix('.') { + let c_end = after + .find(|c: char| !c.is_ascii_digit()) + .unwrap_or(after.len()); + (f, Some(&after[..c_end]), &after[c_end..]) + } else { + (f, None, rest) + } +} - // Parse optional .CHAR - let mut char_pos = 0; - if i < chars.len() && chars[i] == '.' { - i += 1; - let start = i; - while i < chars.len() && chars[i].is_ascii_digit() { - i += 1; - } - char_pos = s[start..i].parse().unwrap_or(0); - } +fn is_blank(c: char) -> bool { + c == ' ' || c == '\t' || c == '\n' +} - // Remaining chars are flags - let flags = s[i..].to_string(); - (field, char_pos, flags) +fn skip_blanks(s: &str, mut pos: usize) -> usize { + while let Some(c) = s[pos..].chars().next() { + if !is_blank(c) { + break; + } + pos += c.len_utf8(); } + pos } -/// Split a line into fields using the given delimiter -fn split_fields(line: &str, delimiter: Option) -> Vec<&str> { - if let Some(delim) = delimiter { - line.split(delim).collect() - } else { - line.split_whitespace().collect() +fn skip_nonblanks(s: &str, mut pos: usize) -> usize { + while let Some(c) = s[pos..].chars().next() { + if is_blank(c) { + break; + } + pos += c.len_utf8(); } + pos } -/// Extract the sort key from a line based on field delimiter and key spec -fn char_to_byte_idx(s: &str, char_idx: usize) -> usize { - s.char_indices() - .nth(char_idx) - .map(|(byte_idx, _)| byte_idx) +/// Position just after the next `tab` (or end of line) from `pos`. +fn next_tab(s: &str, pos: usize, tab: char) -> usize { + s[pos..].find(tab).map(|i| pos + i).unwrap_or(s.len()) +} + +fn advance_chars(s: &str, pos: usize, n: usize) -> usize { + s[pos..] + .char_indices() + .nth(n) + .map(|(i, _)| pos + i) .unwrap_or(s.len()) } -fn extract_key_spec(line: &str, delimiter: Option, key: &KeySpec) -> String { - let fields = split_fields(line, delimiter); - if fields.is_empty() || key.start_field == 0 { - return line.to_string(); +/// GNU `begfield`: byte offset where the key starts. +fn begfield(line: &str, tab: Option, key: &KeySpec) -> usize { + let lim = line.len(); + let mut ptr = 0; + let mut sword = key.sword; + while ptr < lim && sword > 0 { + sword -= 1; + match tab { + Some(t) => { + ptr = next_tab(line, ptr, t); + if ptr < lim { + ptr += t.len_utf8(); + } + } + None => ptr = skip_nonblanks(line, skip_blanks(line, ptr)), + } } - - let start_idx = key.start_field.saturating_sub(1); - if start_idx >= fields.len() { - return String::new(); + if key.opts.skip_sblanks { + ptr = skip_blanks(line, ptr); } + advance_chars(line, ptr, key.schar) +} - let end_idx = if key.end_field == 0 { - fields.len() - 1 - } else { - (key.end_field.saturating_sub(1)).min(fields.len() - 1) +/// GNU `limfield`: byte offset where the key ends. +fn limfield(line: &str, tab: Option, key: &KeySpec) -> usize { + let lim = line.len(); + let Some(mut eword) = key.eword else { + return lim; }; + let echar = key.echar; + if echar == 0 { + eword += 1; + } + let mut ptr = 0; + while ptr < lim && eword > 0 { + eword -= 1; + match tab { + Some(t) => { + ptr = next_tab(line, ptr, t); + if ptr < lim && (eword > 0 || echar > 0) { + ptr += t.len_utf8(); + } + } + None => ptr = skip_nonblanks(line, skip_blanks(line, ptr)), + } + } + if echar != 0 { + if key.opts.skip_eblanks { + ptr = skip_blanks(line, ptr); + } + ptr = advance_chars(line, ptr, echar); + } + ptr +} + +fn key_text<'a>(line: &'a str, tab: Option, key: &KeySpec) -> &'a [u8] { + let beg = begfield(line, tab, key); + let lim = limfield(line, tab, key).max(beg); + &line.as_bytes()[beg..lim] +} - if start_idx > end_idx { - return String::new(); +fn skip_blank_bytes(s: &[u8]) -> &[u8] { + let n = s + .iter() + .take_while(|&&b| b == b' ' || b == b'\t' || b == b'\n') + .count(); + &s[n..] +} + +/// Decimal number as (negative, integer digits, fraction digits), with +/// insignificant zeros stripped so magnitudes compare exactly. +fn parse_decimal(s: &[u8]) -> (bool, &[u8], &[u8]) { + let s = skip_blank_bytes(s); + let (neg, s) = match s.first() { + Some(b'-') => (true, &s[1..]), + _ => (false, s), + }; + let int_len = s.iter().take_while(|b| b.is_ascii_digit()).count(); + let int = &s[..int_len]; + let int = &int[int.iter().take_while(|&&b| b == b'0').count()..]; + let mut frac: &[u8] = &[]; + if s.get(int_len) == Some(&b'.') { + let f = &s[int_len + 1..]; + let flen = f.iter().take_while(|b| b.is_ascii_digit()).count(); + let f = &f[..flen]; + let trail = f.iter().rev().take_while(|&&b| b == b'0').count(); + frac = &f[..flen - trail]; } + let zero = int.is_empty() && frac.is_empty(); + (neg && !zero, int, frac) +} - if start_idx == end_idx { - let field = fields[start_idx]; - let field_char_len = field.chars().count(); - let start_c = if key.start_char > 0 { - (key.start_char - 1).min(field_char_len) - } else { - 0 - }; - let end_c = if key.end_char > 0 { - key.end_char.min(field_char_len) +/// GNU `numcompare`/`strnumcmp`: exact decimal comparison, `-` sign only. +fn numcompare(a: &[u8], b: &[u8]) -> Ordering { + let (an, ai, af) = parse_decimal(a); + let (bn, bi, bf) = parse_decimal(b); + if an != bn { + return if an { + Ordering::Less } else { - field_char_len + Ordering::Greater }; - if start_c >= end_c { - return String::new(); - } - let start_b = char_to_byte_idx(field, start_c); - let end_b = char_to_byte_idx(field, end_c); - return field[start_b..end_b].to_string(); } + let mag = ai + .len() + .cmp(&bi.len()) + .then_with(|| ai.cmp(bi)) + .then_with(|| af.cmp(bf)); + if an { mag.reverse() } else { mag } +} - // Multi-field key - let mut result = String::new(); - for (i, field) in fields.iter().enumerate().take(end_idx + 1).skip(start_idx) { - if i > start_idx { - result.push(delimiter.unwrap_or(' ')); - } - if i == start_idx && key.start_char > 0 { - let field_char_len = field.chars().count(); - let sc = (key.start_char - 1).min(field_char_len); - let start_b = char_to_byte_idx(field, sc); - result.push_str(&field[start_b..]); - } else if i == end_idx && key.end_char > 0 { - let field_char_len = field.chars().count(); - let ec = key.end_char.min(field_char_len); - let end_b = char_to_byte_idx(field, ec); - result.push_str(&field[..end_b]); - } else { - result.push_str(field); +/// GNU `find_unit_order` for `-h`. +fn unit_order(s: &[u8]) -> i32 { + let s = skip_blank_bytes(s); + let (neg, s) = match s.first() { + Some(b'-') => (true, &s[1..]), + _ => (false, s), + }; + let mut i = 0; + let mut nonzero = false; + while i < s.len() && s[i].is_ascii_digit() { + nonzero |= s[i] != b'0'; + i += 1; + } + if s.get(i) == Some(&b'.') { + i += 1; + while i < s.len() && s[i].is_ascii_digit() { + nonzero |= s[i] != b'0'; + i += 1; } } - result + if !nonzero { + return 0; + } + let order = match s.get(i) { + Some(b'K') | Some(b'k') => 1, + Some(b'M') => 2, + Some(b'G') => 3, + Some(b'T') => 4, + Some(b'P') => 5, + Some(b'E') => 6, + Some(b'Z') => 7, + Some(b'Y') => 8, + Some(b'R') => 9, + Some(b'Q') => 10, + _ => 0, + }; + if neg { -order } else { order } +} + +fn human_compare(a: &[u8], b: &[u8]) -> Ordering { + unit_order(a) + .cmp(&unit_order(b)) + .then_with(|| numcompare(a, b)) +} + +/// `strtod`-style prefix parse: `None` when no number could be converted. +fn strtod_prefix(s: &[u8]) -> Option { + strtod(s).map(|(v, _)| v) } -/// Extract leading numeric prefix from a string for `sort -n`. -/// Real coreutils `sort -n` parses the leading numeric portion (optional sign, -/// digits, optional decimal point and digits) and treats the rest as non-numeric. -/// Non-numeric strings have value 0. -fn extract_numeric_prefix(s: &str) -> f64 { - let s = s.trim_start(); - if s.is_empty() { - return 0.0; - } - let chars: Vec = s.chars().collect(); - let mut end = 0; - // Optional sign - if end < chars.len() && (chars[end] == '+' || chars[end] == '-') { - end += 1; - } - // Digits - while end < chars.len() && chars[end].is_ascii_digit() { - end += 1; - } - // Optional decimal point + digits - if end < chars.len() && chars[end] == '.' { - end += 1; - while end < chars.len() && chars[end].is_ascii_digit() { - end += 1; +/// C `strtod` on a byte prefix: the value and how many bytes it consumed +/// (leading blanks, sign, decimal or hex-float digits, `inf`/`infinity`/`nan`). +pub(super) fn strtod(s: &[u8]) -> Option<(f64, usize)> { + let mut i = s + .iter() + .take_while(|b| matches!(b, b' ' | b'\t' | b'\n' | b'\r' | 0x0b | 0x0c)) + .count(); + let mut neg = false; + if let Some(&c) = s.get(i) + && (c == b'+' || c == b'-') + { + neg = c == b'-'; + i += 1; + } + let rest = &s[i..]; + let lower: Vec = rest + .iter() + .take(3) + .map(|b| b.to_ascii_lowercase()) + .collect(); + let sign = if neg { -1.0 } else { 1.0 }; + if lower == b"inf" { + let long = rest.len() >= 8 && rest[..8].eq_ignore_ascii_case(b"infinity"); + return Some((sign * f64::INFINITY, i + if long { 8 } else { 3 })); + } + if lower == b"nan" { + return Some((f64::NAN, i + 3)); + } + let hex_digit = |b: Option<&u8>| b.is_some_and(|b| b.is_ascii_hexdigit()); + if rest.len() > 2 + && rest[0] == b'0' + && (rest[1] == b'x' || rest[1] == b'X') + && (hex_digit(rest.get(2)) || (rest.get(2) == Some(&b'.') && hex_digit(rest.get(3)))) + { + let mut j = 2; + let mut val = 0.0f64; + while let Some(d) = rest.get(j).and_then(|b| (*b as char).to_digit(16)) { + val = val * 16.0 + d as f64; + j += 1; } + if rest.get(j) == Some(&b'.') { + j += 1; + let mut scale = 1.0 / 16.0; + while let Some(d) = rest.get(j).and_then(|b| (*b as char).to_digit(16)) { + val += d as f64 * scale; + scale /= 16.0; + j += 1; + } + } + if matches!(rest.get(j), Some(b'p') | Some(b'P')) { + let mut k = j + 1; + let mut eneg = false; + if let Some(&c) = rest.get(k) + && (c == b'+' || c == b'-') + { + eneg = c == b'-'; + k += 1; + } + let ds = rest[k..].iter().take_while(|b| b.is_ascii_digit()).count(); + if ds > 0 { + let e: i32 = std::str::from_utf8(&rest[k..k + ds]) + .ok() + .and_then(|t| t.parse().ok()) + .unwrap_or(i32::MAX); + val *= 2f64.powi(if eneg { -e } else { e }); + j = k + ds; + } + } + return Some((sign * val, i + j)); + } + let mut j = rest.iter().take_while(|b| b.is_ascii_digit()).count(); + let mut digits = j; + if rest.get(j) == Some(&b'.') { + let f = rest[j + 1..] + .iter() + .take_while(|b| b.is_ascii_digit()) + .count(); + digits += f; + j += 1 + f; } - if end == 0 || (end == 1 && (chars[0] == '+' || chars[0] == '-')) { - return 0.0; + if digits == 0 { + return None; } - let num_str: String = chars[..end].iter().collect(); - num_str.parse().unwrap_or(0.0) + if matches!(rest.get(j), Some(b'e') | Some(b'E')) { + let mut k = j + 1; + if matches!(rest.get(k), Some(b'+') | Some(b'-')) { + k += 1; + } + let ds = rest[k..].iter().take_while(|b| b.is_ascii_digit()).count(); + if ds > 0 { + j = k + ds; + } + } + let text = std::str::from_utf8(&rest[..j]).ok()?; + let text = text.strip_suffix('.').unwrap_or(text); + let text = if text.starts_with('.') { + format!("0{text}") + } else { + text.to_string() + }; + text.parse::().ok().map(|v| (sign * v, i + j)) } -/// Parse human-numeric value (e.g., "10K" → 10_000, "5M" → 5_000_000) -fn parse_human_numeric(s: &str) -> f64 { - let s = s.trim(); - if s.is_empty() { - return 0.0; - } - let last = s.as_bytes().last().copied().unwrap_or(b'0'); - let multiplier = match last { - b'K' | b'k' => 1_000.0, - b'M' | b'm' => 1_000_000.0, - b'G' | b'g' => 1_000_000_000.0, - b'T' | b't' => 1_000_000_000_000.0, - _ => return s.parse::().unwrap_or(0.0), - }; - let num_part = &s[..s.len() - 1]; - num_part.parse::().unwrap_or(0.0) * multiplier +/// GNU `general_numcompare`: conversion errors < NaN < numbers. +fn general_compare(a: &[u8], b: &[u8]) -> Ordering { + match (strtod_prefix(a), strtod_prefix(b)) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Less, + (Some(_), None) => Ordering::Greater, + (Some(x), Some(y)) => match (x.is_nan(), y.is_nan()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => x.partial_cmp(&y).unwrap_or(Ordering::Equal), + }, + } } -/// Compare two strings using version/natural sort order. -/// Splits strings into alternating non-digit and digit chunks and compares -/// each: non-digit chunks lexically, digit chunks numerically. -fn version_cmp(a: &str, b: &str) -> std::cmp::Ordering { - let mut ai = a.chars().peekable(); - let mut bi = b.chars().peekable(); +/// Month number (1-12) when the blank-trimmed text starts with an +/// abbreviated month name, else 0. +fn month_ordinal(s: &[u8]) -> u32 { + const MONTHS: [&[u8; 3]; 12] = [ + b"JAN", b"FEB", b"MAR", b"APR", b"MAY", b"JUN", b"JUL", b"AUG", b"SEP", b"OCT", b"NOV", + b"DEC", + ]; + let s = skip_blank_bytes(s); + if s.len() < 3 { + return 0; + } + let head = [ + s[0].to_ascii_uppercase(), + s[1].to_ascii_uppercase(), + s[2].to_ascii_uppercase(), + ]; + MONTHS + .iter() + .position(|m| **m == head) + .map(|i| i as u32 + 1) + .unwrap_or(0) +} +/// gnulib `file_prefixlen`: length without the trailing +/// `(\.[A-Za-z~][A-Za-z0-9~]*)*` suffix. +fn file_prefixlen(s: &[u8]) -> usize { + let n = s.len(); + let mut prefixlen = 0; + let mut i = 0; loop { - match (ai.peek(), bi.peek()) { - (None, None) => return std::cmp::Ordering::Equal, - (None, Some(_)) => return std::cmp::Ordering::Less, - (Some(_), None) => return std::cmp::Ordering::Greater, - _ => {} + if i == n { + return prefixlen; } - - // Collect non-digit prefix from both - let mut a_text = String::new(); - let mut b_text = String::new(); - while let Some(&c) = ai.peek() { - if c.is_ascii_digit() { - break; + i += 1; + prefixlen = i; + while i + 1 < n && s[i] == b'.' && (s[i + 1].is_ascii_alphabetic() || s[i + 1] == b'~') { + i += 2; + while i < n && (s[i].is_ascii_alphanumeric() || s[i] == b'~') { + i += 1; } - a_text.push(c); - ai.next(); } - while let Some(&c) = bi.peek() { - if c.is_ascii_digit() { - break; + } +} + +fn ver_order(s: &[u8], pos: usize) -> i32 { + match s.get(pos) { + None => -1, + Some(c) if c.is_ascii_digit() => 0, + Some(c) if c.is_ascii_alphabetic() => *c as i32, + Some(b'~') => -2, + Some(c) => *c as i32 + 256, + } +} + +/// dpkg-style `verrevcmp` used by gnulib `filevercmp`. +fn verrevcmp(a: &[u8], b: &[u8]) -> Ordering { + let (mut i, mut j) = (0, 0); + while i < a.len() || j < b.len() { + let mut first_diff = 0i32; + while (i < a.len() && !a[i].is_ascii_digit()) || (j < b.len() && !b[j].is_ascii_digit()) { + let (ac, bc) = (ver_order(a, i), ver_order(b, j)); + if ac != bc { + return ac.cmp(&bc); } - b_text.push(c); - bi.next(); + i += 1; + j += 1; } - if a_text != b_text { - return a_text.cmp(&b_text); + while i < a.len() && a[i] == b'0' { + i += 1; } - - // Collect digit chunk from both - let mut a_num = String::new(); - let mut b_num = String::new(); - while let Some(&c) = ai.peek() { - if !c.is_ascii_digit() { - break; - } - a_num.push(c); - ai.next(); + while j < b.len() && b[j] == b'0' { + j += 1; } - while let Some(&c) = bi.peek() { - if !c.is_ascii_digit() { - break; + while i < a.len() && j < b.len() && a[i].is_ascii_digit() && b[j].is_ascii_digit() { + if first_diff == 0 { + first_diff = a[i] as i32 - b[j] as i32; } - b_num.push(c); - bi.next(); + i += 1; + j += 1; } - if a_num.is_empty() && b_num.is_empty() { - continue; + if i < a.len() && a[i].is_ascii_digit() { + return Ordering::Greater; } - let an: u64 = a_num.parse().unwrap_or(0); - let bn: u64 = b_num.parse().unwrap_or(0); - if an != bn { - return an.cmp(&bn); + if j < b.len() && b[j].is_ascii_digit() { + return Ordering::Less; } - // Equal numeric value but different representations (e.g. "01" vs "1"): - // shorter string (fewer leading zeros) sorts first - if a_num.len() != b_num.len() { - return a_num.len().cmp(&b_num.len()); + if first_diff != 0 { + return first_diff.cmp(&0); } } + Ordering::Equal } -/// Parse month abbreviation to ordinal (1-12, 0 for unknown) -fn month_ordinal(s: &str) -> u32 { - match s.trim().to_uppercase().as_str() { - "JAN" => 1, - "FEB" => 2, - "MAR" => 3, - "APR" => 4, - "MAY" => 5, - "JUN" => 6, - "JUL" => 7, - "AUG" => 8, - "SEP" => 9, - "OCT" => 10, - "NOV" => 11, - "DEC" => 12, - _ => 0, +/// gnulib `filevercmp` (what `sort -V` uses). +fn filevercmp(a: &[u8], b: &[u8]) -> Ordering { + match (a.is_empty(), b.is_empty()) { + (true, true) => return Ordering::Equal, + (true, false) => return Ordering::Less, + (false, true) => return Ordering::Greater, + _ => {} + } + if a[0] == b'.' { + if b[0] != b'.' { + return Ordering::Less; + } + let (adot, bdot) = (a == b".", b == b"."); + if adot || bdot { + return bdot.cmp(&adot); + } + let (add, bdd) = (a == b"..", b == b".."); + if add || bdd { + return bdd.cmp(&add); + } + } else if b[0] == b'.' { + return Ordering::Greater; + } + let (ap, bp) = (file_prefixlen(a), file_prefixlen(b)); + let one_pass = ap == a.len() && bp == b.len(); + let r = verrevcmp(&a[..ap], &b[..bp]); + if r != Ordering::Equal || one_pass { + r + } else { + verrevcmp(a, b) + } +} + +/// Compare two strings using version sort order (`sort -V`). +#[cfg(test)] +fn version_cmp(a: &str, b: &str) -> Ordering { + filevercmp(a.as_bytes(), b.as_bytes()) +} + +struct SortConfig { + keys: Vec, + tab: Option, + reverse: bool, + unique: bool, + stable: bool, + random: std::collections::hash_map::RandomState, +} + +impl SortConfig { + fn compare_key(&self, a: &[u8], b: &[u8], o: &KeyOpts) -> Ordering { + let transform = |s: &[u8]| -> Vec { + s.iter() + .filter(|&&c| { + (!o.dictionary || c.is_ascii_alphanumeric() || c == b' ' || c == b'\t') + && (!o.nonprinting || (0x20..0x7f).contains(&c)) + }) + .map(|&c| if o.fold { c.to_ascii_uppercase() } else { c }) + .collect() + }; + let (ta, tb); + let (a, b) = if o.dictionary || o.nonprinting || o.fold { + ta = transform(a); + tb = transform(b); + (&ta[..], &tb[..]) + } else { + (a, b) + }; + if o.numeric { + numcompare(a, b) + } else if o.general { + general_compare(a, b) + } else if o.human { + human_compare(a, b) + } else if o.month { + month_ordinal(a).cmp(&month_ordinal(b)) + } else if o.random { + use std::hash::BuildHasher; + self.random + .hash_one(a) + .cmp(&self.random.hash_one(b)) + .then_with(|| a.cmp(b)) + } else if o.version { + filevercmp(a, b) + } else { + a.cmp(b) + } + } + + fn compare(&self, a: &str, b: &str) -> Ordering { + if !self.keys.is_empty() { + for key in &self.keys { + let ka = key_text(a, self.tab, key); + let kb = key_text(b, self.tab, key); + let d = self.compare_key(ka, kb, &key.opts); + let d = if key.opts.reverse { d.reverse() } else { d }; + if d != Ordering::Equal { + return d; + } + } + if self.unique || self.stable { + return Ordering::Equal; + } + } + let d = a.as_bytes().cmp(b.as_bytes()); + if self.reverse { d.reverse() } else { d } } } @@ -336,270 +716,360 @@ fn split_records(text: &str, sep: char) -> impl Iterator { .flatten() } +enum CheckMode { + Off, + Diagnose, + Quiet, +} + +const SORT_HELP: &str = "Usage: sort [OPTION]... [FILE]...\nWrite sorted concatenation of all FILE(s) to standard output.\n\n -b\t\tignore leading blanks\n -d\t\tconsider only blanks and alphanumeric characters\n -f\t\tfold lower case to upper case characters\n -g\t\tcompare according to general numerical value\n -i\t\tconsider only printable characters\n -M\t\tcompare (unknown) < 'JAN' < ... < 'DEC'\n -h\t\tcompare human readable numbers (e.g., 2K 1G)\n -n\t\tcompare according to string numerical value\n -R\t\tshuffle, but group identical keys\n -r\t\treverse the result of comparisons\n -V\t\tnatural sort of (version) numbers within text\n --sort=WORD\tsort according to WORD\n -c, -C\tcheck for sorted input\n -k KEYDEF\tsort via a key definition\n -m\t\tmerge already sorted files\n -o FILE\twrite output to FILE\n -s\t\tstable sort\n -t SEP\tuse SEP as field separator\n -u\t\toutput only the first of an equal run\n -z\t\tline delimiter is NUL, not newline\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n"; + +fn sort_err(msg: impl Into) -> ExecResult { + ExecResult::err(msg.into(), 2) +} + #[async_trait] impl Builtin for Sort { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( - ctx.args, - "Usage: sort [OPTION]... [FILE]...\nWrite sorted concatenation of all FILE(s) to standard output.\n\n -f\t\tfold lower case to upper case characters\n -n\t\tcompare according to string numerical value\n -r\t\treverse the result of comparisons\n -u\t\toutput only unique lines\n -V\t\tnatural sort of version numbers\n -t DELIM\tuse DELIM as field separator\n -k KEYDEF\tsort via a key definition\n -s\t\tstable sort\n -c\t\tcheck for sorted input\n -h\t\thuman numeric sort (1K, 2M, 3G)\n -M\t\tmonth sort\n -m\t\tmerge already sorted files\n -o FILE\twrite output to FILE\n -z\t\tline delimiter is NUL, not newline\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", - Some("sort (bashkit) 0.1"), - ) { + if let Some(r) = super::check_help_version(ctx.args, SORT_HELP, Some("sort (bashkit) 0.1")) + { return Ok(r); } - let mut reverse = false; - let mut numeric = false; + let mut gkey = KeyOpts::default(); let mut unique = false; - let mut fold_case = false; let mut stable = false; - let mut check_sorted = false; - let mut human_numeric = false; - let mut month_sort = false; - let mut version_sort = false; + let mut check = CheckMode::Off; let mut merge = false; - let mut delimiter: Option = None; + let mut tab: Option = None; let mut key_specs: Vec = Vec::new(); let mut output_file: Option = None; let mut zero_terminated = false; - let mut files = Vec::new(); + let mut files: Vec = Vec::new(); - let mut p = super::arg_parser::ArgParser::new(ctx.args); - while !p.is_done() { - if let Some(val) = p.flag_value_opt("-t") { - delimiter = val.chars().next(); - } else if let Some(val) = p.flag_value_opt("-k") { - key_specs.push(KeySpec::parse(val)); - } else if let Some(val) = p.flag_value_opt("-o") { - output_file = Some(val.to_string()); + #[allow(clippy::result_large_err)] + let set_tab = |val: &str, tab: &mut Option| -> std::result::Result<(), ExecResult> { + let c = if val == "\\0" { + '\0' } else { - let flags = p.bool_flags("rnufscChMmVz"); - if !flags.is_empty() { - for c in flags { + let mut it = val.chars(); + match (it.next(), it.next()) { + (None, _) => return Err(sort_err("sort: empty tab\n")), + (Some(c), None) => c, + _ => return Err(sort_err(format!("sort: multi-character tab '{val}'\n"))), + } + }; + *tab = Some(c); + Ok(()) + }; + + let args = ctx.args; + let mut i = 0; + let mut opts_done = false; + while i < args.len() { + let arg = args[i].as_str(); + i += 1; + if opts_done || arg == "-" || !arg.starts_with('-') { + files.push(arg.to_string()); + continue; + } + if arg == "--" { + opts_done = true; + continue; + } + if let Some(long) = arg.strip_prefix("--") { + let (name, inline) = match long.split_once('=') { + Some((n, v)) => (n, Some(v.to_string())), + None => (long, None), + }; + const LONGS: &[(&str, bool)] = &[ + ("batch-size", true), + ("buffer-size", true), + ("check", false), + ("compress-program", true), + ("debug", false), + ("dictionary-order", false), + ("field-separator", true), + ("general-numeric-sort", false), + ("human-numeric-sort", false), + ("ignore-case", false), + ("ignore-leading-blanks", false), + ("ignore-nonprinting", false), + ("key", true), + ("merge", false), + ("month-sort", false), + ("numeric-sort", false), + ("output", true), + ("parallel", true), + ("random-sort", false), + ("random-source", true), + ("reverse", false), + ("sort", true), + ("stable", false), + ("temporary-directory", true), + ("unique", false), + ("version-sort", false), + ("zero-terminated", false), + ]; + // Unambiguous prefixes are accepted, as getopt_long does. + let matches: Vec<&(&str, bool)> = match LONGS.iter().find(|(n, _)| *n == name) { + Some(m) => vec![m], + None => LONGS.iter().filter(|(n, _)| n.starts_with(name)).collect(), + }; + let &(lname, takes) = match matches.as_slice() { + [m] => *m, + [] => return Ok(super::invalid_option("sort", arg, 2)), + _ => { + return Ok(sort_err(format!("sort: option '--{name}' is ambiguous\n"))); + } + }; + let value = if takes { + match inline { + Some(v) => v, + None if i < args.len() => { + i += 1; + args[i - 1].clone() + } + None => { + return Ok(sort_err(format!( + "sort: option '--{lname}' requires an argument\n" + ))); + } + } + } else { + inline.clone().unwrap_or_default() + }; + match lname { + "check" => { + check = match value.as_str() { + "" | "diagnose-first" => CheckMode::Diagnose, + "quiet" | "silent" => CheckMode::Quiet, + other => { + return Ok(sort_err(format!( + "sort: invalid argument '{other}' for '--check'\n" + ))); + } + } + } + "dictionary-order" => gkey.dictionary = true, + "field-separator" => { + if let Err(e) = set_tab(&value, &mut tab) { + return Ok(e); + } + } + "general-numeric-sort" => gkey.general = true, + "human-numeric-sort" => gkey.human = true, + "ignore-case" => gkey.fold = true, + "ignore-leading-blanks" => { + gkey.set('b', None); + } + "ignore-nonprinting" => gkey.nonprinting = true, + "key" => match KeySpec::parse(&value) { + Ok(k) => key_specs.push(k), + Err(e) => return Ok(sort_err(e)), + }, + "merge" => merge = true, + "month-sort" => gkey.month = true, + "numeric-sort" => gkey.numeric = true, + "output" => output_file = Some(value), + "random-sort" => gkey.random = true, + "reverse" => gkey.reverse = true, + "sort" => { + // Unambiguous prefixes are accepted, like `--sort=num`. + const WORDS: [(&str, char); 6] = [ + ("general-numeric", 'g'), + ("human-numeric", 'h'), + ("month", 'M'), + ("numeric", 'n'), + ("random", 'R'), + ("version", 'V'), + ]; + let hits: Vec = match WORDS.iter().find(|(w, _)| *w == value) { + Some((_, c)) => vec![*c], + None => WORDS + .iter() + .filter(|(w, _)| !value.is_empty() && w.starts_with(&*value)) + .map(|(_, c)| *c) + .collect(), + }; + match hits.as_slice() { + [c] => { + gkey.set(*c, None); + } + _ => { + return Ok(sort_err(format!( + "sort: invalid argument '{value}' for '--sort'\n" + ))); + } + } + } + "stable" => stable = true, + "unique" => unique = true, + "version-sort" => gkey.version = true, + "zero-terminated" => zero_terminated = true, + // Resource-tuning options have nothing to tune here. + _ => {} + } + continue; + } + // Bundle of short options. + let body = &arg[1..]; + for (pos, c) in body.char_indices() { + match c { + 'k' | 'o' | 't' | 'S' | 'T' | 'y' => { + let attached = &body[pos + c.len_utf8()..]; + let value = if !attached.is_empty() { + attached.to_string() + } else if c == 'y' { + String::new() + } else if i < args.len() { + i += 1; + args[i - 1].clone() + } else { + return Ok(sort_err(format!( + "sort: option requires an argument -- '{c}'\n" + ))); + }; match c { - 'r' => reverse = true, - 'n' => numeric = true, - 'u' => unique = true, - 'f' => fold_case = true, - 's' => stable = true, - 'c' | 'C' => check_sorted = true, - 'h' => human_numeric = true, - 'V' => version_sort = true, - 'M' => month_sort = true, - 'm' => merge = true, - 'z' => zero_terminated = true, + 'k' => match KeySpec::parse(&value) { + Ok(k) => key_specs.push(k), + Err(e) => return Ok(sort_err(e)), + }, + 'o' => output_file = Some(value), + 't' => { + if let Err(e) = set_tab(&value, &mut tab) { + return Ok(e); + } + } _ => {} } + break; + } + 'c' => check = CheckMode::Diagnose, + 'C' => check = CheckMode::Quiet, + 'm' => merge = true, + 's' => stable = true, + 'u' => unique = true, + 'z' => zero_terminated = true, + c => { + if !gkey.set(c, None) { + return Ok(super::invalid_option("sort", &format!("-{c}"), 2)); + } } - } else if p.is_flag() && p.current() != Some("--") { - // Reject unknown options instead of treating them as files. - return Ok(super::invalid_option( - "sort", - p.current().unwrap_or_default(), - 2, - )); - } else if let Some(arg) = p.positional() { - files.push(arg.to_string()); } } } - // 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 { - all_lines.extend(split_records(stdin, line_sep).map(str::to_string)); - } - } else { - for file in &files { - let path = if file.starts_with('/') { - std::path::PathBuf::from(file) - } else { - vfs_join(ctx.cwd, file) - }; - - let text = match read_text_file(&*ctx.fs, &path, "sort").await { - Ok(t) => t, - Err(e) => return Ok(e), - }; - all_lines.extend(split_records(&text, line_sep).map(str::to_string)); + // Keys without ordering options inherit the global ones (GNU rule). + for key in &mut key_specs { + if key.opts.is_default() && !key.opts.reverse { + key.opts = gkey.clone(); } } + if key_specs.is_empty() && !gkey.is_default() { + key_specs.push(KeySpec::whole_line(gkey.clone())); + } + let cfg = SortConfig { + keys: key_specs, + tab, + reverse: gkey.reverse, + unique, + stable, + random: std::collections::hash_map::RandomState::new(), + }; - // Merge mode: k-way merge of pre-sorted inputs - if merge && !files.is_empty() { - let mut streams: Vec> = Vec::new(); - for file in &files { + if files.is_empty() { + files.push("-".to_string()); + } + let line_sep = if zero_terminated { '\0' } else { '\n' }; + let mut streams: Vec> = Vec::new(); + for file in &files { + let text = if file == "-" { + ctx.stdin.map(|s| s.to_string()).unwrap_or_default() + } else { let path = if file.starts_with('/') { std::path::PathBuf::from(file) } else { vfs_join(ctx.cwd, file) }; - let text = match read_text_file(&*ctx.fs, &path, "sort").await { + match read_text_file(&*ctx.fs, &path, "sort").await { Ok(t) => t, - Err(e) => return Ok(e), - }; - let lines: Vec = split_records(&text, line_sep) - .map(|l| l.to_string()) - .collect(); - streams.push(lines); - } - // k-way merge using indices - let mut indices: Vec = vec![0; streams.len()]; - let mut merged = Vec::new(); - loop { - let mut best: Option<(usize, &str)> = None; - for (i, stream) in streams.iter().enumerate() { - if indices[i] < stream.len() { - let line = &stream[indices[i]]; - if let Some((_, best_line)) = best { - if line.as_str() < best_line { - best = Some((i, line)); - } - } else { - best = Some((i, line)); - } + Err(mut e) => { + e.exit_code = 2; + return Ok(e); } } - if let Some((i, line)) = best { - merged.push(line.to_string()); - indices[i] += 1; - } else { - break; - } - } - let sep = if zero_terminated { "\0" } else { "\n" }; - let mut output = merged.join(sep); - if !output.is_empty() { - output.push_str(sep); - } - return Ok(ExecResult::ok(output)); + }; + streams.push(split_records(&text, line_sep).map(str::to_string).collect()); } - // Check sorted mode - if check_sorted { - for i in 1..all_lines.len() { - let cmp = if numeric { - let a: f64 = all_lines[i - 1].trim().parse().unwrap_or(0.0); - let b: f64 = all_lines[i].trim().parse().unwrap_or(0.0); - a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal) - } else { - all_lines[i - 1].cmp(&all_lines[i]) - }; - let out_of_order = if reverse { - cmp == std::cmp::Ordering::Less - } else { - cmp == std::cmp::Ordering::Greater - }; - if out_of_order { - return Ok(ExecResult::err( - format!("sort: -:{}:disorder: {}\n", i + 1, all_lines[i]), - 1, - )); + match check { + CheckMode::Off => {} + mode => { + let lines = streams.concat(); + for i in 1..lines.len() { + let d = cfg.compare(&lines[i - 1], &lines[i]); + if d == Ordering::Greater || (unique && d == Ordering::Equal) { + let stderr = match mode { + CheckMode::Diagnose => { + format!("sort: {}:{}: disorder: {}\n", files[0], i + 1, lines[i]) + } + _ => String::new(), + }; + return Ok(ExecResult::err(stderr, 1)); + } } + return Ok(ExecResult::ok(String::new())); } - return Ok(ExecResult::ok(String::new())); } - // Get the key extractor - /// Compare two keys using the specified sort mode flags - fn compare_keys( - ka: &str, - kb: &str, - is_numeric: bool, - is_human: bool, - is_month: bool, - is_fold_case: bool, - ) -> std::cmp::Ordering { - if is_human { - let na = parse_human_numeric(ka); - let nb = parse_human_numeric(kb); - na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal) - } else if is_month { - month_ordinal(ka).cmp(&month_ordinal(kb)) - } else if is_numeric { - let na = extract_numeric_prefix(ka); - let nb = extract_numeric_prefix(kb); - na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal) - } else if is_fold_case { - ka.to_lowercase().cmp(&kb.to_lowercase()) - } else { - ka.cmp(kb) - } - } - - // Sort the lines - let sort_fn = |a: &String, b: &String| -> std::cmp::Ordering { - let ord = if !key_specs.is_empty() { - // Multi-key sort: compare by each key spec in order - let mut result = std::cmp::Ordering::Equal; - for key in &key_specs { - let ka = extract_key_spec(a, delimiter, key); - let kb = extract_key_spec(b, delimiter, key); - // Per-key flags override global flags - let ord = if key.version_sort || version_sort { - version_cmp(&ka, &kb) - } else { - compare_keys( - &ka, - &kb, - key.numeric || numeric, - key.human_numeric || human_numeric, - key.month_sort || month_sort, - key.fold_case || fold_case, - ) - }; - let ord = if key.reverse { ord.reverse() } else { ord }; - if ord != std::cmp::Ordering::Equal { - result = ord; - break; + let mut all_lines: Vec = if merge { + // k-way merge of pre-sorted inputs; ties go to the earlier input. + let mut idx = vec![0usize; streams.len()]; + let mut merged = Vec::new(); + loop { + let mut best: Option = None; + for (s, stream) in streams.iter().enumerate() { + if idx[s] >= stream.len() { + continue; } + best = match best { + Some(b) + if cfg.compare(&stream[idx[s]], &streams[b][idx[b]]) + != Ordering::Less => + { + Some(b) + } + _ => Some(s), + }; } - if result == std::cmp::Ordering::Equal && !stable { - // GNU sort uses the full line as the final tie-breaker unless -s disables it. - a.cmp(b) - } else { - result - } - } else { - // No key specs — use global flags on whole line - let ord = if version_sort { - version_cmp(a, b) - } else { - compare_keys(a, b, numeric, human_numeric, month_sort, fold_case) - }; - if ord == std::cmp::Ordering::Equal && !stable { - // Without -s, equal mode-specific keys still fall back to lexical line order. - a.cmp(b) - } else { - ord - } - }; - - if reverse && ord != std::cmp::Ordering::Equal { - ord.reverse() - } else { - ord + let Some(b) = best else { break }; + merged.push(std::mem::take(&mut streams[b][idx[b]])); + idx[b] += 1; } - }; - - if stable { - all_lines.sort_by(sort_fn); + merged } else { - all_lines.sort_unstable_by(sort_fn); - } + let mut v = streams.concat(); + // Stable so `-u` keeps the first input line of each equal run. + v.sort_by(|a, b| cfg.compare(a, b)); + v + }; if unique { - // Use a HashSet so identical lines are removed even when not adjacent - // (can happen in stable mode when equal-key different-content lines interleave). - let mut seen = std::collections::HashSet::new(); - all_lines.retain(|line| seen.insert(line.clone())); + let mut out: Vec = Vec::with_capacity(all_lines.len()); + for line in all_lines.drain(..) { + if out + .last() + .is_none_or(|prev| cfg.compare(prev, &line) != Ordering::Equal) + { + out.push(line); + } + } + all_lines = out; } let sep = if zero_terminated { "\0" } else { "\n" }; let mut output = all_lines.join(sep); - if !output.is_empty() { + if !all_lines.is_empty() { output.push_str(sep); } @@ -611,7 +1081,7 @@ impl Builtin for Sort { vfs_join(ctx.cwd, outfile) }; if let Err(e) = ctx.fs.write_file(&path, output.as_bytes()).await { - return Ok(ExecResult::err(format!("sort: {}: {}\n", outfile, e), 1)); + return Ok(sort_err(format!("sort: {}: {}\n", outfile, e))); } return Ok(ExecResult::ok(String::new())); } @@ -622,158 +1092,255 @@ impl Builtin for Sort { /// The uniq builtin - report or omit repeated lines. /// -/// Usage: uniq [-cdiu] [-f N] [INPUT [OUTPUT]] +/// Usage: uniq [-cdDiuz] [-f N] [-s N] [-w N] [INPUT [OUTPUT]] /// /// Options: /// -c Prefix lines by the number of occurrences -/// -d Only print duplicate lines +/// -d Only print duplicate lines, one for each group +/// -D Print all duplicate lines /// -u Only print unique lines /// -i Case insensitive comparison /// -f N Skip N fields before comparing +/// -s N Skip N characters before comparing +/// -w N Compare no more than N characters +/// -z Line delimiter is NUL, not newline pub struct Uniq; -/// Get the comparison key for a line, skipping fields and optionally case-folding -fn uniq_key(line: &str, skip_fields: usize, case_insensitive: bool) -> String { - let key = if skip_fields > 0 { - line.split_whitespace() - .skip(skip_fields) - .collect::>() - .join(" ") - } else { - line.to_string() - }; - if case_insensitive { +#[derive(Default)] +struct UniqKeyOpts { + skip_fields: usize, + skip_chars: usize, + check_chars: Option, + case_insensitive: bool, +} + +/// Get the comparison key for a line: GNU skips N blank-then-nonblank +/// fields, then N characters, then compares at most `-w` characters. +fn uniq_key(line: &str, o: &UniqKeyOpts) -> String { + let is_blank = |c: char| c == ' ' || c == '\t'; + let mut rest = line; + for _ in 0..o.skip_fields { + rest = rest.trim_start_matches(is_blank); + rest = rest.trim_start_matches(|c| !is_blank(c)); + } + let start = advance_chars(rest, 0, o.skip_chars); + let rest = &rest[start..]; + let end = o + .check_chars + .map(|n| advance_chars(rest, 0, n)) + .unwrap_or(rest.len()); + let key = &rest[..end]; + if o.case_insensitive { key.to_lowercase() } else { - key + key.to_string() } } +#[allow(clippy::result_large_err)] +fn parse_uniq_count(opt: &str, val: &str) -> std::result::Result { + val.trim().parse().map_err(|_| { + let what = match opt { + "f" => "number of fields to skip", + "s" => "number of bytes to skip", + _ => "number of bytes to compare", + }; + ExecResult::err(format!("uniq: {val}: invalid {what}\n"), 1) + }) +} + #[async_trait] impl Builtin for Uniq { async fn execute(&self, ctx: Context<'_>) -> Result { if let Some(r) = super::check_help_version( ctx.args, - "Usage: uniq [OPTION]... [INPUT [OUTPUT]]\nFilter adjacent matching lines from INPUT, writing to OUTPUT.\n\n -c\t\tprefix lines by the number of occurrences\n -d\t\tonly print duplicate lines\n -u\t\tonly print unique lines\n -i\t\tignore differences in case when comparing\n -f NUM\tavoid comparing the first NUM fields\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + "Usage: uniq [OPTION]... [INPUT [OUTPUT]]\nFilter adjacent matching lines from INPUT, writing to OUTPUT.\n\n -c\t\tprefix lines by the number of occurrences\n -d\t\tonly print duplicate lines, one for each group\n -D\t\tprint all duplicate lines\n -u\t\tonly print unique lines\n -i\t\tignore differences in case when comparing\n -f NUM\tavoid comparing the first NUM fields\n -s NUM\tavoid comparing the first NUM characters\n -w NUM\tcompare no more than NUM characters in lines\n -z\t\tline delimiter is NUL, not newline\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("uniq (bashkit) 0.1"), ) { return Ok(r); } let mut count = false; let mut only_duplicates = false; + let mut all_duplicates = false; let mut only_unique = false; - let mut case_insensitive = false; - let mut skip_fields: usize = 0; + let mut zero_terminated = false; + let mut key_opts = UniqKeyOpts::default(); let mut files = Vec::new(); - let mut p = super::arg_parser::ArgParser::new(ctx.args); - while !p.is_done() { - if let Some(val) = p.flag_value_opt("-f") { - skip_fields = val.parse().unwrap_or(0); - } else { - let flags = p.bool_flags("cdui"); - if !flags.is_empty() { - for c in flags { + let args = ctx.args; + let mut i = 0; + let mut opts_done = false; + while i < args.len() { + let arg = args[i].as_str(); + i += 1; + if opts_done || arg == "-" || !arg.starts_with('-') { + files.push(arg.to_string()); + continue; + } + if arg == "--" { + opts_done = true; + continue; + } + if let Some(long) = arg.strip_prefix("--") { + let (name, inline) = match long.split_once('=') { + Some((n, v)) => (n, Some(v.to_string())), + None => (long, None), + }; + let short = match name { + "count" => 'c', + "repeated" => 'd', + "all-repeated" => 'D', + "unique" => 'u', + "ignore-case" => 'i', + "zero-terminated" => 'z', + "skip-fields" => 'f', + "skip-chars" => 's', + "check-chars" => 'w', + _ => return Ok(super::invalid_option("uniq", arg, 1)), + }; + if matches!(short, 'f' | 's' | 'w') { + let val = match inline { + Some(v) => v, + None if i < args.len() => { + i += 1; + args[i - 1].clone() + } + None => { + return Ok(ExecResult::err( + format!("uniq: option '--{name}' requires an argument\n"), + 1, + )); + } + }; + let n = match parse_uniq_count(&short.to_string(), &val) { + Ok(n) => n, + Err(e) => return Ok(e), + }; + match short { + 'f' => key_opts.skip_fields = n, + 's' => key_opts.skip_chars = n, + _ => key_opts.check_chars = Some(n), + } + continue; + } + match short { + 'c' => count = true, + 'd' => only_duplicates = true, + 'D' => all_duplicates = true, + 'u' => only_unique = true, + 'i' => key_opts.case_insensitive = true, + _ => zero_terminated = true, + } + continue; + } + let body = &arg[1..]; + for (pos, c) in body.char_indices() { + match c { + 'f' | 's' | 'w' => { + let attached = &body[pos + 1..]; + let val = if !attached.is_empty() { + attached.to_string() + } else if i < args.len() { + i += 1; + args[i - 1].clone() + } else { + return Ok(ExecResult::err( + format!("uniq: option requires an argument -- '{c}'\n"), + 1, + )); + }; + let n = match parse_uniq_count(&c.to_string(), &val) { + Ok(n) => n, + Err(e) => return Ok(e), + }; match c { - 'c' => count = true, - 'd' => only_duplicates = true, - 'u' => only_unique = true, - 'i' => case_insensitive = true, - _ => {} + 'f' => key_opts.skip_fields = n, + 's' => key_opts.skip_chars = n, + _ => key_opts.check_chars = Some(n), } + break; } - } else if p.is_flag() && p.current() != Some("--") { - // Reject unknown options instead of treating them as files. - return Ok(super::invalid_option( - "uniq", - p.current().unwrap_or_default(), - 1, - )); - } else if let Some(arg) = p.positional() { - files.push(arg.to_string()); + 'c' => count = true, + 'd' => only_duplicates = true, + 'D' => all_duplicates = true, + 'u' => only_unique = true, + 'i' => key_opts.case_insensitive = true, + 'z' => zero_terminated = true, + _ => return Ok(super::invalid_option("uniq", &format!("-{c}"), 1)), } } } - // Get input lines - let lines: Vec = if files.is_empty() { - ctx.stdin - .map(|s| s.lines().map(|l| l.to_string()).collect()) - .unwrap_or_default() - } else { - let file = &files[0]; - let path = if file.starts_with('/') { - std::path::PathBuf::from(file) - } else { - vfs_join(ctx.cwd, file) - }; - - match read_text_file(&*ctx.fs, &path, "uniq").await { - Ok(text) => text.lines().map(|l| l.to_string()).collect(), - Err(e) => return Ok(e), + let sep = if zero_terminated { '\0' } else { '\n' }; + let text = match files.first().map(String::as_str) { + None | Some("-") => ctx.stdin.map(|s| s.to_string()).unwrap_or_default(), + Some(file) => { + let path = if file.starts_with('/') { + std::path::PathBuf::from(file) + } else { + vfs_join(ctx.cwd, file) + }; + match read_text_file(&*ctx.fs, &path, "uniq").await { + Ok(text) => text, + Err(e) => return Ok(e), + } } }; + let lines: Vec<&str> = split_records(&text, sep).collect(); - let mut result = Vec::new(); - let mut prev_line: Option = None; + // Group adjacent lines with equal keys. + let mut groups: Vec<(usize, usize)> = Vec::new(); // (start, len) let mut prev_key: Option = None; - let mut current_count = 0usize; - - for line in lines { - let key = uniq_key(&line, skip_fields, case_insensitive); - if let Some(ref pk) = prev_key { - if *pk == key { - current_count += 1; - continue; - } else { - let should_output = if only_duplicates { - current_count > 1 - } else if only_unique { - current_count == 1 - } else { - true - }; - - if should_output { - if count { - result.push(format!( - "{:>7} {}", - current_count, - prev_line.as_deref().unwrap_or("") - )); - } else { - result.push(prev_line.clone().unwrap_or_default()); - } - } - } + for (idx, line) in lines.iter().enumerate() { + let key = uniq_key(line, &key_opts); + match (&prev_key, groups.last_mut()) { + (Some(pk), Some(g)) if *pk == key => g.1 += 1, + _ => groups.push((idx, 1)), } - prev_line = Some(line); prev_key = Some(key); - current_count = 1; } - // Last line - if let Some(prev) = prev_line { - let should_output = if only_duplicates { - current_count > 1 + let mut output = String::new(); + let mut emit = |line: &str, n: usize| { + if count { + output.push_str(&format!("{:>7} ", n)); + } + output.push_str(line); + output.push(sep); + }; + for &(start, n) in &groups { + if all_duplicates { + if n > 1 { + for line in &lines[start..start + n] { + emit(line, n); + } + } + continue; + } + let keep = if only_duplicates && only_unique { + false + } else if only_duplicates { + n > 1 } else if only_unique { - current_count == 1 + n == 1 } else { true }; - - if should_output { - if count { - result.push(format!("{:>7} {}", current_count, prev)); - } else { - result.push(prev); - } + if keep { + emit(lines[start], n); } } - let mut output = result.join("\n"); - if !output.is_empty() { - output.push('\n'); + if let Some(out) = files.get(1) { + let path = if out.starts_with('/') { + std::path::PathBuf::from(out) + } else { + vfs_join(ctx.cwd, out) + }; + if let Err(e) = ctx.fs.write_file(&path, output.as_bytes()).await { + return Ok(ExecResult::err(format!("uniq: {out}: {e}\n"), 1)); + } + return Ok(ExecResult::ok(String::new())); } Ok(ExecResult::ok(output)) @@ -964,62 +1531,98 @@ mod tests { assert_eq!(result.stdout, "x a\nx b\n"); } - #[tokio::test] - async fn test_extract_key_spec() { - let key = KeySpec::parse("2"); - assert_eq!(extract_key_spec("a:b:c", Some(':'), &key), "b"); - let key1 = KeySpec::parse("1"); - assert_eq!(extract_key_spec("hello world", None, &key1), "hello"); - let key2 = KeySpec::parse("2"); - assert_eq!(extract_key_spec("hello world", None, &key2), "world"); - let key5 = KeySpec::parse("5"); - assert_eq!(extract_key_spec("x", None, &key5), ""); + fn key(spec: &str, line: &str, tab: Option) -> String { + let k = KeySpec::parse(spec).unwrap(); + String::from_utf8(key_text(line, tab, &k).to_vec()).unwrap() + } - let utf8_single_field = KeySpec::parse("1.2,1.2"); - assert_eq!(extract_key_spec("éa", None, &utf8_single_field), "a"); + #[test] + fn test_key_text() { + assert_eq!(key("2,2", "a:b:c", Some(':')), "b"); + assert_eq!(key("2", "a:b:c", Some(':')), "b:c"); + assert_eq!(key("1,1", "hello world", None), "hello"); + // Fields keep their leading blanks unless -b; no end = end of line. + assert_eq!(key("2", "hello world x", None), " world x"); + assert_eq!(key("2b,2", "hello world x", None), "world"); + assert_eq!(key("5", "x", None), ""); + // Char offsets may run past the field end into the rest of the line. + assert_eq!(key("1.3", "a b", None), "b"); + // Char positions count UTF-8 characters. + assert_eq!(key("1.2,1.2", "éa", None), "a"); + assert_eq!(key("1.2,2.1", "éx yz", None), "x "); + } + + #[test] + fn test_keyspec_parse() { + let k = KeySpec::parse("2n,3r").unwrap(); + assert_eq!((k.sword, k.eword), (1, Some(2))); + assert!(k.opts.numeric && k.opts.reverse); + + let k2 = KeySpec::parse("1.2,1.5f").unwrap(); + assert_eq!((k2.sword, k2.schar, k2.eword, k2.echar), (0, 1, Some(0), 5)); + assert!(k2.opts.fold); - let utf8_multi_field = KeySpec::parse("1.2,2.1"); - assert_eq!(extract_key_spec("éx yz", None, &utf8_multi_field), "x y"); + assert!(KeySpec::parse("0").is_err()); + assert!(KeySpec::parse("1.0").is_err()); + assert!(KeySpec::parse("1x").is_err()); } - #[tokio::test] - async fn test_keyspec_parse() { - let k = KeySpec::parse("2n,3r"); - assert_eq!(k.start_field, 2); - assert_eq!(k.end_field, 3); - assert!(k.numeric); - assert!(k.reverse); + #[test] + fn test_version_cmp() { + assert_eq!(version_cmp("1.2", "1.10"), Ordering::Less); + assert_eq!(version_cmp("2.0", "1.9"), Ordering::Greater); + assert_eq!(version_cmp("1.0", "1.0"), Ordering::Equal); + assert_eq!(version_cmp("a-1.9~rc", "a-1.9"), Ordering::Less); + assert_eq!(version_cmp(".", ".."), Ordering::Less); + assert_eq!(version_cmp("file.tar.gz", "file2.tar.gz"), Ordering::Less); + } - let k2 = KeySpec::parse("1.2,1.5f"); - assert_eq!(k2.start_field, 1); - assert_eq!(k2.start_char, 2); - assert_eq!(k2.end_field, 1); - assert_eq!(k2.end_char, 5); - assert!(k2.fold_case); + #[test] + fn test_numeric_compare_is_exact() { + assert_eq!( + numcompare(b"100000000000000000001", b"100000000000000000000"), + Ordering::Greater + ); + assert_eq!(numcompare(b"-0", b"0"), Ordering::Equal); + assert_eq!(numcompare(b"+5", b"0"), Ordering::Equal); + assert_eq!(numcompare(b".5", b"0.50"), Ordering::Equal); + assert_eq!(numcompare(b"-2", b"-10"), Ordering::Greater); + } + + #[test] + fn test_human_and_general_compare() { + assert_eq!(human_compare(b"1023K", b"1M"), Ordering::Less); + assert_eq!(human_compare(b"-1K", b"-2"), Ordering::Less); + assert_eq!(general_compare(b"abc", b"nan"), Ordering::Less); + assert_eq!(general_compare(b"0x10", b"1e3"), Ordering::Less); + assert_eq!(general_compare(b"10", b"0x10"), Ordering::Less); + } + + #[test] + fn test_month_ordinal() { + assert_eq!(month_ordinal(b"JAN"), 1); + assert_eq!(month_ordinal(b" feb"), 2); + assert_eq!(month_ordinal(b"december"), 12); + assert_eq!(month_ordinal(b"xyz"), 0); } #[tokio::test] - async fn test_version_cmp() { - assert_eq!(version_cmp("1.2", "1.10"), std::cmp::Ordering::Less); - assert_eq!(version_cmp("2.0", "1.9"), std::cmp::Ordering::Greater); - assert_eq!(version_cmp("1.0", "1.0"), std::cmp::Ordering::Equal); + async fn test_sort_key_options_override_global() { + let result = run_sort(&["-r", "-k2n"], Some("a 2 z\nb 10 a\nc 2 b\n")).await; + assert_eq!(result.stdout, "c 2 b\na 2 z\nb 10 a\n"); } #[tokio::test] - async fn test_parse_human_numeric() { - assert_eq!(parse_human_numeric("1K"), 1000.0); - assert_eq!(parse_human_numeric("5M"), 5_000_000.0); - assert_eq!(parse_human_numeric("2G"), 2_000_000_000.0); - assert_eq!(parse_human_numeric("42"), 42.0); - assert_eq!(parse_human_numeric(""), 0.0); + async fn test_sort_unique_uses_keys_only() { + let result = run_sort(&["-nu"], Some("1\n01\nb\na\n")).await; + assert_eq!(result.stdout, "b\n1\n"); } #[tokio::test] - async fn test_month_ordinal() { - assert_eq!(month_ordinal("JAN"), 1); - assert_eq!(month_ordinal("feb"), 2); - assert_eq!(month_ordinal("Dec"), 12); - assert_eq!(month_ordinal("xyz"), 0); + async fn test_sort_bad_key_is_error() { + let result = run_sort(&["-k0"], Some("a\n")).await; + assert_eq!(result.exit_code, 2); + assert!(result.stderr.contains("field number is zero")); } #[tokio::test] diff --git a/crates/bashkit/src/builtins/textrev.rs b/crates/bashkit/src/builtins/textrev.rs index fe2f20053..48900b686 100644 --- a/crates/bashkit/src/builtins/textrev.rs +++ b/crates/bashkit/src/builtins/textrev.rs @@ -80,89 +80,97 @@ impl Builtin for Tac { } }; - // TODO(uutils-parity): implement -b/--before, -r/--regex, - // -s/--separator. The generated parser accepts them so scripts that - // pass the flags are not rejected outright; until the body lands we - // error explicitly when any are actually requested rather than - // silently no-op'ing. - if matches.get_flag("before") - || matches.get_flag("regex") - || matches.contains_id("separator") - { - return Ok(ExecResult::err( - "tac: -b/-r/-s not yet implemented in bashkit\n".to_string(), - 2, - )); - } + let before = matches.get_flag("before"); + let regex = matches.get_flag("regex"); + // GNU: an empty separator means a NUL byte. + let separator = match matches.get_one::("separator") { + Some(s) if s.is_empty() => "\0".to_string(), + Some(s) => s.to_string_lossy().into_owned(), + None => "\n".to_string(), + }; + let sep = if regex { + let translated = super::sed::translate_posix_regex(&separator, false); + match super::search_common::build_regex(&translated) { + Ok(re) => TacSep::Regex(re), + Err(_) => { + return Ok(ExecResult::err( + format!("tac: invalid regular expression: '{separator}'\n"), + 1, + )); + } + } + } else { + TacSep::Literal(separator) + }; - let files: Vec = matches + let mut files: Vec = matches .get_many::("file") .map(|vs| vs.map(|v| v.to_string_lossy().into_owned()).collect()) .unwrap_or_default(); + if files.is_empty() { + files.push("-".to_string()); + } - let raw = match read_tac_files(&ctx, &files).await { - Ok(r) => r, - Err(e) => return Ok(e), - }; - - Ok(ExecResult::ok(reverse_lines(&raw))) + // GNU tac reverses each file on its own, then outputs them in order. + let mut out = String::new(); + for file in &files { + let raw = match read_tac_file(&ctx, file).await { + Ok(r) => r, + Err(e) => return Ok(e), + }; + reverse_records(&raw, &sep, before, &mut out); + } + Ok(ExecResult::ok(out)) } } -async fn read_tac_files( - ctx: &Context<'_>, - files: &[String], -) -> std::result::Result { - let mut raw = String::new(); - if files.is_empty() { - if let Some(stdin) = ctx.stdin { - raw.push_str(stdin); - } - return Ok(raw); - } - for file in files { - if file == "-" { - if let Some(stdin) = ctx.stdin { - raw.push_str(stdin); - } - } else { - let path = if Path::new(file).is_absolute() { - file.clone() - } else { - vfs_join(ctx.cwd, file).to_string_lossy().into_owned() - }; - let text = read_text_file(&*ctx.fs, Path::new(&path), "tac").await?; - raw.push_str(&text); +enum TacSep { + Literal(String), + Regex(regex::Regex), +} + +impl TacSep { + /// Byte ranges of every non-empty separator match, left to right. + fn matches(&self, raw: &str) -> Vec<(usize, usize)> { + match self { + TacSep::Literal(s) => raw + .match_indices(s.as_str()) + .map(|(i, m)| (i, i + m.len())) + .collect(), + TacSep::Regex(re) => re + .find_iter(raw) + .filter(|m| !m.is_empty()) + .map(|m| (m.start(), m.end())) + .collect(), } } - Ok(raw) } -fn reverse_lines(raw: &str) -> String { - if raw.is_empty() { - return String::new(); +async fn read_tac_file(ctx: &Context<'_>, file: &str) -> std::result::Result { + if file == "-" { + return Ok(ctx.stdin.map(ToString::to_string).unwrap_or_default()); } - // GNU tac splits input into records by newline separators; each record - // keeps its own trailing separator (the last record has none when input - // is unterminated). Reversing records preserves that — so an - // unterminated last line concatenates directly with the previous one - // without an inserted newline. - let has_trailing_newline = raw.ends_with('\n'); - let trimmed = if has_trailing_newline { - &raw[..raw.len() - 1] + let path = if Path::new(file).is_absolute() { + file.to_string() } else { - raw + vfs_join(ctx.cwd, file).to_string_lossy().into_owned() }; - let lines: Vec<&str> = trimmed.split('\n').collect(); - let last = lines.len() - 1; - let mut out = String::new(); - for (i, line) in lines.iter().enumerate().rev() { - out.push_str(line); - if i != last || has_trailing_newline { - out.push('\n'); - } + read_text_file(&*ctx.fs, Path::new(&path), "tac").await +} + +/// Split `raw` into records that end with (or, with `before`, start with) a +/// separator, and append them in reverse order. An unterminated last record +/// is kept as-is, so it joins the previous record without a separator. +fn reverse_records(raw: &str, sep: &TacSep, before: bool, out: &mut String) { + let mut cuts = vec![0]; + for (start, end) in sep.matches(raw) { + cuts.push(if before { start } else { end }); + } + cuts.push(raw.len()); + cuts.dedup(); + for w in cuts.windows(2).rev() { + out.push_str(&raw[w[0]..w[1]]); } - out } /// The rev builtin - reverse characters of each line. @@ -255,4 +263,31 @@ mod tests { assert_eq!(result.exit_code, 1); assert!(result.stderr.contains("invalid option -- 'Q'")); } + + fn tac(raw: &str, sep: TacSep, before: bool) -> String { + let mut out = String::new(); + reverse_records(raw, &sep, before, &mut out); + out + } + + #[test] + fn test_tac_records_default_and_unterminated() { + let nl = || TacSep::Literal("\n".to_string()); + assert_eq!(tac("a\nb\n", nl(), false), "b\na\n"); + assert_eq!(tac("a\nb", nl(), false), "ba\n"); + assert_eq!(tac("", nl(), false), ""); + } + + #[test] + fn test_tac_records_separator_and_before() { + let x = || TacSep::Literal("X".to_string()); + assert_eq!(tac("aXbXcX", x(), false), "cXbXaX"); + assert_eq!(tac("XaXb", x(), true), "XbXa"); + } + + #[test] + fn test_tac_records_regex() { + let re = regex::Regex::new("[0-9]").unwrap(); + assert_eq!(tac("a1b22c", TacSep::Regex(re), false), "c2b2a1"); + } } diff --git a/crates/bashkit/tests/integration/builtin_fs_error_tests.rs b/crates/bashkit/tests/integration/builtin_fs_error_tests.rs index aaf9a7979..eb2fb11fd 100644 --- a/crates/bashkit/tests/integration/builtin_fs_error_tests.rs +++ b/crates/bashkit/tests/integration/builtin_fs_error_tests.rs @@ -22,7 +22,7 @@ const READERS: &[(&str, &str, i32)] = &[ ("tail", "tail /nope", 1), ("strings", "strings /nope", 1), ("wc", "wc /nope", 1), - ("sort", "sort /nope", 1), + ("sort", "sort /nope", 2), ("uniq", "uniq /nope", 1), ("cut", "cut -c1 /nope", 1), ("nl", "nl /nope", 1), diff --git a/crates/bashkit/tests/integration/byte_stream_tests.rs b/crates/bashkit/tests/integration/byte_stream_tests.rs index 6bbd15c33..8c74cbddc 100644 --- a/crates/bashkit/tests/integration/byte_stream_tests.rs +++ b/crates/bashkit/tests/integration/byte_stream_tests.rs @@ -29,7 +29,7 @@ async fn mixed_utf8_and_invalid_bytes_survive_stdin_pipeline_and_redirect() { .await .unwrap(); - assert_eq!(result.stdout.text().unwrap(), "aMOp/wAK\n"); + assert_eq!(result.stdout.text().unwrap(), "aMOp/wAK"); assert_eq!( bash.fs() .read_file(std::path::Path::new("/blob")) diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh index 3887babb1..89462161b 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-base64.test.sh @@ -4,7 +4,6 @@ ### bashbox_base64_empty_input_encodes_to_nothing # empty input encodes to nothing -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '' | base64 ### expect ### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh index 6d94cd6a7..0dbc28e3e 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-comm.test.sh @@ -10,7 +10,6 @@ comm -123 a b ### bashbox_comm_zero_terminated # zero terminated -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\0b\0' > z1; printf 'b\0c\0' > z2; comm -z z1 z2 | od -c ### expect 0000000 a \0 \t \t b \0 \t c \0 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh index 0a00e27d3..5e2d6a3c8 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-date.test.sh @@ -11,7 +11,6 @@ Thu Jan 1 00:00:00 UTC 1970 ### bashbox_date_combined_ud # combined -ud -### skip: TODO bashbox corpus gap, bashkit output differs from real bash date -ud @86400; date -ud@86400 +%F ### expect Fri Jan 2 00:00:00 UTC 1970 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh index 4650cba79..0a55de91c 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-echo.test.sh @@ -26,7 +26,6 @@ a b\c ### bashbox_echo_e_unknown_escape_and_trailing_backslash_stay # -e unknown escape and trailing backslash stay -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo -e '\z\xg\' ### expect \z\xg\ @@ -41,7 +40,6 @@ AJ2 ### bashbox_echo_e_c_stops_output_and_the_newline # -e \c stops output and the newline -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo -e 'a\cb'; echo -e x ### expect ax diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh index 858120c47..fe2a36575 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-expand.test.sh @@ -11,7 +11,6 @@ a b c d ### bashbox_expand_extend # extend -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\tb\tc\td\te\n' | expand -t 2,5,/3 ### expect a b c d e @@ -19,7 +18,6 @@ a b c d e ### bashbox_expand_increment # increment -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\tb\tc\td\te\n' | expand -t 2,5,+3 ### expect a b c d e @@ -27,7 +25,6 @@ a b c d e ### bashbox_expand_only_extend # only extend -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\tb\n' | expand -t /3 ### expect a b @@ -42,7 +39,6 @@ a b ### bashbox_expand_repeated_t_adds_stops # repeated -t adds stops -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\tb\tc\n' | expand -t 2 -t 6 ### expect a b c @@ -50,7 +46,6 @@ a b c ### bashbox_expand_stdin # stdin -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '\tz\n' | expand -t 3 - ### expect z @@ -58,7 +53,6 @@ printf '\tz\n' | expand -t 3 - ### bashbox_expand_slash_then_value_later # slash then value later -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\tb\tc\n' | expand -t /,4 ### expect a b c diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh index 752565146..8694f94f1 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-find-predicates.test.sh @@ -34,7 +34,6 @@ find t -newermt '2020-01-02 03:04:05' -name f ### bashbox_find_predicates_fprint_creates_the_file_even_without_matches # -fprint creates the file even without matches -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo old > out; find t -name nomatch -fprint out; wc -c < out ### expect 0 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh index 2c5b4e92d..a9a17f828 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-find.test.sh @@ -58,7 +58,6 @@ find proj -quit ### bashbox_find_execdir_from_the_root # -execdir from the root -### skip: TODO bashbox corpus gap, bashkit output differs from real bash find / -maxdepth 0 -execdir echo {} \; ### expect / diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh index a9ec6dbde..81eb6ffcb 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-fold.test.sh @@ -22,7 +22,6 @@ p qr ### bashbox_fold_backspace_at_column_0 # backspace at column 0 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '\x08abcde\n' | fold -w 3 ### expect abc @@ -31,7 +30,6 @@ de ### bashbox_fold_tab_wider_than_width # tab wider than width -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '\tab\n' | fold -w 4 ### expect @@ -59,7 +57,6 @@ ij ### bashbox_fold_tab_after_break_with_s # tab after break with -s -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'ab\tcdefghij\n' | fold -s -w 6 ### expect ab diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh index 3a82b833a..962b6fa31 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-grep.test.sh @@ -31,7 +31,6 @@ grep -q apple a.txt nope ### bashbox_grep_only_matching_skips_empty_matches # only matching skips empty matches -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo aaa | grep -o 'b*' ### expect ### end @@ -51,7 +50,6 @@ x-v ### bashbox_grep_newline_separates_patterns # newline separates patterns -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\nc\n' | grep "$(printf 'a\nc')" ### expect a @@ -74,7 +72,6 @@ pie ### bashbox_grep_word_match_with_alternation # word match with alternation -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'ab\nxa\nb\n' | grep -wE 'a|b' ### expect b @@ -82,7 +79,6 @@ b ### bashbox_grep_word_match_with_fixed_string # word match with fixed string -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'foo.bar\nfoo.\n' | grep -wF 'foo.' ### expect foo. @@ -90,7 +86,6 @@ foo. ### bashbox_grep_bre_is_literal # BRE + is literal -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a+b\nab\n' | grep 'a+b' ### expect a+b @@ -106,7 +101,6 @@ dog ### bashbox_grep_bre_back_reference # BRE back-reference -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'abab\nabba\n' | grep '\(ab\)\1' ### expect abab @@ -114,7 +108,6 @@ abab ### bashbox_grep_bre_leading_star_is_literal # BRE leading star is literal -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '*a\nb\n' | grep '*a' ### expect *a @@ -129,7 +122,6 @@ aa ### bashbox_grep_g_resets_to_bre # -G resets to BRE -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a|b\na\n' | grep -G 'a|b' ### expect a|b diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-nl.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-nl.test.sh index 6bc7e8bc2..bef98548f 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-nl.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-nl.test.sh @@ -4,7 +4,6 @@ ### bashbox_nl_one_char_delimiter # one-char delimiter -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '@:@:\nh\n@:\nb\n' | nl -d @ -ha ### expect @@ -15,7 +14,6 @@ printf '@:@:\nh\n@:\nb\n' | nl -d @ -ha ### bashbox_nl_three_char_delimiter # three-char delimiter -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'abcabc\nh\nabc\nf\n' | nl -d abc -ha -fa ### expect @@ -33,7 +31,6 @@ printf '\\:x\n' | nl ### bashbox_nl_join_counts_across_sections # join counts across sections -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\n\n\\:\:\n\nb\n' | nl -ba -l2 ### expect 1 a diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-od.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-od.test.sh index df1566377..b80be6602 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-od.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-od.test.sh @@ -4,7 +4,6 @@ ### bashbox_od_hexl_trailer_partial # hexl trailer partial -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'abc' | od -t x2z ### expect 0000000 6261 0063 >abc< diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-options.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-options.test.sh index 52882f740..50c72cb1a 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-options.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-options.test.sh @@ -19,7 +19,6 @@ x ### bashbox_options_tr_complements_set1 # tr complements set1 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo abc | tr -c a X; echo aabbc | tr -cs a X; echo aabbc | tr --complement -s a ### expect aXXXaaXaabc @@ -27,7 +26,6 @@ aXXXaaXaabc ### bashbox_options_tr_truncates_set1 # tr truncates set1 -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo abcd | tr -t abcd xy ### expect xycd @@ -43,7 +41,6 @@ printf 'a b\n' | wc -m; printf 'a b\n' | wc --chars --lines ### bashbox_options_cut_long_options # cut long options -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo a:b | cut --delimiter=: --fields=2 --complement ### expect a @@ -51,7 +48,6 @@ a ### bashbox_options_cut_s_drops_lines_without_a_delimiter # cut -s drops lines without a delimiter -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\tb\nc\n' | cut -sf1 ### expect a @@ -59,7 +55,6 @@ a ### bashbox_options_cut_takes_an_empty_delimiter_as_nul # cut takes an empty delimiter as NUL -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\0b\n' | cut -d '' -f2 ### expect b @@ -67,7 +62,6 @@ b ### bashbox_options_tail_bytes # tail --bytes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\n' | tail --bytes=2 ### expect b @@ -75,7 +69,6 @@ b ### bashbox_options_base64_wrapping # base64 wrapping -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf hello | base64 -w0; echo; printf hello | base64 --wrap=4 ### expect aGVsbG8= @@ -85,7 +78,6 @@ bG8= ### bashbox_options_date_long_options # date long options -### skip: TODO bashbox corpus gap, bashkit output differs from real bash date --utc --date=@0 +%F; date --universal -d @86400 +%F ### expect 1970-01-01 @@ -94,7 +86,6 @@ date --utc --date=@0 +%F; date --universal -d @86400 +%F ### bashbox_options_file_commands_long_options # file commands long options -### skip: TODO bashbox corpus gap, bashkit output differs from real bash mkdir --parents p/q; touch --no-create p/none; echo x | tee --append p/q/f >/dev/null; rm --recursive --force p/none; cp --recursive p r; ls r/q ### expect f diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-paste.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-paste.test.sh index ec916f25d..ea3c28829 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-paste.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-paste.test.sh @@ -4,7 +4,6 @@ ### bashbox_paste_stdin_twice_takes_turns # stdin twice takes turns -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '1\n2\n3\n4\n5\n' | paste - - ### expect 1 2 @@ -14,7 +13,6 @@ printf '1\n2\n3\n4\n5\n' | paste - - ### bashbox_paste_serial_stdin_twice # serial stdin twice -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '1\n2\n' | paste -s - - ### expect 1 2 @@ -23,7 +21,6 @@ printf '1\n2\n' | paste -s - - ### bashbox_paste_zero_terminated # zero terminated -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\0b\0' | paste -z - - | od -c ### expect 0000000 a \t b \0 @@ -32,7 +29,6 @@ printf 'a\0b\0' | paste -z - - | od -c ### bashbox_paste_serial_zero_terminated # serial zero terminated -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\0b' | paste -sz - | od -c ### expect 0000000 a \t b \0 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-path-name.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-path-name.test.sh index 880017334..af1c4a1c3 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-path-name.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-path-name.test.sh @@ -13,7 +13,6 @@ b.txt ### bashbox_path_name_basename_strips_a_suffix_unless_it_is_the_whole_name # basename strips a suffix unless it is the whole name -### skip: TODO bashbox corpus gap, bashkit output differs from real bash basename a/b.txt .txt; basename .txt .txt ### expect b diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-printf.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-printf.test.sh index be9927af6..fb746aa9a 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-printf.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-printf.test.sh @@ -54,7 +54,6 @@ plain ### bashbox_printf_format_escapes # format escapes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '\101|\0101|\x41|\e|\t|\\|\q|\c\n' ### expect A|1|A|| |\|\q|\c @@ -132,7 +131,6 @@ printf "[%'d]\n" 1234567 ### bashbox_printf_bash_pads_b_and_q_itself_never_with_zeros # bash pads %b and %q itself, never with zeros -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '[%05b|%05q|%05Q]\n' 'a\tb' 'c d' e ### expect [ a b| c\ d| e] @@ -140,7 +138,6 @@ printf '[%05b|%05q|%05Q]\n' 'a\tb' 'c d' e ### bashbox_printf_q_and_q_with_width_and_precision # %q and %Q with width and precision -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '[%10q|%-6q|%.2q|%.2Q|%5Q]\n' 'a b' x abc 'a bc' 'a b' ### expect [ a\ b|x |ab|a\ | a\ b] @@ -162,7 +159,6 @@ printf '%d %d %d %d %d %d %i %X\n' "'A" '"B' '+0x10' '-010' "'" ' 12' 0x7f 3054 ### bashbox_printf_ends_options # -- ends options -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf -- '[%s]\n' a ### expect [a] diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-rmdir.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-rmdir.test.sh index bc3e42cb3..d22415184 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-rmdir.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-rmdir.test.sh @@ -4,7 +4,6 @@ ### bashbox_rmdir_removes_empty_directories # removes empty directories -### skip: TODO bashbox corpus gap, bashkit output differs from real bash rmdir -v a/b/c; ls a/b ### expect rmdir: removing directory, 'a/b/c' diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-sed.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-sed.test.sh index 9a0a05276..5280884a6 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-sed.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-sed.test.sh @@ -231,7 +231,6 @@ aBC ### bashbox_sed_custom_delimiter_and_escaped_delimiter # custom delimiter and escaped delimiter -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo 'a|b' | sed 's|a\|b|X|' ### expect X @@ -606,7 +605,6 @@ X ### bashbox_sed_a_with_nothing_after_the_backslash # a with nothing after the backslash -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo a | sed 'a\' ### expect a diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-seq.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-seq.test.sh index e6b20665d..e7be616e3 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-seq.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-seq.test.sh @@ -28,7 +28,6 @@ seq 3 1; seq -s, 3 1 ### bashbox_seq_decimals_follow_first_and_increment # decimals follow FIRST and INCREMENT -### skip: TODO bashbox corpus gap, bashkit output differs from real bash seq 1 0.5 2; seq 1 2.50; seq 1.0 2 ### expect 1.0 @@ -42,7 +41,6 @@ seq 1 0.5 2; seq 1 2.50; seq 1.0 2 ### bashbox_seq_custom_formats # custom formats -### skip: TODO bashbox corpus gap, bashkit output differs from real bash seq -f '%03g' 9 10; seq -f '%.2f' 1 0.25 1.5; seq -f '%e' 1 ### expect 009 @@ -55,7 +53,6 @@ seq -f '%03g' 9 10; seq -f '%.2f' 1 0.25 1.5; seq -f '%e' 1 ### bashbox_seq_separator # separator -### skip: TODO bashbox corpus gap, bashkit output differs from real bash seq -s, 1 3; seq --separator=-1 1 2 ### expect 1,2,3 @@ -64,7 +61,6 @@ seq -s, 1 3; seq --separator=-1 1 2 ### bashbox_seq_equal_width_pads_after_the_sign # equal width pads after the sign -### skip: TODO bashbox corpus gap, bashkit output differs from real bash seq -w 1 -0.5 -1; seq -w 1 50 100; seq --equal-width 9 10 ### expect 01.0 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-sleep.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-sleep.test.sh index 53fe4cb05..4b76049e7 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-sleep.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-sleep.test.sh @@ -16,7 +16,6 @@ sleep 0.001 ### bashbox_sleep_units # units -### skip: TODO bashbox corpus gap, bashkit output differs from real bash sleep 0s 0m 0h 0d ### expect ### end @@ -35,7 +34,6 @@ sleep 0e5 ### bashbox_sleep_hex # hex -### skip: TODO bashbox corpus gap, bashkit output differs from real bash sleep 0x0 ### expect ### end @@ -48,21 +46,18 @@ sleep +0 ### bashbox_sleep_leading_blank # leading blank -### skip: TODO bashbox corpus gap, bashkit output differs from real bash sleep ' 0' ### expect ### end ### bashbox_sleep_dash_dash # dash dash -### skip: TODO bashbox corpus gap, bashkit output differs from real bash sleep -- 0 ### expect ### end ### bashbox_sleep_hex_fraction # hex fraction -### skip: TODO bashbox corpus gap, bashkit output differs from real bash sleep 0x.0p1 ### expect ### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-sort.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-sort.test.sh index ea341a529..3c00bcf53 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-sort.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-sort.test.sh @@ -38,7 +38,6 @@ a ### bashbox_sort_numeric_with_ties_broken_bytewise # numeric with ties broken bytewise -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'b\na\n10\n9\n-1\n+5\n.5\n' | sort -n ### expect -1 @@ -60,7 +59,6 @@ b ### bashbox_sort_numeric_unique_keeps_the_first_of_a_run # numeric unique keeps the first of a run -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '1\n01\nb\na\n' | sort -nu ### expect b @@ -87,7 +85,6 @@ b 1 ### bashbox_sort_key_keeps_leading_blanks # key keeps leading blanks -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'x b\ny a\n' | sort -k2 ### expect x b @@ -113,7 +110,6 @@ b 10 a ### bashbox_sort_key_modifiers_override_global_r # key modifiers override global -r -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a 2 z\nb 10 a\nc 2 b\n' | sort -r -k2n ### expect c 2 b @@ -141,7 +137,6 @@ a:3 ### bashbox_sort_unique_by_key # unique by key -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a b c\nz b d\na c b\n' | sort -u -k2,2 ### expect a b c @@ -169,7 +164,6 @@ xb2 ### bashbox_sort_character_position_past_the_field_end # character position past the field end -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'ab c\nab a\na b\n' | sort -k1.3 ### expect ab a @@ -215,7 +209,6 @@ b zz ### bashbox_sort_global_b_applies_to_keys_without_modifiers # global -b applies to keys without modifiers -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'x b\ny a\nz c\n' | sort -b -k2 ### expect y a @@ -225,7 +218,6 @@ z c ### bashbox_sort_key_modifiers_stop_global_ones_being_inherited # key modifiers stop global ones being inherited -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'x b\ny a\nz c\n' | sort -b -k2r ### expect y a @@ -260,7 +252,6 @@ k:xb ### bashbox_sort_t_nul_separator # -t NUL separator -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'b\000a\na\000b\n' | sort -t '\0' -k2 | tr '\0' '|' ### expect b|a @@ -269,7 +260,6 @@ a|b ### bashbox_sort_d_dictionary_order # -d dictionary order -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a-c\nab\na c\n' | sort -d ### expect a c @@ -289,7 +279,6 @@ b ### bashbox_sort_fu_keeps_the_first_of_each_folded_run # -fu keeps the first of each folded run -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'b\nA\na\nB\n' | sort -fu ### expect A @@ -298,7 +287,6 @@ b ### bashbox_sort_i_ignores_nonprinting_characters # -i ignores nonprinting characters -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'b\na\001c\nac\n' | sort -i | tr '\001' '^' ### expect a^c @@ -308,7 +296,6 @@ b ### bashbox_sort_m_month_order # -M month order -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'mar x\n feb\nJAN\nfoo\ndecember\n' | sort -M ### expect foo @@ -320,7 +307,6 @@ december ### bashbox_sort_g_general_numeric # -g general numeric -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '1e3\n-inf\nnan\nabc\n10\n0x10\n-5.5\ninf\n+2\n' | sort -g ### expect abc @@ -336,7 +322,6 @@ inf ### bashbox_sort_h_human_numeric # -h human numeric -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '0K\n1\n-1K\n2M\n500K\n0\n1.5K\n3k\n1m\n-2\n' | sort -h ### expect -1K @@ -368,7 +353,6 @@ printf '007\n-0\n0.50\n.5\n-1.5\n-.5\n10\n1e5\n 3\n' | sort -n ### bashbox_sort_n_compares_long_numbers_exactly # -n compares long numbers exactly -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '100000000000000000001\n100000000000000000000\n99999999999999999999\n' | sort -n ### expect 99999999999999999999 @@ -378,7 +362,6 @@ printf '100000000000000000001\n100000000000000000000\n99999999999999999999\n' | ### bashbox_sort_v_version_order # -V version order -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a-1.10\na-1.9\na-1.9~rc\n.b\n..\n.\nfile.tar.gz\nfile2.tar.gz\nfile10\nfile\n\nfile~\n.a\n1.0a\n1.0\n' | sort -V ### expect @@ -427,7 +410,6 @@ b ### bashbox_sort_u_with_n_treats_equal_numbers_as_duplicates # -u with -n treats equal numbers as duplicates -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '1\n01\n1.0\n2\n' | sort -un ### expect 1 @@ -488,7 +470,6 @@ c ### bashbox_sort_m_with_keys_and_reverse # -m with keys and reverse -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '3 a\n1 b\n' > f1; printf '2 c\n' > f2; sort -m -k1,1nr f1 f2 ### expect 3 a @@ -498,7 +479,6 @@ printf '3 a\n1 b\n' > f1; printf '2 c\n' > f2; sort -m -k1,1nr f1 f2 ### bashbox_sort_long_options # long options -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'b:2\na:10\n' | sort --field-separator=: --key=2 --numeric-sort --reverse ### expect a:10 @@ -507,7 +487,6 @@ b:2 ### bashbox_sort_sort_selects_the_comparison # --sort selects the comparison -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '10\n9\n' | sort --sort=num ### expect 9 @@ -516,7 +495,6 @@ printf '10\n9\n' | sort --sort=num ### bashbox_sort_ignored_performance_options # ignored performance options -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'b\na\n' | sort -S 1M -T /tmp --parallel=2 --compress-program=gzip ### expect a @@ -525,7 +503,6 @@ b ### bashbox_sort_r_groups_equal_lines # -R groups equal lines -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\na\nb\n' | sort -R | uniq | sort ### expect a @@ -534,7 +511,6 @@ b ### bashbox_sort_d_is_compatible_with_v # -d is compatible with -V -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a-2\na-10\n' | sort -dV ### expect a-2 @@ -543,7 +519,6 @@ a-10 ### bashbox_sort_r_with_f # -R with -f -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nA\nb\na\n' | sort -fR | tr A a | uniq | sort ### expect a diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh index 69396acf4..3a6aeb80e 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-stat.test.sh @@ -4,7 +4,6 @@ ### bashbox_stat_name_needing_double_quotes # name needing double quotes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf x > "a'b c"; stat -c %N "a'b c" ### expect "a'b c" diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-tac.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-tac.test.sh index 6eb88d27b..4461e0ed4 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-tac.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-tac.test.sh @@ -12,7 +12,6 @@ printf '1\n2\n' | tac ### bashbox_tac_empty_separator_is_nul # empty separator is NUL -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\0b\0' | tac -s '' | od -c ### expect 0000000 b \0 a \0 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-temp-path.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-temp-path.test.sh index 7cf4cd04b..336c8370f 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-temp-path.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-temp-path.test.sh @@ -4,7 +4,6 @@ ### bashbox_temp_path_quiet_hides_creation_failures # --quiet hides creation failures -### skip: TODO bashbox corpus gap, bashkit output differs from real bash mktemp --quiet -p /nonexist fooXXX ### expect ### end diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-tr.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-tr.test.sh index 9c47471e9..6d5534335 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-tr.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-tr.test.sh @@ -18,7 +18,6 @@ CCCCC ### bashbox_tr_octal_escape # octal escape -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo ABC | tr '\101' x ### expect xBC @@ -26,7 +25,6 @@ xBC ### bashbox_tr_octal_escape_stops_after_three_digits # octal escape stops after three digits -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo A8 | tr '\1018' xy ### expect xy diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh index 893d5144c..ce6af4c4b 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-unexpand.test.sh @@ -4,7 +4,6 @@ ### bashbox_unexpand_single_space_before_stop # single space before stop -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'abcdefg h\n' | unexpand -a ### expect abcdefg h @@ -12,7 +11,6 @@ abcdefg h ### bashbox_unexpand_single_blank_becomes_tab # single blank becomes tab -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'abcdefg \tx\n' | unexpand -a ### expect abcdefg x @@ -20,7 +18,6 @@ abcdefg x ### bashbox_unexpand_list # list -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf ' a b c\n' | unexpand -t 2,6 ### expect a b c @@ -28,7 +25,6 @@ printf ' a b c\n' | unexpand -t 2,6 ### bashbox_unexpand_list_exhausted # list exhausted -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf ' a b c d\n' | unexpand -t 2,6 ### expect a b c d @@ -36,7 +32,6 @@ printf ' a b c d\n' | unexpand -t 2,6 ### bashbox_unexpand_extend # extend -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf ' a b c d\n' | unexpand -t 2,/3 ### expect a b c d @@ -44,7 +39,6 @@ printf ' a b c d\n' | unexpand -t 2,/3 ### bashbox_unexpand_increment # increment -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf ' a b c d\n' | unexpand -t 2,+3 ### expect a b c d @@ -52,7 +46,6 @@ printf ' a b c d\n' | unexpand -t 2,+3 ### bashbox_unexpand_obsolete_list # obsolete list -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf ' a b c\n' | unexpand -2,6 ### expect a b c @@ -60,7 +53,6 @@ printf ' a b c\n' | unexpand -2,6 ### bashbox_unexpand_obsolete_trailing_comma # obsolete trailing comma -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf ' a b c\n' | unexpand -2,6, ### expect a b c @@ -76,7 +68,6 @@ printf 'a ' | unexpand -a | od -c ### bashbox_unexpand_non_blank_stops_leading_conversion # non-blank stops leading conversion -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'x y\n' | unexpand ### expect x y @@ -84,7 +75,6 @@ x y ### bashbox_unexpand_tab_at_stop # tab at stop -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '\t\t a\n' | unexpand -t 4 ### expect a @@ -92,7 +82,6 @@ printf '\t\t a\n' | unexpand -t 4 ### bashbox_unexpand_spaces_then_tab # spaces then tab -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf ' \ta\n' | unexpand ### expect a @@ -100,7 +89,6 @@ printf ' \ta\n' | unexpand ### bashbox_unexpand_one_space_then_tab # one space then tab -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'abcdefg \t \tx\n' | unexpand -a ### expect abcdefg x @@ -108,7 +96,6 @@ abcdefg x ### bashbox_unexpand_blank_past_last_stop # blank past last stop -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'abc d\n' | unexpand -t 2 ### expect abc d diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh index 1378b66b3..8b08d2a40 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-uniq.test.sh @@ -40,7 +40,6 @@ printf '' | uniq ### bashbox_uniq_second_operand_is_the_output_file # second operand is the output file -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\na\n' > in; uniq in out; cat out ### expect a @@ -71,7 +70,6 @@ a b ### bashbox_uniq_s_skips_bytes # -s skips bytes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'xa\nya\nzb\n' | uniq --skip-chars=1 ### expect xa @@ -80,7 +78,6 @@ zb ### bashbox_uniq_w_compares_a_prefix # -w compares a prefix -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'ab1\nab2\nac\n' | uniq -w2 ### expect ab1 diff --git a/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh b/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh index a6c6ca98c..16395fb18 100644 --- a/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/bashbox-xargs.test.sh @@ -35,7 +35,6 @@ ab ### bashbox_xargs_i_runs_once_per_line_keeping_trailing_blanks # -I runs once per line, keeping trailing blanks -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'one\n\n two \n' | xargs -I {} echo '<{}>' {} ### expect one @@ -64,7 +63,6 @@ printf '' | xargs -r echo hi ### bashbox_xargs_quotes_and_backslashes_group_words # quotes and backslashes group words -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '%s\n' '"a b" '\''c d'\'' e\\ f g\\\\h' 'i\\' 'j' | xargs -n1 echo ### expect a b @@ -78,7 +76,6 @@ j ### bashbox_xargs_an_empty_quoted_word_is_kept_but_a_final_one_is_dropped # an empty quoted word is kept but a final one is dropped -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf '"" a\nb ""' | xargs -n1 echo -; printf "c '" | xargs echo ### expect - @@ -89,7 +86,6 @@ c ### bashbox_xargs_a_backslash_escapes_a_newline # a backslash escapes a newline -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\\\nb\n' | xargs -n1 echo; printf 'c\\' | xargs echo ### expect a @@ -99,7 +95,6 @@ c ### bashbox_xargs_a_nul_ends_an_argument_with_a_warning # a NUL ends an argument, with a warning -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\0b c\0d\n' | xargs echo ### expect a c @@ -107,7 +102,6 @@ a c ### bashbox_xargs_0_splits_at_nuls # -0 splits at NULs -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a b\0\0c\0' | xargs -0 -n1 echo - ### expect - a b @@ -117,7 +111,6 @@ printf 'a b\0\0c\0' | xargs -0 -n1 echo - ### bashbox_xargs_d_takes_a_character_or_an_escape # -d takes a character or an escape -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a,b,,c' | xargs -d, echo; printf 'a\tb\n' | xargs -d '\t' echo; printf 'aXb' | xargs -d '\x58' echo; printf 'aXb' | xargs --delimiter='\130' echo; printf 'a\ab' | xargs -d '\a' echo ### expect a b c @@ -130,7 +123,6 @@ a b ### bashbox_xargs_d_numbers_are_read_like_strtoul # -d numbers are read like strtoul -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'aAb' | xargs -d '\x 41' echo; printf 'aAb' | xargs -d '\x+0x41' echo; printf 'a\0b' | xargs -d '\x' echo; printf 'a\0b' | xargs -d '\x-0' echo ### expect a b @@ -141,7 +133,6 @@ a b ### bashbox_xargs_d_items_keep_blanks_and_quotes # -d items keep blanks and quotes -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf "a 'b\nc\n" | xargs -d '\n' -n1 echo ### expect a 'b @@ -150,7 +141,6 @@ c ### bashbox_xargs_e_stops_at_the_end_of_file_word # -E stops at the end-of-file word -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a b\nSTOP\nc\n' | xargs -E STOP echo; printf 'a STOP b\n' | xargs -eSTOP echo; printf 'STOP\nc\n' | xargs --eof=STOP echo x; printf 'a STOP' | xargs -E STOP; printf 'STOP' | xargs -E STOP echo y ### expect a b @@ -162,7 +152,6 @@ y ### bashbox_xargs_e_on_a_line_ending # -E on a line ending -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a STOP\nb\n' | xargs -E STOP -L1 echo ### expect a @@ -170,7 +159,6 @@ a ### bashbox_xargs_a_bare_e_turns_e_off # a bare -e turns -E off -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo a x b | xargs -E x -e echo; echo a x b | xargs -E x --eof echo ### expect a x b @@ -179,7 +167,6 @@ a x b ### bashbox_xargs_e_has_no_effect_with_0_or_d # -E has no effect with -0 or -d -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\0x\0b' | xargs -E x -0 echo ### expect a x b @@ -187,7 +174,6 @@ a x b ### bashbox_xargs_l_runs_per_lines # -L runs per lines -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\nc\nd\ne\n' | xargs -L2 echo ### expect a b @@ -197,7 +183,6 @@ e ### bashbox_xargs_l_continues_a_line_ending_in_a_blank # -L continues a line ending in a blank -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a b \nc\nd\\ \ne\n' | xargs -L1 echo ### expect a b c @@ -206,7 +191,6 @@ d e ### bashbox_xargs_l_defaults_to_one_line # -l defaults to one line -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\nc\n' | xargs -l echo; printf 'a\nb\nc\n' | xargs -l2 echo; printf 'a\nb\n' | xargs --max-lines echo ### expect a @@ -220,7 +204,6 @@ b ### bashbox_xargs_a_bare_optional_option_at_the_end_of_a_bundle # a bare optional option at the end of a bundle -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo a x b | xargs -E x -tl echo; echo y | xargs -ti echo {}; echo z | xargs -it echo {} ### expect a @@ -230,7 +213,6 @@ y ### bashbox_xargs_l_n_and_i_exclude_each_other # -L, -n and -I exclude each other -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\nc\n' | xargs -n2 -L1 echo; printf 'a\nb\nc\n' | xargs -L1 -n2 echo; printf 'a\nb\n' | xargs -I{} -l echo {} ### expect a @@ -244,7 +226,6 @@ c ### bashbox_xargs_i_excludes_n_and_l # -I excludes -n and -L -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\n' | xargs -L1 -I{} echo {}; printf 'a\nb\n' | xargs -n2 -I{} echo {}; printf 'a\nb\n' | xargs -I{} -n2 echo {} ### expect a @@ -256,7 +237,6 @@ b ### bashbox_xargs_n1_after_i_is_ignored # -n1 after -I is ignored -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a b\nc\n' | xargs -I{} -n1 echo [{}] ### expect [a b] @@ -265,7 +245,6 @@ printf 'a b\nc\n' | xargs -I{} -n1 echo [{}] ### bashbox_xargs_i_defaults_to # -i defaults to {} -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\n' | xargs -i echo {}; echo c | xargs --replace echo {}; echo d | xargs -iX echo X ### expect a @@ -276,7 +255,6 @@ d ### bashbox_xargs_numbers_may_have_blanks_and_a_sign # numbers may have blanks and a sign -### skip: TODO bashbox corpus gap, bashkit output differs from real bash echo a b c | xargs -n ' +2' echo; echo d | xargs -P 2 echo ### expect a b @@ -286,7 +264,6 @@ d ### bashbox_xargs_s_limits_the_command_length # -s limits the command length -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\nc\n' | xargs -s 9 echo ### expect a b @@ -295,7 +272,6 @@ c ### bashbox_xargs_x_alone_still_splits # -x alone still splits -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a b c d\n' | xargs -x -s 9 echo ### expect a b @@ -304,7 +280,6 @@ c d ### bashbox_xargs_i_size_limits # -I size limits -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'abc\n' | xargs -I{} -s 8 echo {} {}; printf 'abcde\n' | xargs -I{} -s 6 echo x{}; printf 'abcdef\n' | xargs -I{} -s 6 echo x{}; printf 'a\n' | xargs -I{} -s 3 echo {}; printf '' | xargs -I{} -s 3 echo {} ### expect ### end @@ -319,7 +294,6 @@ printf 'a b\0c\0' | xargs -0 -I{} echo [{}] ### bashbox_xargs_a_reads_standard_input # -a - reads standard input -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a\nb\n' | xargs -a - echo ### expect a b @@ -327,7 +301,6 @@ a b ### bashbox_xargs_a_with_a_directory_reads_nothing # -a with a directory reads nothing -### skip: TODO bashbox corpus gap, bashkit output differs from real bash mkdir d; xargs -a d echo x ### expect x @@ -335,7 +308,6 @@ x ### bashbox_xargs_t_prints_each_command # -t prints each command -### skip: TODO bashbox corpus gap, bashkit output differs from real bash printf 'a b\nc\n' | xargs -t -n2 echo; printf "it's\ta b\n" | xargs -d '\n' --verbose ### expect a b diff --git a/crates/bashkit/tests/spec_cases/bash/fileops.test.sh b/crates/bashkit/tests/spec_cases/bash/fileops.test.sh index cff4a338e..b6aa384a9 100644 --- a/crates/bashkit/tests/spec_cases/bash/fileops.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/fileops.test.sh @@ -270,3 +270,52 @@ not-created 02/03 04:05:06 rc=1 ### end + +### cp_recursive_tree +# cp -r copies a tree, refuses to copy into itself, and skips dirs without -r +rm -rf /tmp/cpr && mkdir -p /tmp/cpr/a/b && cd /tmp/cpr +echo x > a/b/f; ln -s f a/b/l +cp -r a c; cat c/b/f; readlink c/b/l +cp -R a c; ls c +cp -r a/ a/z 2>&1; echo "exit=$?" +mkdir q; cp q q2 2>&1; echo "exit=$?" +### expect +x +f +a +b +cp: cannot copy a directory, 'a/', into itself, 'a/z' +exit=1 +cp: -r not specified; omitting directory 'q' +exit=1 +### end + +### rmdir_verbose_parents +# rmdir -v reports each removal; -p walks the operand's prefixes +rm -rf /tmp/rmv && mkdir -p /tmp/rmv/a/b/c /tmp/rmv/x/y && cd /tmp/rmv +rmdir -v a/b/c; ls a +rmdir -pv x/y +touch a/b/f; rmdir a/b 2>&1; rmdir --ignore-fail-on-non-empty a/b; echo "exit=$?" +### expect +rmdir: removing directory, 'a/b/c' +b +rmdir: removing directory, 'x/y' +rmdir: removing directory, 'x' +rmdir: failed to remove 'a/b': Directory not empty +exit=0 +### end + +### mktemp_template_rules +# mktemp replaces the trailing X run, keeps a relative template relative +# (stderr dropped: GNU quotes the template with locale quotes) +rm -rf /tmp/mkt && mkdir -p /tmp/mkt && cd /tmp/mkt +mktemp fooXXXX | grep -c '^foo[A-Za-z0-9]\{4\}$' +mktemp -u -p /tmp/mkt barXXX.txt | grep -c '^/tmp/mkt/bar...\.txt$' +mktemp fooXX 2>/dev/null; echo "exit=$?" +mktemp -q -p /nonexist fooXXX; echo "exit=$?" +### expect +1 +1 +exit=1 +exit=1 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/find.test.sh b/crates/bashkit/tests/spec_cases/bash/find.test.sh index 3c83d452b..04a3e707e 100644 --- a/crates/bashkit/tests/spec_cases/bash/find.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/find.test.sh @@ -477,3 +477,16 @@ find d -type f -ls | awk '{print $7}' 11 -rw-r--r-- d/f 3 ### end + +### find_fprint_family +# -fprint/-fprintf write to a file, truncated even when nothing matches +rm -rf /tmp/fpr && mkdir -p /tmp/fpr/d && touch /tmp/fpr/d/a && cd /tmp/fpr +echo old > out.txt +find d -type f -fprint out.txt; cat out.txt +find d -type f -fprintf out2.txt '%f|'; cat out2.txt; echo +find d -name nomatch -fprint out.txt; wc -c < out.txt +### expect +d/a +a| +0 +### end diff --git a/crates/bashkit/tests/spec_cases/bash/stat.test.sh b/crates/bashkit/tests/spec_cases/bash/stat.test.sh index 27ec801bb..f4bfd72bd 100644 --- a/crates/bashkit/tests/spec_cases/bash/stat.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/stat.test.sh @@ -76,3 +76,12 @@ stat -f /tmp/stat_combo.txt 2>/dev/null; echo "exit=$?" ### expect exit=1 ### end + +### stat_quoted_name_and_link +# %N quotes the name and shows a symlink's target +cd /tmp && rm -f stn stl && touch stn && ln -s stn stl +stat -c '%N' stn stl +### expect +'stn' +'stl' -> 'stn' +### end diff --git a/crates/bashkit/tests/spec_cases/bash/textrev.test.sh b/crates/bashkit/tests/spec_cases/bash/textrev.test.sh index e7e21f7e5..a87366f25 100644 --- a/crates/bashkit/tests/spec_cases/bash/textrev.test.sh +++ b/crates/bashkit/tests/spec_cases/bash/textrev.test.sh @@ -52,12 +52,15 @@ tac --no-such-flag 2>/dev/null; echo "exit=$?" exit=2 ### end -### tac_separator_unimplemented -### bash_diff: bashkit explicitly errors on -s until the body lands; GNU tac applies the separator -# bashkit accepts -s at the parser level but errors explicitly until the body lands -tac -s X /tmp/tac_test 2>/dev/null; echo "exit=$?" -### expect -exit=2 +### tac_separator +# tac -s uses STRING as the record separator +printf 'aXbXcX' | tac -s X; echo +printf 'XaXb' | tac -b -s X; echo +printf 'a1b22c' | tac -r -s '[0-9]'; echo +### expect +cXbXaX +XbXa +c2b2a1 ### end ### rev_basic diff --git a/knowledge/foundations/builtins.md b/knowledge/foundations/builtins.md index 837e694c7..f7991baaa 100644 --- a/knowledge/foundations/builtins.md +++ b/knowledge/foundations/builtins.md @@ -25,6 +25,20 @@ All external-style builtins support `--help` and `--version` flags via the flags `-h`/`-V` have different meanings in many tools). Tools where `-h`/`-V` genuinely mean help/version handle them directly in `execute()`. +### GNU Option Parsing + +Hand-written coreutils-style builtins parse options with +`builtins::arg_parser::gnu_getopt` (getopt_long semantics: bundles such as +`-ud@0`, attached or separate values, `--long=VAL`, unambiguous long +prefixes, `--`, optional argument permutation). It returns the options in +command-line order plus operands, and GNU-worded errors (`invalid option -- +'z'`, `unrecognized option`, `requires an argument`) with the caller's exit +code. New or reworked builtins should use it rather than ad-hoc loops, so +option spelling matches GNU; builtins on a ported clap surface (see +[Coreutils Argument Port](../runtimes/coreutils-args-port.md)) keep clap. +Obsolete forms GNU still accepts (`head -5`, `fold -5`) are rewritten to the +modern option before getopt runs, never when they are an option's value. + ### Command Dispatch Order functions → special commands → builtins → path execution → $PATH search → diff --git a/knowledge/log.md b/knowledge/log.md index d940a4b71..63260046a 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -67,6 +67,7 @@ * **Feature**: Secret generators. `$SRANDOM` (bash 5.1, 32 bits from `getrandom`), `uuidgen` (v4 only, L-RAND-001) and `openssl rand [-hex|-base64] [-out] N` (only `rand`; capped at 1 MiB, TM-DOS-123). All draw from the OS CSPRNG like `/dev/urandom`, never the `$RANDOM` LCG. * **Decision**: CPython stdlib ships as bytecode only. Non-preloaded imports compiled source on Pulley every call (3-7 s each); unchecked-hash `.pyc` cut that ~10x. Unusable modules (`ctypes`, `ssl`, network clients/servers, `pdb`, `pydoc`, `_pyrepl`) and pure-Python C twins dropped so the crate stays under the 10 MiB cap. Dropping preloaded modules was rejected: ~1-2 ms first-call gain, seconds per import lost. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md), [Limitations](operations/limitations.md) L-CPY-009. * **Performance**: Profiled CPython start. A warm `python3 -c 'print(1)'` was ~4.5 ms of Pulley interpreting ~500K guest instructions plus ~1.5 ms in ~340 copy-on-write faults; the first call adds ~13 ms of one-time memory-image build (the 40 MB snapshot span copied into a memfd). Snapshot objects are now immortal (no refcount writes, faults -20%) and the per-call environment is installed from C; warm calls went from ~6.2 to ~5.1 ms. Native AOT instead of Pulley measured 2.3 ms per call but needs executable memory and a 41 MB copy on first load; not taken yet. See [CPython WebAssembly Runtime](runtimes/cpython-wasm.md). +* **Fix**: Coreutils parity from the BashBox corpus (bash-verified cases unskipped). `sort` follows GNU keys/ordering (`-k` fields and chars, `-b/-d/-f/-g/-h/-i/-M/-n/-R/-r/-V`, last-resort compare, stable `-s`, `-u` on key equality, `-m`, `-c/-C`); `uniq` adds `-s/-w/-D/-z`; `tr` decodes octal escapes and adds `-t`; `cut` keeps lines without the delimiter; `expand`/`unexpand` follow GNU tab-stop lists; `xargs` ports GNU input parsing (quotes, `-E`, `-L`, `-s`, `-d`, `-a`, 123/124 exits); `grep` uses the shared POSIX translator with real `-w` and newline-separated patterns; `printf` keeps `\c` literal in FORMAT and pads `%b`/`%q`; `echo -e` adds `\e`, `\u`, `\U`; `seq` matches GNU precision, `-w` and `-f`; `paste` shares one stdin cursor across `-` operands and adds `-z`; `nl` implements logical-page sections, `-d/-h/-f/-l/-p` and `pBRE`; `fold` uses GNU's column model; `comm` adds `-z`, `--output-delimiter`, `--total`; `tac` implements `-s/-b/-r`; `sleep` takes unit suffixes, hex and summed operands; `stat` adds `%N`; `od` ports GNU's block layout (multiple `-t`, sizes, `z`, `*` runs, default `-t o2`); `find` adds `-fprint*`/`-fls`; `du` prints operand-relative paths with GNU options and rounding; `date`, `base64`, `head`/`tail`, `basename`, `rmdir`, `mktemp` parse GNU options; `cp -r` copies trees (it failed on every directory). Options go through the new `gnu_getopt` helper (see [Builtins](foundations/builtins.md#gnu-option-parsing)). New limitations L-FIND-003, L-DU-001, L-OD-001, L-FOLD-001. ## 2026-10-06 diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index 5066e5b58..a6f63e1c4 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -172,8 +172,12 @@ pass in CI); only divergences and boundaries are recorded here. | L-FACTOR-001 | factor | Numbers above 2^64-1 are rejected ("too large"); GNU factor accepts arbitrary precision | `factor.rs::rejects_bad_tokens` | | L-ENV-001 | umask, ulimit, enable | `umask` is stored and reported (subshell-scoped) but does not yet change VFS file creation modes; `ulimit` reports fixed sandbox values (not host limits) and stores lowered values without enforcing them, real caps are `ExecutionLimits`; `enable -n/-d/-f` (disable or load builtins) are refused | `shellenv.rs`, `process-env-builtins.test.sh` | | L-DD-001 | dd | Transfer-statistics line reports `0 s, 0 B/s` (no wall-clock rate); each invocation moves at most 64 MiB (`DD_MAX_BYTES`) and an uncounted `/dev/zero`/`/dev/urandom` read stops there with exit 1 | `dd.rs` | -| L-FIND-001 | find | Not implemented: `-ok`/`-okdir` (need an interactive terminal; refused with an error), `-fls`, `-fprint`/`-fprint0`/`-fprintf`, `-samefile`, `-inum`, `-links`, `-fstype`, `-context`, `-used`. Entries are visited in byte-sorted order, not readdir order. Access/change times read the modification time (the VFS keeps one). `-newerXt` dates use `date -d` parsing. `{} +` batches run at the end of the walk (or every 4096 paths), so their output follows find's own output; `-execdir ... {} +` is not flushed per directory. `-printf` `%i`/`%D` print 0, `%F` prints `vfs`, `%k`/`%b` assume 4 KiB blocks; `-ls` always shows `HH:MM`, never the year, and directories have size 0 | `find/mod.rs`, `find.test.sh` | +| L-FIND-001 | find | Not implemented: `-ok`/`-okdir` (need an interactive terminal; refused with an error), `-samefile`, `-inum`, `-links`, `-fstype`, `-context`, `-used`. Entries are visited in byte-sorted order, not readdir order. Access/change times read the modification time (the VFS keeps one). `-newerXt` dates use `date -d` parsing. `{} +` batches run at the end of the walk (or every 4096 paths), so their output follows find's own output; `-execdir ... {} +` is not flushed per directory. `-printf` `%i`/`%D` print 0, `%F` prints `vfs`, `%k`/`%b` assume 4 KiB blocks; `-ls` always shows `HH:MM`, never the year, and directories have size 0 | `find/mod.rs`, `find.test.sh` | | L-FIND-002 | find | `-exec` commands run with no stdin (GNU passes find's stdin through) | `find/mod.rs` | +| L-FIND-003 | find | `-fprint`/`-fprint0`/`-fprintf`/`-fls` truncate their file before the walk (as GNU does) but write the collected text when the walk ends, so the file stays empty while `-exec` commands run | `find_fprint_family` | +| L-DU-001 | du | The VFS has no block allocation: sizes are apparent sizes (default output is ceil(bytes/1024)), hard links are not de-duplicated, and `-x`/`-l`/`-S`/`-L`/`-P`/`-0` are accepted but have no effect | `disk.rs` tests | +| L-OD-001 | od | Floating-point types (`-t f`, `-e`, `-f`, `-F`) are refused with an error; `-S`/`--strings` and `--traditional` are accepted but ignored | `test_od_float_rejected` | +| L-FOLD-001 | fold | Every character counts one column (no East Asian wide-char widths); `-b` counts UTF-8 bytes | `fold.rs` | | L-STR-001 | strings | Accepts dash-prefixed filenames (e.g. `-data.bin`), so only a lone unknown short option (`-Q`) is rejected as invalid; GNU rejects `-data.bin` too | `strings.rs` | | L-MAKE-001 | make | `$(eval)` and `$(file)` stop make with `function '...' is not supported (L-MAKE-001)` | Both write makefile state or files mid-expansion; expansion restarts on a `$(shell)`/`$(wildcard)` miss, so side-effecting functions would run more than once | `l_make_001_eval_and_file_unsupported` | | L-MAKE-002 | make | No built-in implicit rules (as `make -r`), pattern rules do not chain through intermediate files, and `vpath`/`VPATH` are not searched | The sandbox has no compiler for built-in rules to call; chaining and directory search are the next steps if real makefiles need them | `l_make_002_no_builtin_or_chained_rules` | diff --git a/knowledge/operations/maintenance.md b/knowledge/operations/maintenance.md index 89c7dca44..a4367977b 100644 --- a/knowledge/operations/maintenance.md +++ b/knowledge/operations/maintenance.md @@ -206,8 +206,8 @@ See [Coreutils Argument Port](../runtimes/coreutils-args-port.md). (weekly cron). The PR covers **both** ported argument surfaces (args mode) and vendored uucore modules (module mode): - **Args mode review**: confirm new flags are wired into the - consuming builtin or explicitly rejected (matching the existing - `tac -b/-r/-s` "not yet implemented" pattern, no silent no-ops). + consuming builtin or explicitly rejected with a "not yet + implemented" error (no silent no-ops). Confirm removed/renamed flags don't break downstream scripts; migrate or document. - **Module mode review**: for every entry in