diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 867d360c..2d57968c 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -58,6 +58,7 @@ mod source; mod strings; mod system; mod test; +mod textrev; mod timeout; mod vars; mod wait; @@ -105,6 +106,7 @@ pub use source::Source; pub use strings::Strings; pub use system::{Hostname, Id, Uname, Whoami, DEFAULT_HOSTNAME, DEFAULT_USERNAME}; pub use test::{Bracket, Test}; +pub use textrev::{Rev, Tac}; pub use timeout::Timeout; pub use vars::{Eval, Local, Readonly, Set, Shift, Shopt, Times, Unset}; pub use wait::Wait; diff --git a/crates/bashkit/src/builtins/textrev.rs b/crates/bashkit/src/builtins/textrev.rs new file mode 100644 index 00000000..b7417da5 --- /dev/null +++ b/crates/bashkit/src/builtins/textrev.rs @@ -0,0 +1,125 @@ +//! Text reversal builtins: tac (reverse line order) and rev (reverse characters per line) + +use async_trait::async_trait; +use std::path::Path; + +use super::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +/// Read input from files or stdin, returning the raw text. +async fn read_input(ctx: &Context<'_>) -> std::result::Result { + let mut files: Vec<&str> = Vec::new(); + for arg in ctx.args { + if !arg.starts_with('-') { + files.push(arg); + } + } + + let mut raw = String::new(); + if files.is_empty() { + if let Some(stdin) = ctx.stdin { + raw.push_str(stdin); + } + } else { + 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.to_string() + } else { + ctx.cwd.join(file).to_string_lossy().to_string() + }; + match ctx.fs.read_file(Path::new(&path)).await { + Ok(content) => { + let text = String::from_utf8_lossy(&content); + raw.push_str(&text); + } + Err(e) => { + return Err(ExecResult::err(format!("tac: {}: {}\n", file, e), 1)); + } + } + } + } + } + Ok(raw) +} + +/// The tac builtin - concatenate and print files in reverse (line order). +/// +/// Usage: tac [FILE...] +/// +/// Prints lines in reverse order. Reads from stdin if no files given. +pub struct Tac; + +#[async_trait] +impl Builtin for Tac { + async fn execute(&self, ctx: Context<'_>) -> Result { + let raw = match read_input(&ctx).await { + Ok(r) => r, + Err(e) => return Ok(e), + }; + + if raw.is_empty() { + return Ok(ExecResult::ok(String::new())); + } + + let has_trailing_newline = raw.ends_with('\n'); + let trimmed = if has_trailing_newline { + &raw[..raw.len() - 1] + } else { + &raw + }; + + let mut lines: Vec<&str> = trimmed.split('\n').collect(); + lines.reverse(); + + let mut output = lines.join("\n"); + output.push('\n'); + + Ok(ExecResult::ok(output)) + } +} + +/// The rev builtin - reverse characters of each line. +/// +/// Usage: rev [FILE...] +/// +/// Reverses characters on each line. Reads from stdin if no files given. +pub struct Rev; + +#[async_trait] +impl Builtin for Rev { + async fn execute(&self, ctx: Context<'_>) -> Result { + let raw = match read_input(&ctx).await { + Ok(r) => r, + Err(e) => return Ok(e), + }; + + if raw.is_empty() { + return Ok(ExecResult::ok(String::new())); + } + + let has_trailing_newline = raw.ends_with('\n'); + let trimmed = if has_trailing_newline { + &raw[..raw.len() - 1] + } else { + &raw + }; + + let mut output = String::new(); + for (i, line) in trimmed.split('\n').enumerate() { + if i > 0 { + output.push('\n'); + } + let reversed: String = line.chars().rev().collect(); + output.push_str(&reversed); + } + output.push('\n'); + + Ok(ExecResult::ok(output)) + } +} diff --git a/crates/bashkit/src/interpreter/mod.rs b/crates/bashkit/src/interpreter/mod.rs index 65407e9f..be1c458f 100644 --- a/crates/bashkit/src/interpreter/mod.rs +++ b/crates/bashkit/src/interpreter/mod.rs @@ -258,6 +258,8 @@ impl Interpreter { builtins.insert("xxd".to_string(), Box::new(builtins::Xxd)); builtins.insert("hexdump".to_string(), Box::new(builtins::Hexdump)); builtins.insert("seq".to_string(), Box::new(builtins::Seq)); + builtins.insert("tac".to_string(), Box::new(builtins::Tac)); + builtins.insert("rev".to_string(), Box::new(builtins::Rev)); builtins.insert("sort".to_string(), Box::new(builtins::Sort)); builtins.insert("uniq".to_string(), Box::new(builtins::Uniq)); builtins.insert("cut".to_string(), Box::new(builtins::Cut)); diff --git a/crates/bashkit/tests/spec_cases/bash/textrev.test.sh b/crates/bashkit/tests/spec_cases/bash/textrev.test.sh new file mode 100644 index 00000000..ca2131c8 --- /dev/null +++ b/crates/bashkit/tests/spec_cases/bash/textrev.test.sh @@ -0,0 +1,92 @@ +### tac_basic +# tac reverses line order +printf "a\nb\nc\n" | tac +### expect +c +b +a +### end + +### tac_single_line +# tac with single line +echo "hello" | tac +### expect +hello +### end + +### tac_from_file +# tac reads from file +### bash_diff +printf "one\ntwo\nthree\n" > /tmp/tac_test +tac /tmp/tac_test +### expect +three +two +one +### end + +### tac_numbered +# tac with numbered lines +printf "1\n2\n3\n4\n5\n" | tac +### expect +5 +4 +3 +2 +1 +### end + +### tac_empty_stdin +# tac with empty input produces no output +echo -n "" | tac +echo "done" +### expect +done +### end + +### rev_basic +# rev reverses characters on each line +echo "hello" | rev +### expect +olleh +### end + +### rev_multiple_lines +# rev reverses each line independently +printf "abc\ndef\nghi\n" | rev +### expect +cba +fed +ihg +### end + +### rev_palindrome +# rev on palindrome outputs same word +echo "racecar" | rev +### expect +racecar +### end + +### rev_from_file +# rev reads from file +### bash_diff +echo "hello world" > /tmp/rev_test +rev /tmp/rev_test +### expect +dlrow olleh +### end + +### rev_empty_stdin +# rev with empty input produces no output +echo -n "" | rev +echo "done" +### expect +done +### end + +### rev_spaces +# rev preserves and reverses spaces +echo "a b c" | rev +### expect +c b a +### end diff --git a/specs/009-implementation-status.md b/specs/009-implementation-status.md index 56533212..46bf42b4 100644 --- a/specs/009-implementation-status.md +++ b/specs/009-implementation-status.md @@ -103,17 +103,17 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See ## Spec Test Coverage -**Total spec test cases:** 1348 (1343 pass, 5 skip) +**Total spec test cases:** 1359 (1354 pass, 5 skip) | Category | Cases | In CI | Pass | Skip | Notes | |----------|-------|-------|------|------|-------| -| Bash (core) | 930 | Yes | 925 | 5 | `bash_spec_tests` in CI | +| Bash (core) | 941 | Yes | 936 | 5 | `bash_spec_tests` in CI | | AWK | 96 | Yes | 96 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g | | Grep | 76 | Yes | 76 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude, binary detect | | Sed | 75 | Yes | 75 | 0 | hold space, change, regex ranges, -E | | JQ | 114 | Yes | 114 | 0 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | | Python | 57 | Yes | 57 | 0 | embedded Python (Monty) | -| **Total** | **1348** | **Yes** | **1343** | **5** | | +| **Total** | **1359** | **Yes** | **1354** | **5** | | ### Bash Spec Tests Breakdown @@ -165,6 +165,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. See | eval-bugs.test.sh | 4 | regression tests for eval/script bugs | | script-exec.test.sh | 10 | script execution by path, $PATH search, exit codes | | seq.test.sh | 12 | `seq` numeric sequences, `-w`, `-s`, decrement, negative | +| textrev.test.sh | 11 | `tac` reverse line order, `rev` reverse characters | | heredoc.test.sh | 10 | heredoc variable expansion, quoted delimiters, file redirects, `<<-` tab strip | | string-ops.test.sh | 14 | string replacement (prefix/suffix anchored), `${var:?}`, case conversion | | read-builtin.test.sh | 10 | `read` builtin, IFS splitting, `-r`, `-a` (array), `-n` (nchars), here-string |