feat(builtins): sweep of GNU/BSD compat fixes + str performance opts
Addresses a broad audit of built-in shell utilities against their real counterparts: timeout gains signal delivery, -s/-k/--preserve-status/--foreground/-v, and GNU exit codes; diff defaults to normal format and gains -w/-b/-B/-i/-c/-x/-L/-s/--strip-trailing-cr and proper -r gating; find fixes -newerXY timestamp comparison direction, anchors -regex to whole paths, and gains BSD -perm +mode, -type lists, -size T/P suffixes, -E/-x/-s flags; date gains BSD -r epoch, -v adjustments, -j -f strptime, and non-greedy -I; tail/head accept obsolete -N/+N at any position with any file count; rg resolves case flags by last occurrence and gains --path-separator and clean -0 output; stat prints integer epochs for %X/%Y/%Z and gains BSD -s/-x/-t; cksum is registered as a builtin; truncate implements -o/--io-blocks and b/= size suffixes; sleep/timeout accept infinity; yes/errno/kill accept hyphen-prefixed operands; nohup -- cmd no longer runs --; which gains BSD -s.
This commit is contained in:
+192
-45
@@ -175,10 +175,13 @@ fn process_num_block(
|
||||
let mut quiet = false;
|
||||
let mut verbose = false;
|
||||
let mut zero_terminated = false;
|
||||
// Lowercase suffixes are byte multipliers (obsolete BSD `-Nc`/`-Nb`/`-Nk`/`-Nm`);
|
||||
// uppercase suffixes mirror the modern `-n NUM<suffix>` form and scale the
|
||||
// line count (`head -10K` == `head -n 10240`).
|
||||
let mut multiplier = None;
|
||||
let mut line_multiplier: usize = 1;
|
||||
let mut c = last_char;
|
||||
loop {
|
||||
// note that here, we only match lower case 'k', 'c', and 'm'
|
||||
match c {
|
||||
// we want to preserve order
|
||||
// this also saves us 1 heap allocation
|
||||
@@ -195,6 +198,18 @@ fn process_num_block(
|
||||
'b' => multiplier = Some(512),
|
||||
'k' => multiplier = Some(1024),
|
||||
'm' => multiplier = Some(1024 * 1024),
|
||||
'K' => {
|
||||
line_multiplier = 1024;
|
||||
multiplier = None;
|
||||
},
|
||||
'M' => {
|
||||
line_multiplier = 1024 * 1024;
|
||||
multiplier = None;
|
||||
},
|
||||
'G' => {
|
||||
line_multiplier = 1024 * 1024 * 1024;
|
||||
multiplier = None;
|
||||
},
|
||||
'\0' => {},
|
||||
_ => return Err(ParseError),
|
||||
}
|
||||
@@ -220,6 +235,7 @@ fn process_num_block(
|
||||
options.push(OsString::from(format!("{num}")));
|
||||
} else {
|
||||
options.push(OsString::from("-n"));
|
||||
let num = num.saturating_mul(line_multiplier);
|
||||
options.push(OsString::from(format!("{num}")));
|
||||
}
|
||||
Ok(options)
|
||||
@@ -266,6 +282,8 @@ mod tests {
|
||||
assert_eq!(obsolete("-1k"), obsolete_result(&["-c", "1024"]));
|
||||
assert_eq!(obsolete("-2b"), obsolete_result(&["-c", "1024"]));
|
||||
assert_eq!(obsolete("-1mmk"), obsolete_result(&["-c", "1024"]));
|
||||
assert_eq!(obsolete("-10K"), obsolete_result(&["-n", "10240"]));
|
||||
assert_eq!(obsolete("-1M"), obsolete_result(&["-n", "1048576"]));
|
||||
assert_eq!(obsolete("-1vz"), obsolete_result(&["-v", "-z", "-n", "1"]));
|
||||
assert_eq!(
|
||||
obsolete("-1vzqvq"),
|
||||
@@ -1020,33 +1038,81 @@ impl Mode {
|
||||
}
|
||||
}
|
||||
|
||||
fn arg_iterate<'a>(
|
||||
mut args: impl Iterator<Item = OsString> + 'a,
|
||||
) -> HeadResult<Box<dyn Iterator<Item = OsString> + 'a>> {
|
||||
// argv[0] is always present
|
||||
let first = args.next().unwrap();
|
||||
if let Some(second) = args.next() {
|
||||
if let Some(s) = second.to_str() {
|
||||
if let Some(v) = parse::parse_obsolete(s) {
|
||||
match v {
|
||||
Ok(iter) => Ok(Box::new(vec![first].into_iter().chain(iter).chain(args))),
|
||||
Err(parse::ParseError) => {
|
||||
Err(HeadError::ParseError(format!("bad argument format: {}", s.quote())))
|
||||
},
|
||||
}
|
||||
} else {
|
||||
// The second argument contains non-UTF-8 sequences, so it can't be an obsolete
|
||||
// option like "-5". Treat it as a regular file argument.
|
||||
Ok(Box::new(vec![first, second].into_iter().chain(args)))
|
||||
}
|
||||
} else {
|
||||
// The second argument contains non-UTF-8 sequences, so it can't be an obsolete
|
||||
// option like "-5". Treat it as a regular file argument.
|
||||
Ok(Box::new(vec![first, second].into_iter().chain(args)))
|
||||
/// True when `token` is an option that takes its value from the *next* argv
|
||||
/// token, so that value must never be mistaken for an obsolete `-NUM` form
|
||||
/// (e.g. the `-5` in `head -n -5 file`).
|
||||
fn consumes_separate_value(token: &str) -> bool {
|
||||
if let Some(long) = token.strip_prefix("--") {
|
||||
if long.is_empty() || long.contains('=') {
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
Ok(Box::new(vec![first].into_iter()))
|
||||
// clap infers unambiguous long-option prefixes.
|
||||
return ["lines", "bytes"].iter().any(|name| name.starts_with(long));
|
||||
}
|
||||
let Some(cluster) = token.strip_prefix('-') else {
|
||||
return false;
|
||||
};
|
||||
let mut chars = cluster.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
match c {
|
||||
// Value-taking shorts: a trailing `-n`/`-c` consumes the next
|
||||
// token; anything after them in the cluster is an attached value.
|
||||
'n' | 'c' => return chars.next().is_none(),
|
||||
'q' | 'v' | 'z' => {},
|
||||
_ => return false,
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Rewrites every obsolete `-NUM[suffix]` token (before `--`) into modern
|
||||
/// options, wherever it appears among flags and operands: GNU/BSD accept
|
||||
/// `head -q -5 file`, `head file -5`, and `head -5 -20 file`.
|
||||
fn arg_iterate(argv: Vec<OsString>) -> HeadResult<Vec<OsString>> {
|
||||
let mut rewritten = Vec::with_capacity(argv.len() + 1);
|
||||
let mut iter = argv.into_iter();
|
||||
// argv[0] is always present
|
||||
rewritten.extend(iter.next());
|
||||
let mut skip_value = false;
|
||||
let mut seen_ddash = false;
|
||||
for arg in iter {
|
||||
if skip_value || seen_ddash {
|
||||
skip_value = false;
|
||||
rewritten.push(arg);
|
||||
continue;
|
||||
}
|
||||
let Some(token) = arg.to_str() else {
|
||||
// Non-UTF-8 can't be an obsolete option like "-5"; treat it as a
|
||||
// regular file argument.
|
||||
rewritten.push(arg);
|
||||
continue;
|
||||
};
|
||||
if token == "--" {
|
||||
seen_ddash = true;
|
||||
rewritten.push(arg);
|
||||
continue;
|
||||
}
|
||||
if matches!(token.as_bytes(), [b'-', b'0'..=b'9', ..]) {
|
||||
match parse::parse_obsolete(token) {
|
||||
Some(Ok(options)) => {
|
||||
rewritten.extend(options);
|
||||
continue;
|
||||
},
|
||||
Some(Err(parse::ParseError)) => {
|
||||
return Err(HeadError::ParseError(format!(
|
||||
"bad argument format: {}",
|
||||
token.quote()
|
||||
)));
|
||||
},
|
||||
None => {},
|
||||
}
|
||||
}
|
||||
if token.len() > 1 && token.starts_with('-') {
|
||||
skip_value = consumes_separate_value(token);
|
||||
}
|
||||
rewritten.push(arg);
|
||||
}
|
||||
Ok(rewritten)
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Default)]
|
||||
@@ -1301,9 +1367,7 @@ impl Utility for Head {
|
||||
// Normalize GNU's obsolete `-NUM` syntax before clap sees argv; clap
|
||||
// otherwise treats it as an unknown short-option cluster.
|
||||
fn rewrite_argv(argv: Vec<OsString>) -> Result<Vec<OsString>, String> {
|
||||
arg_iterate(argv.into_iter())
|
||||
.map(Iterator::collect)
|
||||
.map_err(|err| err.to_string())
|
||||
arg_iterate(argv).map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -1316,14 +1380,24 @@ impl Utility for Head {
|
||||
},
|
||||
};
|
||||
|
||||
let print_headers = (options.files.len() > 1 && !options.quiet) || options.verbose;
|
||||
// GNU head only emits the blank separator line before a header when a
|
||||
// previous file actually produced output; open failures print nothing
|
||||
// and must not flip `first`.
|
||||
let mut first = true;
|
||||
fn print_header(out: &mut impl Write, name: &[u8], first: &mut bool) {
|
||||
if !*first {
|
||||
let _ = writeln!(out);
|
||||
}
|
||||
let _ = out.write_all(b"==> ");
|
||||
let _ = out.write_all(name);
|
||||
let _ = out.write_all(b" <==\n");
|
||||
*first = false;
|
||||
}
|
||||
for file in &options.files {
|
||||
let result = if file == "-" {
|
||||
if (options.files.len() > 1 && !options.quiet) || options.verbose {
|
||||
if !first {
|
||||
let _ = writeln!(host.stdout);
|
||||
}
|
||||
let _ = writeln!(host.stdout, "==> standard input <==");
|
||||
if print_headers {
|
||||
print_header(&mut host.stdout, b"standard input", &mut first);
|
||||
}
|
||||
let mut input = io::BufReader::with_capacity(BUF_SIZE, &mut host.stdin);
|
||||
match options.mode {
|
||||
@@ -1344,25 +1418,23 @@ impl Utility for Head {
|
||||
} else {
|
||||
let resolved = host.resolve(file);
|
||||
if resolved.is_dir() {
|
||||
// GNU prints the header before reporting the read error,
|
||||
// and that header counts as produced output.
|
||||
if print_headers {
|
||||
print_header(&mut host.stdout, file.as_encoded_bytes(), &mut first);
|
||||
}
|
||||
host.error(format!("error reading {}: Is a directory", file.quote()), 1);
|
||||
first = false;
|
||||
continue;
|
||||
}
|
||||
let mut input = match File::open(&resolved) {
|
||||
Ok(input) => input,
|
||||
Err(err) => {
|
||||
host.error(format!("cannot open {} for reading: {err}", file.quote()), 1);
|
||||
first = false;
|
||||
continue;
|
||||
},
|
||||
};
|
||||
if (options.files.len() > 1 && !options.quiet) || options.verbose {
|
||||
if !first {
|
||||
let _ = writeln!(host.stdout);
|
||||
}
|
||||
let _ = write!(host.stdout, "==> ");
|
||||
let _ = host.stdout.write_all(file.as_encoded_bytes());
|
||||
let _ = writeln!(host.stdout, " <==");
|
||||
if print_headers {
|
||||
print_header(&mut host.stdout, file.as_encoded_bytes(), &mut first);
|
||||
}
|
||||
head_file(&mut input, &mut host.stdout, &options)
|
||||
};
|
||||
@@ -1372,10 +1444,14 @@ impl Utility for Head {
|
||||
} else {
|
||||
PathBuf::from(file)
|
||||
};
|
||||
// A dead pipe ends the whole invocation; any other I/O error
|
||||
// only fails this operand, and GNU keeps going.
|
||||
let broken_pipe = err.kind() == io::ErrorKind::BrokenPipe;
|
||||
host.error(HeadError::Io { name, err }, 1);
|
||||
return 1;
|
||||
if broken_pipe {
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
host.exit_code()
|
||||
}
|
||||
@@ -1434,6 +1510,77 @@ mod tests {
|
||||
assert_eq!(capture.err(), "head: bad argument format: '-123FooBar'\n");
|
||||
}
|
||||
|
||||
fn rewritten(argv: &[&str]) -> Vec<String> {
|
||||
Head::rewrite_argv(argv.iter().map(OsString::from).collect())
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|arg| arg.to_str().unwrap().to_owned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
// Failure mode: obsolete `-NUM` was only recognized as argv[1], so
|
||||
// `head -q -5 file`, `head file -5`, and repeated counts were parse errors.
|
||||
#[test]
|
||||
fn obsolete_num_is_rewritten_at_any_position() {
|
||||
assert_eq!(rewritten(&["head", "-q", "-5", "f"]), ["head", "-q", "-n", "5", "f"]);
|
||||
assert_eq!(rewritten(&["head", "-v", "-20", "f"]), ["head", "-v", "-n", "20", "f"]);
|
||||
assert_eq!(rewritten(&["head", "f", "-5"]), ["head", "f", "-n", "5"]);
|
||||
assert_eq!(
|
||||
rewritten(&["head", "-5", "-20", "f"]),
|
||||
["head", "-n", "5", "-n", "20", "f"]
|
||||
);
|
||||
assert_eq!(rewritten(&["head", "-5qz", "f"]), ["head", "-q", "-z", "-n", "5", "f"]);
|
||||
}
|
||||
|
||||
// Failure mode: `-5` following a value-taking option is that option's
|
||||
// value, and rewriting it would corrupt the invocation.
|
||||
#[test]
|
||||
fn option_values_and_post_ddash_operands_are_not_rewritten() {
|
||||
assert_eq!(rewritten(&["head", "-n", "-5", "f"]), ["head", "-n", "-5", "f"]);
|
||||
assert_eq!(rewritten(&["head", "-c", "-5", "f"]), ["head", "-c", "-5", "f"]);
|
||||
assert_eq!(rewritten(&["head", "--lines", "-5", "f"]), ["head", "--lines", "-5", "f"]);
|
||||
assert_eq!(rewritten(&["head", "--", "-5"]), ["head", "--", "-5"]);
|
||||
assert_eq!(rewritten(&["head", "-n5", "-", "f"]), ["head", "-n5", "-", "f"]);
|
||||
}
|
||||
|
||||
// Failure mode: uppercase suffixes in the obsolete form were rejected
|
||||
// even though `head -n 10K` accepts them.
|
||||
#[test]
|
||||
fn obsolete_uppercase_suffixes_scale_lines() {
|
||||
assert_eq!(options("-10K").unwrap().mode, Mode::FirstLines(10 * 1024));
|
||||
assert_eq!(options("-1M").unwrap().mode, Mode::FirstLines(1024 * 1024));
|
||||
assert_eq!(options("-1G").unwrap().mode, Mode::FirstLines(1024 * 1024 * 1024));
|
||||
// Lowercase suffixes keep their historical byte meaning.
|
||||
assert_eq!(options("-1k").unwrap().mode, Mode::FirstBytes(1024));
|
||||
}
|
||||
|
||||
// Failure mode: an unreadable operand flipped the separator state and the
|
||||
// next header gained a spurious leading blank line; a mid-list open error
|
||||
// must also not abort the remaining operands.
|
||||
#[test]
|
||||
fn open_error_produces_no_separator_and_processing_continues() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::write(dir.path().join("f1"), b"a\n").unwrap();
|
||||
std::fs::write(dir.path().join("f2"), b"b\n").unwrap();
|
||||
let (code, capture) = run_util::<Head>(&["-n", "1", "missing", "f1", "f2"], "", dir.path());
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(capture.out(), "==> f1 <==\na\n\n==> f2 <==\nb\n");
|
||||
assert!(capture.err().contains("cannot open 'missing' for reading"));
|
||||
}
|
||||
|
||||
// Failure mode: the `==> dir <==` header was suppressed before the
|
||||
// Is-a-directory diagnostic; GNU prints it and counts it as output.
|
||||
#[test]
|
||||
fn directory_operand_prints_header_before_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
std::fs::create_dir(dir.path().join("d")).unwrap();
|
||||
std::fs::write(dir.path().join("f"), b"a\n").unwrap();
|
||||
let (code, capture) = run_util::<Head>(&["-n", "1", "d", "f"], "", dir.path());
|
||||
assert_eq!(code, 1);
|
||||
assert_eq!(capture.out(), "==> d <==\n\n==> f <==\na\n");
|
||||
assert_eq!(capture.err(), "head: error reading 'd': Is a directory\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_to_ten_lines_from_stdin() {
|
||||
let input = (1..=12).map(|n| format!("{n}\n")).collect::<String>();
|
||||
|
||||
Reference in New Issue
Block a user