From 93d1e946e568d3b3dd9559cee81e961c49115fa8 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 25 May 2026 21:08:40 +0000 Subject: [PATCH 1/3] refactor(builtins): add BuiltinHelper trait, migrate 12 builtins Introduces a small trait that binds the command name as an associated const and provides default check_help / err / err_path helpers. Removes the per-builtin repetition of 'cmd: ' prefixes and 'cmd: file: msg' path diagnostics. Migrated: echo, yes, sleep, base64, comm, fold, column, expand, unexpand, cut, tr, iconv. --- crates/bashkit/src/builtins/base64.rs | 18 +++---- crates/bashkit/src/builtins/column.rs | 8 ++- crates/bashkit/src/builtins/comm.rs | 10 ++-- crates/bashkit/src/builtins/cuttr.rs | 26 ++++----- crates/bashkit/src/builtins/echo.rs | 8 ++- crates/bashkit/src/builtins/expand.rs | 39 ++++++-------- crates/bashkit/src/builtins/fold.rs | 18 +++---- crates/bashkit/src/builtins/helpers.rs | 75 ++++++++++++++++++++++++++ crates/bashkit/src/builtins/iconv.rs | 58 ++++++-------------- crates/bashkit/src/builtins/mod.rs | 3 ++ crates/bashkit/src/builtins/sleep.rs | 20 ++++--- crates/bashkit/src/builtins/yes.rs | 8 ++- 12 files changed, 174 insertions(+), 117 deletions(-) create mode 100644 crates/bashkit/src/builtins/helpers.rs diff --git a/crates/bashkit/src/builtins/base64.rs b/crates/bashkit/src/builtins/base64.rs index 355ff4671..2572d406f 100644 --- a/crates/bashkit/src/builtins/base64.rs +++ b/crates/bashkit/src/builtins/base64.rs @@ -4,7 +4,7 @@ use async_trait::async_trait; use base64::Engine; use super::arg_parser::ArgParser; -use super::{Builtin, Context, read_text_file}; +use super::{Builtin, BuiltinHelper, Context, read_text_file}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -17,10 +17,14 @@ use crate::interpreter::ExecResult; /// -w COLS Wrap encoded lines after COLS characters (default: 76, 0 = no wrap) pub struct Base64; +impl BuiltinHelper for Base64 { + const NAME: &'static str = "base64"; +} + #[async_trait] impl Builtin for Base64 { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + if let Some(r) = Self::check_help( ctx.args, "Usage: base64 [OPTION]... [FILE]\nBase64 encode or decode FILE, or standard input.\n\n -d, --decode\tdecode data\n -w COLS, --wrap=COLS\twrap encoded lines after COLS characters (default 76)\n -i, --ignore-garbage\twhen decoding, ignore non-alphabet characters\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("base64 (bashkit) 0.1"), @@ -47,8 +51,7 @@ impl Builtin for Base64 { // silently accept } else if parser.is_flag() { if let Some(s) = parser.positional() { - let msg = format!("base64: invalid option -- '{}'\n", &s[1..]); - return Ok(ExecResult::err(msg, 1)); + return Ok(Self::err(format!("invalid option -- '{}'", &s[1..]), 1)); } } else if let Some(arg) = parser.positional() { file = Some(arg.to_string()); @@ -64,10 +67,7 @@ impl Builtin for Base64 { match read_text_file(ctx.fs.as_ref(), &resolved, "base64").await { Ok(text) => text, Err(_) => { - return Ok(ExecResult::err( - format!("base64: {}: No such file or directory\n", path), - 1, - )); + return Ok(Self::err_path(path, "No such file or directory", 1)); } } } @@ -84,7 +84,7 @@ impl Builtin for Base64 { let output = String::from_utf8_lossy(&bytes).to_string(); Ok(ExecResult::ok(output)) } - Err(e) => Ok(ExecResult::err(format!("base64: invalid input: {e}\n"), 1)), + Err(e) => Ok(Self::err(format!("invalid input: {e}"), 1)), } } else { // Encode diff --git a/crates/bashkit/src/builtins/column.rs b/crates/bashkit/src/builtins/column.rs index 3b27186fe..1304fd471 100644 --- a/crates/bashkit/src/builtins/column.rs +++ b/crates/bashkit/src/builtins/column.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; -use super::{Builtin, Context, read_text_file}; +use super::{Builtin, BuiltinHelper, Context, read_text_file}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -16,6 +16,10 @@ use crate::interpreter::ExecResult; /// -o SEP Specify output delimiter for -t mode (default: two spaces) pub struct Column; +impl BuiltinHelper for Column { + const NAME: &'static str = "column"; +} + struct ColumnOptions { table: bool, input_sep: Option, @@ -141,7 +145,7 @@ fn format_columns(text: &str) -> String { #[async_trait] impl Builtin for Column { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + if let Some(r) = Self::check_help( ctx.args, "Usage: column [OPTION]... [FILE]...\nColumnate lists.\n\n -t\t\tcreate a table\n -s SEP\tspecify input delimiter for -t mode\n -o SEP\tspecify output delimiter for -t mode\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("column (bashkit) 0.1"), diff --git a/crates/bashkit/src/builtins/comm.rs b/crates/bashkit/src/builtins/comm.rs index f8df091ad..7beda127a 100644 --- a/crates/bashkit/src/builtins/comm.rs +++ b/crates/bashkit/src/builtins/comm.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; -use super::{Builtin, Context, read_text_file}; +use super::{Builtin, BuiltinHelper, Context, read_text_file}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -16,6 +16,10 @@ use crate::interpreter::ExecResult; /// -3 Suppress lines that appear in both files pub struct Comm; +impl BuiltinHelper for Comm { + const NAME: &'static str = "comm"; +} + struct CommOptions { suppress_1: bool, suppress_2: bool, @@ -51,7 +55,7 @@ fn parse_comm_args(args: &[String]) -> (CommOptions, Vec) { #[async_trait] impl Builtin for Comm { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + 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", Some("comm (bashkit) 0.1"), @@ -61,7 +65,7 @@ impl Builtin for Comm { let (opts, files) = parse_comm_args(ctx.args); if files.len() < 2 { - return Ok(ExecResult::err("comm: missing operand\n".to_string(), 1)); + return Ok(Self::err("missing operand", 1)); } // Read both files diff --git a/crates/bashkit/src/builtins/cuttr.rs b/crates/bashkit/src/builtins/cuttr.rs index c324f85f0..239900454 100644 --- a/crates/bashkit/src/builtins/cuttr.rs +++ b/crates/bashkit/src/builtins/cuttr.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; -use super::{Builtin, Context, read_text_file}; +use super::{Builtin, BuiltinHelper, Context, read_text_file}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -22,6 +22,10 @@ use crate::interpreter::ExecResult; /// --output-delimiter Use STRING as output delimiter pub struct Cut; +impl BuiltinHelper for Cut { + const NAME: &'static str = "cut"; +} + #[derive(PartialEq)] enum CutMode { Fields, @@ -31,7 +35,7 @@ enum CutMode { #[async_trait] impl Builtin for Cut { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + if let Some(r) = Self::check_help( ctx.args, "Usage: cut OPTION... [FILE]...\nPrint selected parts of lines from each FILE to standard output.\n\n -d DELIM\t\tuse DELIM instead of TAB for field delimiter\n -f FIELDS\t\tselect only these fields\n -b BYTES\t\tselect only these bytes\n -c CHARS\t\tselect only these characters\n -s\t\t\tonly print lines containing delimiter\n -z\t\t\tline delimiter is NUL, not newline\n --complement\t\tcomplement the selection\n --output-delimiter=STRING\tuse STRING as output delimiter\n --help\t\tdisplay this help and exit\n --version\t\toutput version information and exit\n", Some("cut (bashkit) 0.1"), @@ -86,10 +90,7 @@ impl Builtin for Cut { } if spec.is_empty() { - return Ok(ExecResult::err( - "cut: you must specify a list of fields\n".to_string(), - 1, - )); + return Ok(Self::err("you must specify a list of fields", 1)); } // Parse position specification (supports open-ended ranges like "3-" and "-3") @@ -280,10 +281,14 @@ fn resolve_positions(positions: &[Position], total: usize) -> Vec { /// [:alnum:], [:space:], [:blank:], [:punct:], [:xdigit:], [:print:], [:graph:] pub struct Tr; +impl BuiltinHelper for Tr { + const NAME: &'static str = "tr"; +} + #[async_trait] impl Builtin for Tr { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + if let Some(r) = Self::check_help( ctx.args, "Usage: tr [OPTION]... SET1 [SET2]\nTranslate, squeeze, and/or delete characters from standard input.\n\n -d\t\tdelete characters in SET1\n -s\t\tsqueeze repeated output characters\n -c, -C\tcomplement SET1\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("tr (bashkit) 0.1"), @@ -314,7 +319,7 @@ impl Builtin for Tr { } if non_flag_args.is_empty() { - return Ok(ExecResult::err("tr: missing operand\n".to_string(), 1)); + return Ok(Self::err("missing operand", 1)); } let mut set1 = expand_char_set(&non_flag_args[0]); @@ -350,10 +355,7 @@ impl Builtin for Tr { squeeze_chars(stdin, &set1) } else { if non_flag_args.len() < 2 { - return Ok(ExecResult::err( - "tr: missing operand after SET1\n".to_string(), - 1, - )); + return Ok(Self::err("missing operand after SET1", 1)); } let set2 = expand_char_set(&non_flag_args[1]); diff --git a/crates/bashkit/src/builtins/echo.rs b/crates/bashkit/src/builtins/echo.rs index c8eefc6c2..d66e29fd0 100644 --- a/crates/bashkit/src/builtins/echo.rs +++ b/crates/bashkit/src/builtins/echo.rs @@ -2,17 +2,21 @@ use async_trait::async_trait; -use super::{Builtin, Context}; +use super::{Builtin, BuiltinHelper, Context}; use crate::error::Result; use crate::interpreter::ExecResult; /// The echo builtin command. pub struct Echo; +impl BuiltinHelper for Echo { + const NAME: &'static str = "echo"; +} + #[async_trait] impl Builtin for Echo { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + if let Some(r) = Self::check_help( ctx.args, "Usage: echo [SHORT-OPTION]... [STRING]...\n or: echo LONG-OPTION\nEcho the STRING(s) to standard output.\n\n -n\tdo not output the trailing newline\n -e\tenable interpretation of backslash escapes\n -E\tdisable interpretation of backslash escapes (default)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("echo (bashkit) 0.1"), diff --git a/crates/bashkit/src/builtins/expand.rs b/crates/bashkit/src/builtins/expand.rs index 888ecefc9..d8f628c8f 100644 --- a/crates/bashkit/src/builtins/expand.rs +++ b/crates/bashkit/src/builtins/expand.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; -use super::{Builtin, Context, read_text_file, resolve_path}; +use super::{Builtin, BuiltinHelper, Context, read_text_file, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -13,10 +13,14 @@ use crate::interpreter::ExecResult; /// Converts tabs to spaces. Default tab stop is 8. pub struct Expand; +impl BuiltinHelper for Expand { + const NAME: &'static str = "expand"; +} + #[async_trait] impl Builtin for Expand { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + 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", Some("expand (bashkit) 0.1"), @@ -32,10 +36,7 @@ impl Builtin for Expand { "-t" => { i += 1; if i >= ctx.args.len() { - return Ok(ExecResult::err( - "expand: option requires an argument -- 't'\n".to_string(), - 1, - )); + return Ok(Self::err("option requires an argument -- 't'", 1)); } tab_stops = parse_tab_stops(&ctx.args[i]); } @@ -56,10 +57,7 @@ impl Builtin for Expand { match read_text_file(ctx.fs.as_ref(), &path, "expand").await { Ok(text) => buf.push_str(&text), Err(_) => { - return Ok(ExecResult::err( - format!("expand: {}: No such file or directory\n", file), - 1, - )); + return Ok(Self::err_path(file, "No such file or directory", 1)); } } } @@ -101,10 +99,14 @@ impl Builtin for Expand { /// Converts spaces to tabs. By default, only converts leading spaces. pub struct Unexpand; +impl BuiltinHelper for Unexpand { + const NAME: &'static str = "unexpand"; +} + #[async_trait] impl Builtin for Unexpand { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + 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", Some("unexpand (bashkit) 0.1"), @@ -122,17 +124,11 @@ impl Builtin for Unexpand { "-t" => { i += 1; if i >= ctx.args.len() { - return Ok(ExecResult::err( - "unexpand: option requires an argument -- 't'\n".to_string(), - 1, - )); + return Ok(Self::err("option requires an argument -- 't'", 1)); } let parsed_tab_stops = parse_tab_stops(&ctx.args[i]); if parsed_tab_stops.is_empty() { - return Ok(ExecResult::err( - format!("unexpand: invalid tab size: '{}'\n", ctx.args[i]), - 1, - )); + return Ok(Self::err(format!("invalid tab size: '{}'", ctx.args[i]), 1)); } tab_stops = parsed_tab_stops; all = true; // -t implies -a @@ -151,10 +147,7 @@ impl Builtin for Unexpand { match read_text_file(ctx.fs.as_ref(), &path, "unexpand").await { Ok(text) => buf.push_str(&text), Err(_) => { - return Ok(ExecResult::err( - format!("unexpand: {}: No such file or directory\n", file), - 1, - )); + return Ok(Self::err_path(file, "No such file or directory", 1)); } } } diff --git a/crates/bashkit/src/builtins/fold.rs b/crates/bashkit/src/builtins/fold.rs index 1b4426187..676df7962 100644 --- a/crates/bashkit/src/builtins/fold.rs +++ b/crates/bashkit/src/builtins/fold.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; -use super::{Builtin, Context, read_text_file, resolve_path}; +use super::{Builtin, BuiltinHelper, Context, read_text_file, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -16,10 +16,14 @@ use crate::interpreter::ExecResult; /// -b Count bytes instead of columns pub struct Fold; +impl BuiltinHelper for Fold { + const NAME: &'static str = "fold"; +} + #[async_trait] impl Builtin for Fold { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + 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", Some("fold (bashkit) 0.1"), @@ -38,10 +42,7 @@ impl Builtin for Fold { "-w" => { i += 1; if i >= ctx.args.len() { - return Ok(ExecResult::err( - "fold: option requires an argument -- 'w'\n".to_string(), - 1, - )); + return Ok(Self::err("option requires an argument -- 'w'", 1)); } width = ctx.args[i].parse().unwrap_or(80); } @@ -66,10 +67,7 @@ impl Builtin for Fold { match read_text_file(ctx.fs.as_ref(), &path, "fold").await { Ok(text) => buf.push_str(&text), Err(_) => { - return Ok(ExecResult::err( - format!("fold: {}: No such file or directory\n", file), - 1, - )); + return Ok(Self::err_path(file, "No such file or directory", 1)); } } } diff --git a/crates/bashkit/src/builtins/helpers.rs b/crates/bashkit/src/builtins/helpers.rs new file mode 100644 index 000000000..dad48d49c --- /dev/null +++ b/crates/bashkit/src/builtins/helpers.rs @@ -0,0 +1,75 @@ +//! Shared helpers for builtin commands. +//! +//! Builtins implement [`BuiltinHelper`] on their unit struct (e.g. +//! `impl BuiltinHelper for Echo { const NAME: &'static str = "echo"; }`) +//! and inherit consistent help/version handling and error formatting from +//! the trait's default methods. +//! +//! Error helpers produce real-shell-style messages (`{name}: {msg}\n`) and +//! use `Display`, never `Debug`, satisfying TM-INF-022. + +use super::check_help_version; +use crate::interpreter::ExecResult; + +/// Default helpers shared by builtin commands. +/// +/// Each impl binds the command name to the type, removing the need to +/// repeat it in every `format!("{cmd}: ...")` call site. All methods are +/// associated functions; call them as `Self::err(...)` from inside +/// `impl Builtin for X` (where `Self = X`) once the trait is in scope. +pub(crate) trait BuiltinHelper { + /// Canonical command name as it appears in error messages. + const NAME: &'static str; + + /// Standard `--help` / `--version` dispatch. + /// + /// Returns `Some(result)` when one of the flags is present; the + /// caller should propagate it as `return Ok(r)`. + fn check_help(args: &[String], help_text: &str, version: Option<&str>) -> Option { + check_help_version(args, help_text, version) + } + + /// Format `{NAME}: {msg}\n` with the given exit code. + fn err(msg: impl AsRef, code: i32) -> ExecResult { + ExecResult::err(format!("{}: {}\n", Self::NAME, msg.as_ref()), code) + } + + /// Format `{NAME}: {path}: {msg}\n` for path-qualified errors. + fn err_path(path: impl std::fmt::Display, msg: impl AsRef, code: i32) -> ExecResult { + ExecResult::err( + format!("{}: {}: {}\n", Self::NAME, path, msg.as_ref()), + code, + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct Demo; + impl BuiltinHelper for Demo { + const NAME: &'static str = "demo"; + } + + #[test] + fn err_prefixes_name() { + let r = Demo::err("boom", 2); + assert_eq!(r.stderr, "demo: boom\n"); + assert_eq!(r.exit_code, 2); + } + + #[test] + fn err_path_includes_path() { + let r = Demo::err_path("/etc/foo", "permission denied", 1); + assert_eq!(r.stderr, "demo: /etc/foo: permission denied\n"); + } + + #[test] + fn check_help_delegates() { + let args = vec!["--help".to_string()]; + let r = Demo::check_help(&args, "usage text\n", Some("demo 0.1")); + assert!(r.is_some()); + assert_eq!(r.unwrap().stdout, "usage text\n"); + } +} diff --git a/crates/bashkit/src/builtins/iconv.rs b/crates/bashkit/src/builtins/iconv.rs index 89d93febd..7fee821ec 100644 --- a/crates/bashkit/src/builtins/iconv.rs +++ b/crates/bashkit/src/builtins/iconv.rs @@ -10,13 +10,17 @@ use async_trait::async_trait; -use super::{Builtin, Context, resolve_path}; +use super::{Builtin, BuiltinHelper, Context, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; /// iconv builtin - character encoding conversion. pub struct Iconv; +impl BuiltinHelper for Iconv { + const NAME: &'static str = "iconv"; +} + /// Parse an encoding spec like "ascii//translit" into (encoding, translit). fn parse_encoding_spec(spec: &str) -> (Option<&'static str>, bool) { let (name, translit) = if let Some(pos) = spec.find("//") { @@ -226,7 +230,7 @@ fn decode_from(input: &[u8], encoding: &str) -> std::result::Result) -> Result { - if let Some(r) = super::check_help_version( + if let Some(r) = Self::check_help( ctx.args, "Usage: iconv [OPTION]... [FILE]\nConvert text from one character encoding to another.\n\n -f ENCODING\tconvert characters from ENCODING\n -t ENCODING\tconvert characters to ENCODING (supports //TRANSLIT)\n -l, --list\tlist known coded character sets\n --help\t\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("iconv (bashkit) 0.1"), @@ -247,28 +251,19 @@ impl Builtin for Iconv { "-f" => { i += 1; if i >= ctx.args.len() { - return Ok(ExecResult::err( - "iconv: option '-f' requires an argument\n".to_string(), - 1, - )); + return Ok(Self::err("option '-f' requires an argument", 1)); } from_enc = Some(ctx.args[i].clone()); } "-t" => { i += 1; if i >= ctx.args.len() { - return Ok(ExecResult::err( - "iconv: option '-t' requires an argument\n".to_string(), - 1, - )); + return Ok(Self::err("option '-t' requires an argument", 1)); } to_enc = Some(ctx.args[i].clone()); } arg if arg.starts_with('-') => { - return Ok(ExecResult::err( - format!("iconv: unknown option '{arg}'\n"), - 1, - )); + return Ok(Self::err(format!("unknown option '{arg}'"), 1)); } _ => { file_arg = Some(ctx.args[i].clone()); @@ -289,37 +284,17 @@ impl Builtin for Iconv { let from = match &from_enc { Some(f) => match normalize_encoding(f) { Some(e) => e, - None => { - return Ok(ExecResult::err( - format!("iconv: unsupported encoding '{}'\n", f), - 1, - )); - } + None => return Ok(Self::err(format!("unsupported encoding '{}'", f), 1)), }, - None => { - return Ok(ExecResult::err( - "iconv: missing source encoding (-f)\n".to_string(), - 1, - )); - } + None => return Ok(Self::err("missing source encoding (-f)", 1)), }; let (to, to_translit) = match &to_enc { Some(t) => match parse_encoding_spec(t) { (Some(e), translit) => (e, translit), - (None, _) => { - return Ok(ExecResult::err( - format!("iconv: unsupported encoding '{}'\n", t), - 1, - )); - } + (None, _) => return Ok(Self::err(format!("unsupported encoding '{}'", t), 1)), }, - None => { - return Ok(ExecResult::err( - "iconv: missing target encoding (-t)\n".to_string(), - 1, - )); - } + None => return Ok(Self::err("missing target encoding (-t)", 1)), }; // Read input from file or stdin @@ -327,15 +302,12 @@ impl Builtin for Iconv { let path = resolve_path(ctx.cwd, file); match ctx.fs.read_file(&path).await { Ok(bytes) => bytes, - Err(e) => return Ok(ExecResult::err(format!("iconv: {}: {e}\n", file), 1)), + Err(e) => return Ok(Self::err_path(file, e.to_string(), 1)), } } else if let Some(stdin) = ctx.stdin { stdin.as_bytes().to_vec() } else { - return Ok(ExecResult::err( - "iconv: no input (provide file argument or pipe stdin)\n".to_string(), - 1, - )); + return Ok(Self::err("no input (provide file argument or pipe stdin)", 1)); }; // Decode from source encoding to UTF-8 string diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 0531fd3cc..59d1e532c 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -116,6 +116,9 @@ mod zip_cmd; /// Shared by printf and AWK builtins. pub(crate) const MAX_FORMAT_WIDTH: usize = 10_000; +mod helpers; +pub(crate) use helpers::BuiltinHelper; + pub(crate) mod git; pub(crate) mod ssh; diff --git a/crates/bashkit/src/builtins/sleep.rs b/crates/bashkit/src/builtins/sleep.rs index 6e9850a03..1ae7f83a6 100644 --- a/crates/bashkit/src/builtins/sleep.rs +++ b/crates/bashkit/src/builtins/sleep.rs @@ -3,7 +3,7 @@ use async_trait::async_trait; use std::time::Duration; -use super::{Builtin, Context}; +use super::{Builtin, BuiltinHelper, Context}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -18,10 +18,14 @@ const MAX_SLEEP_SECONDS: f64 = 60.0; /// Maximum duration is capped at 60 seconds for safety. pub struct Sleep; +impl BuiltinHelper for Sleep { + const NAME: &'static str = "sleep"; +} + #[async_trait] impl Builtin for Sleep { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + if let Some(r) = Self::check_help( ctx.args, "Usage: sleep SECONDS\nPause for SECONDS seconds.\nSECONDS may be a floating-point number. Maximum duration is 60 seconds.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("sleep (bashkit) 0.1"), @@ -31,10 +35,7 @@ impl Builtin for Sleep { let seconds = match ctx.args.first() { Some(arg) => match arg.parse::() { Ok(s) if s < 0.0 => { - return Ok(ExecResult::err( - format!("sleep: invalid time interval '{}'\n", arg), - 1, - )); + return Ok(Self::err(format!("invalid time interval '{}'", arg), 1)); } Ok(s) => s.min(MAX_SLEEP_SECONDS), Err(_) => { @@ -42,15 +43,12 @@ impl Builtin for Sleep { if arg.parse::().is_ok() { arg.parse::().unwrap_or(0.0).min(MAX_SLEEP_SECONDS) } else { - return Ok(ExecResult::err( - format!("sleep: invalid time interval '{}'\n", arg), - 1, - )); + return Ok(Self::err(format!("invalid time interval '{}'", arg), 1)); } } }, None => { - return Ok(ExecResult::err("sleep: missing operand\n".to_string(), 1)); + return Ok(Self::err("missing operand", 1)); } }; diff --git a/crates/bashkit/src/builtins/yes.rs b/crates/bashkit/src/builtins/yes.rs index cf024cca5..6b51b4752 100644 --- a/crates/bashkit/src/builtins/yes.rs +++ b/crates/bashkit/src/builtins/yes.rs @@ -2,7 +2,7 @@ use async_trait::async_trait; -use super::{Builtin, Context}; +use super::{Builtin, BuiltinHelper, Context}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -14,6 +14,10 @@ use crate::interpreter::ExecResult; /// In bashkit, output is limited to avoid infinite loops. pub struct Yes; +impl BuiltinHelper for Yes { + const NAME: &'static str = "yes"; +} + /// Maximum number of lines to output (safety limit) const MAX_LINES: usize = 10_000; /// Maximum stdout bytes produced by yes. @@ -49,7 +53,7 @@ fn build_yes_output(text: &str) -> String { #[async_trait] impl Builtin for Yes { async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( + if let Some(r) = Self::check_help( ctx.args, "Usage: yes [STRING]\nRepeatedly output a line with STRING, or 'y'.\n\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", Some("yes (bashkit) 0.1"), From 6f471e00f91e59ff5339f4ed2123111c4d7064cc Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 25 May 2026 21:17:01 +0000 Subject: [PATCH 2/3] refactor(builtins): centralize per-builtin DoS limits in limits.rs Gathers the security-relevant MAX_* / MKTEMP_* / TIMEOUT_* constants that were scattered across individual builtin source files into crates/bashkit/src/builtins/limits.rs. Each builtin now imports the shared constant under its existing local alias, so the call sites are unchanged. Threat-model-referenced limits (TM-DOS-027/028/051/052/059, etc.) are now reviewable in a single file. Tunable runtime limits that are part of public config types (PythonLimits, TypeScriptLimits, SqliteLimits, ssh defaults) are intentionally left alone. --- crates/bashkit/src/builtins/archive.rs | 5 +- crates/bashkit/src/builtins/awk.rs | 27 +++++----- crates/bashkit/src/builtins/curl.rs | 3 +- crates/bashkit/src/builtins/dirstack.rs | 4 +- crates/bashkit/src/builtins/fileops.rs | 3 +- crates/bashkit/src/builtins/limits.rs | 68 +++++++++++++++++++++++++ crates/bashkit/src/builtins/mod.rs | 7 ++- crates/bashkit/src/builtins/numfmt.rs | 4 +- crates/bashkit/src/builtins/parallel.rs | 5 +- crates/bashkit/src/builtins/printf.rs | 3 +- crates/bashkit/src/builtins/retry.rs | 5 +- crates/bashkit/src/builtins/sed.rs | 3 +- crates/bashkit/src/builtins/sleep.rs | 4 +- crates/bashkit/src/builtins/template.rs | 5 +- crates/bashkit/src/builtins/timeout.rs | 3 +- crates/bashkit/src/builtins/yaml.rs | 5 +- crates/bashkit/src/builtins/yes.rs | 7 +-- 17 files changed, 98 insertions(+), 63 deletions(-) create mode 100644 crates/bashkit/src/builtins/limits.rs diff --git a/crates/bashkit/src/builtins/archive.rs b/crates/bashkit/src/builtins/archive.rs index 37a9ae121..5fe1be4af 100644 --- a/crates/bashkit/src/builtins/archive.rs +++ b/crates/bashkit/src/builtins/archive.rs @@ -16,14 +16,11 @@ use flate2::write::GzEncoder; use std::io::{Read, Write}; use std::path::Path; +use super::limits::ARCHIVE_MAX_DECOMPRESSION_RATIO as MAX_DECOMPRESSION_RATIO; use super::{Builtin, Context, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; -/// Maximum decompression ratio to detect zip bombs. -/// A ratio over 100:1 is suspicious. -const MAX_DECOMPRESSION_RATIO: usize = 100; - /// Read from a decoder with size limits to prevent zip bombs. /// /// Returns an error if the decompressed size exceeds the limit or diff --git a/crates/bashkit/src/builtins/awk.rs b/crates/bashkit/src/builtins/awk.rs index b43c93be4..43d51f024 100644 --- a/crates/bashkit/src/builtins/awk.rs +++ b/crates/bashkit/src/builtins/awk.rs @@ -23,6 +23,12 @@ use super::search_common::build_regex; use std::path::PathBuf; use std::sync::Arc; +use super::limits::{ + AWK_MAX_CALL_DEPTH as MAX_AWK_CALL_DEPTH, + AWK_MAX_GETLINE_CACHED_FILES as MAX_GETLINE_CACHED_FILES, + AWK_MAX_OUTPUT_BYTES as MAX_AWK_OUTPUT_BYTES, + AWK_MAX_PARSER_DEPTH as MAX_AWK_PARSER_DEPTH, +}; use super::{Builtin, Context, read_text_file}; use crate::error::{Error, Result}; use crate::fs::FileSystem; @@ -362,11 +368,8 @@ impl AwkState { } /// THREAT[TM-DOS-027]: Maximum recursion depth for awk expression parser. -/// Prevents stack overflow from deeply nested expressions like `(((((...)))))` -/// or deeply chained unary operators like `- - - - - x`. -/// Set conservatively: each recursion level uses ~1-2KB stack in debug mode. -/// 100 levels × ~2KB = ~200KB, well within typical stack limits. -const MAX_AWK_PARSER_DEPTH: usize = 100; +// Parser-depth limit moved to `super::limits::AWK_MAX_PARSER_DEPTH` +// (TM-DOS guard against deeply nested expressions). /// Preprocess awk program: replace newlines with semicolons inside action blocks. /// This makes newlines act as statement separators per POSIX awk spec. @@ -2079,16 +2082,10 @@ enum AwkFlow { Return(AwkValue), // Return from user-defined function } -/// THREAT[TM-DOS-027]: Maximum recursion depth for awk user-defined function calls. -const MAX_AWK_CALL_DEPTH: usize = 64; - -/// THREAT[TM-DOS-027]: Maximum total AWK output size (stdout + stderr + file redirects) -/// to prevent memory exhaustion. 10 MB. -const MAX_AWK_OUTPUT_BYTES: usize = 10_000_000; - -/// THREAT[TM-DOS-028]: Maximum number of distinct files cached by `getline var < file`. -/// Prevents memory exhaustion from opening hundreds of large files. -const MAX_GETLINE_CACHED_FILES: usize = 100; +// Awk runtime limits (TM-DOS-027, TM-DOS-028) live in `super::limits`: +// - AWK_MAX_CALL_DEPTH (user-function recursion) +// - AWK_MAX_OUTPUT_BYTES (total stdout+stderr+file redirects, 10 MB) +// - AWK_MAX_GETLINE_CACHED_FILES (distinct files held open by `getline`). struct AwkInterpreter { state: AwkState, diff --git a/crates/bashkit/src/builtins/curl.rs b/crates/bashkit/src/builtins/curl.rs index 62a4c2dd6..dfda09947 100644 --- a/crates/bashkit/src/builtins/curl.rs +++ b/crates/bashkit/src/builtins/curl.rs @@ -14,6 +14,8 @@ use async_trait::async_trait; +#[cfg(feature = "http_client")] +use super::limits::CURL_MAX_REDIRECTS as MAX_REDIRECTS; use super::resolve_path; use super::{Builtin, Context}; use crate::error::Result; @@ -479,7 +481,6 @@ async fn execute_curl_request( let mut current_headers = header_pairs.clone(); let mut current_url = url.to_string(); let mut redirect_count = 0; - const MAX_REDIRECTS: u32 = 10; loop { if verbose { diff --git a/crates/bashkit/src/builtins/dirstack.rs b/crates/bashkit/src/builtins/dirstack.rs index 888b43554..65e48b11f 100644 --- a/crates/bashkit/src/builtins/dirstack.rs +++ b/crates/bashkit/src/builtins/dirstack.rs @@ -5,13 +5,11 @@ use async_trait::async_trait; use std::path::PathBuf; +use super::limits::DIRSTACK_MAX_SIZE as MAX_DIRSTACK_SIZE; use super::{Builtin, Context}; use crate::error::Result; use crate::interpreter::ExecResult; -// Security decision: bound mutable `_DIRSTACK_SIZE` to avoid unbounded builtin loops. -const MAX_DIRSTACK_SIZE: usize = 4096; - fn get_stack_size(ctx: &Context<'_>) -> usize { ctx.variables .get("_DIRSTACK_SIZE") diff --git a/crates/bashkit/src/builtins/fileops.rs b/crates/bashkit/src/builtins/fileops.rs index ad7c74984..59eb6d813 100644 --- a/crates/bashkit/src/builtins/fileops.rs +++ b/crates/bashkit/src/builtins/fileops.rs @@ -7,6 +7,7 @@ use chrono::{Datelike, Local, LocalResult, NaiveDate, TimeZone}; use std::path::Path; use std::time::SystemTime; +use super::limits::MKTEMP_MAX_ATTEMPTS; use super::{Builtin, Context, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -849,8 +850,6 @@ impl Builtin for Kill { /// -q, --quiet Suppress error diagnostics on failure. pub struct Mktemp; -const MKTEMP_MAX_ATTEMPTS: usize = 64; - fn mktemp_suffix_for_attempt(attempt: usize) -> String { use std::collections::hash_map::RandomState; use std::hash::{BuildHasher, Hasher}; diff --git a/crates/bashkit/src/builtins/limits.rs b/crates/bashkit/src/builtins/limits.rs new file mode 100644 index 000000000..fab92dd71 --- /dev/null +++ b/crates/bashkit/src/builtins/limits.rs @@ -0,0 +1,68 @@ +//! Centralized DoS / resource caps for individual builtins. +//! +//! These constants are referenced by the threat model (TM-DOS-*). +//! Keeping them in one place makes auditing easier: every adjustment +//! lands in a single file, so a reviewer can read all per-builtin +//! limits without grepping the tree. +//! +//! Tunable runtime limits that are part of a public config surface +//! (e.g. `PythonLimits`, `TypeScriptLimits`, `SqliteLimits`, ssh +//! defaults) live on their own config types and are intentionally +//! NOT mirrored here. + +/// Max width/precision for printf-style format specifiers to prevent +/// memory exhaustion. Shared by `printf` and `awk`. +pub(crate) const MAX_FORMAT_WIDTH: usize = 10_000; + +/// archive: cap on decompression expansion ratio (zip-bomb guard). +pub(crate) const ARCHIVE_MAX_DECOMPRESSION_RATIO: usize = 100; + +/// awk: max parser recursion depth. +pub(crate) const AWK_MAX_PARSER_DEPTH: usize = 100; +/// awk: max user-function call depth at runtime. +pub(crate) const AWK_MAX_CALL_DEPTH: usize = 64; +/// awk: total output byte cap per invocation. +pub(crate) const AWK_MAX_OUTPUT_BYTES: usize = 10_000_000; +/// awk: max distinct files held open by `getline < file`. +pub(crate) const AWK_MAX_GETLINE_CACHED_FILES: usize = 100; + +/// curl: max number of HTTP redirects to follow. +#[cfg(feature = "http_client")] +pub(crate) const CURL_MAX_REDIRECTS: u32 = 10; + +/// dirs/pushd/popd: max entries on the directory stack. +pub(crate) const DIRSTACK_MAX_SIZE: usize = 4096; + +/// mktemp: max name-collision retries before giving up. +pub(crate) const MKTEMP_MAX_ATTEMPTS: usize = 64; + +/// numfmt: total output / padding / precision cap. +pub(crate) const NUMFMT_MAX_OUTPUT_BYTES: usize = 1_048_576; + +/// parallel: cap on Cartesian product expansion. +pub(crate) const PARALLEL_MAX_CARTESIAN_PRODUCT: usize = 100_000; + +/// printf: max diagnostic message length. +pub(crate) const PRINTF_MAX_DIAG_CHARS: usize = 1_024; + +/// retry: max retry attempts. +pub(crate) const RETRY_MAX_ATTEMPTS: u32 = 10_000; + +/// sed: max group-nesting depth in `s` replacements. +pub(crate) const SED_MAX_GROUP_NESTING_DEPTH: usize = 128; + +/// sleep: max sleep duration. +pub(crate) const SLEEP_MAX_SECONDS: f64 = 60.0; + +/// template: max template-expansion recursion depth. +pub(crate) const TEMPLATE_MAX_DEPTH: usize = 100; + +/// timeout: max timeout duration in seconds (5 minutes). +pub(crate) const TIMEOUT_MAX_SECONDS: u64 = 300; + +/// yaml: max nesting depth. +pub(crate) const YAML_MAX_DEPTH: usize = 100; + +/// yes: max lines and total output bytes per invocation. +pub(crate) const YES_MAX_LINES: usize = 10_000; +pub(crate) const YES_MAX_OUTPUT_BYTES: usize = 1_048_576; diff --git a/crates/bashkit/src/builtins/mod.rs b/crates/bashkit/src/builtins/mod.rs index 59d1e532c..9ebd17a7d 100644 --- a/crates/bashkit/src/builtins/mod.rs +++ b/crates/bashkit/src/builtins/mod.rs @@ -112,13 +112,12 @@ mod yaml; mod yes; mod zip_cmd; -/// Max width/precision for printf-style format specifiers to prevent memory exhaustion. -/// Shared by printf and AWK builtins. -pub(crate) const MAX_FORMAT_WIDTH: usize = 10_000; - mod helpers; pub(crate) use helpers::BuiltinHelper; +pub(crate) mod limits; +pub(crate) use limits::MAX_FORMAT_WIDTH; + pub(crate) mod git; pub(crate) mod ssh; diff --git a/crates/bashkit/src/builtins/numfmt.rs b/crates/bashkit/src/builtins/numfmt.rs index 3f0b18163..17b0050b5 100644 --- a/crates/bashkit/src/builtins/numfmt.rs +++ b/crates/bashkit/src/builtins/numfmt.rs @@ -5,13 +5,11 @@ use async_trait::async_trait; +use super::limits::NUMFMT_MAX_OUTPUT_BYTES as MAX_OUTPUT_BYTES; use super::{Builtin, Context}; use crate::error::Result; use crate::interpreter::ExecResult; -/// Maximum output size to prevent memory exhaustion. -/// THREAT[TM-DOS-059]: Bound numfmt output -const MAX_OUTPUT_BYTES: usize = 1_048_576; const MAX_PADDING_WIDTH: usize = MAX_OUTPUT_BYTES; const MAX_FORMAT_PRECISION: usize = MAX_OUTPUT_BYTES; diff --git a/crates/bashkit/src/builtins/parallel.rs b/crates/bashkit/src/builtins/parallel.rs index a1e5b6ebe..8b9e234ff 100644 --- a/crates/bashkit/src/builtins/parallel.rs +++ b/crates/bashkit/src/builtins/parallel.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; +use super::limits::PARALLEL_MAX_CARTESIAN_PRODUCT as MAX_CARTESIAN_PRODUCT; use super::{Builtin, Context}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -106,10 +107,6 @@ fn parse_parallel_args(args: &[String]) -> std::result::Result Result<(Option
, &str)> { Ok((None, s)) } -const MAX_GROUP_NESTING_DEPTH: usize = 128; - fn validate_group_nesting(s: &str, base_depth: usize) -> Result<()> { let mut in_subst = false; let mut subst_delim = None; diff --git a/crates/bashkit/src/builtins/sleep.rs b/crates/bashkit/src/builtins/sleep.rs index 1ae7f83a6..fa03a2be7 100644 --- a/crates/bashkit/src/builtins/sleep.rs +++ b/crates/bashkit/src/builtins/sleep.rs @@ -3,13 +3,11 @@ use async_trait::async_trait; use std::time::Duration; +use super::limits::SLEEP_MAX_SECONDS as MAX_SLEEP_SECONDS; use super::{Builtin, BuiltinHelper, Context}; use crate::error::Result; use crate::interpreter::ExecResult; -/// Maximum sleep duration in seconds (safety limit) -const MAX_SLEEP_SECONDS: f64 = 60.0; - /// The sleep builtin - pause execution for a specified number of seconds. /// /// Usage: sleep SECONDS diff --git a/crates/bashkit/src/builtins/template.rs b/crates/bashkit/src/builtins/template.rs index 5f67cef75..b8d28246f 100644 --- a/crates/bashkit/src/builtins/template.rs +++ b/crates/bashkit/src/builtins/template.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; +use super::limits::TEMPLATE_MAX_DEPTH as MAX_TEMPLATE_DEPTH; use super::{Builtin, Context, read_text_file, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -139,10 +140,6 @@ fn is_truthy( false } -// THREAT[TM-DOS-052]: Maximum recursion depth for template rendering. -// Prevents stack overflow from deeply nested {{#if}}/{{#each}} blocks. -const MAX_TEMPLATE_DEPTH: usize = 100; - /// Render a template string with the given data sources. fn render_template( template: &str, diff --git a/crates/bashkit/src/builtins/timeout.rs b/crates/bashkit/src/builtins/timeout.rs index 1ced808eb..8474e3877 100644 --- a/crates/bashkit/src/builtins/timeout.rs +++ b/crates/bashkit/src/builtins/timeout.rs @@ -6,6 +6,7 @@ use async_trait::async_trait; use std::time::Duration; +use super::limits::TIMEOUT_MAX_SECONDS as MAX_TIMEOUT_SECONDS; use super::{Builtin, Context, ExecutionPlan, SubCommand}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -38,8 +39,6 @@ use crate::interpreter::ExecResult; /// for safety. pub struct Timeout; -const MAX_TIMEOUT_SECONDS: u64 = 300; // 5 minutes max - /// Parse a duration string like "30", "30s", "5m", "1h", "1d" pub(crate) fn parse_duration(s: &str) -> Option { let s = s.trim(); diff --git a/crates/bashkit/src/builtins/yaml.rs b/crates/bashkit/src/builtins/yaml.rs index a55a9b331..2f2a3353c 100644 --- a/crates/bashkit/src/builtins/yaml.rs +++ b/crates/bashkit/src/builtins/yaml.rs @@ -11,6 +11,7 @@ use async_trait::async_trait; +use super::limits::YAML_MAX_DEPTH as MAX_YAML_DEPTH; use super::{Builtin, Context, read_text_file, resolve_path}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -184,10 +185,6 @@ fn indent_level(line: &str) -> usize { line.len() - line.trim_start().len() } -// THREAT[TM-DOS-051]: Maximum recursion depth for YAML parsing. -// Prevents stack overflow from deeply nested YAML documents. -const MAX_YAML_DEPTH: usize = 100; - /// Parse YAML content into a value. /// Supports maps, lists, and scalars with 2-space indentation. fn parse_yaml(content: &str) -> YamlValue { diff --git a/crates/bashkit/src/builtins/yes.rs b/crates/bashkit/src/builtins/yes.rs index 6b51b4752..5053cc9a3 100644 --- a/crates/bashkit/src/builtins/yes.rs +++ b/crates/bashkit/src/builtins/yes.rs @@ -2,6 +2,7 @@ use async_trait::async_trait; +use super::limits::{YES_MAX_LINES as MAX_LINES, YES_MAX_OUTPUT_BYTES as MAX_OUTPUT_BYTES}; use super::{Builtin, BuiltinHelper, Context}; use crate::error::Result; use crate::interpreter::ExecResult; @@ -18,12 +19,6 @@ impl BuiltinHelper for Yes { const NAME: &'static str = "yes"; } -/// Maximum number of lines to output (safety limit) -const MAX_LINES: usize = 10_000; -/// Maximum stdout bytes produced by yes. -/// THREAT[TM-DOS-059]: Bound repeated output to prevent memory exhaustion. -const MAX_OUTPUT_BYTES: usize = 1_048_576; - fn truncate_to_char_boundary(s: &str, max_bytes: usize) -> &str { if s.len() <= max_bytes { return s; From ad6f3e8d50037c6f7478edaeb7b87c1a2170582b Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Mon, 25 May 2026 21:37:17 +0000 Subject: [PATCH 3/3] refactor(builtins): split ls.rs and awk.rs into directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ls/ (3824 lines → 5 production files + tests): - list.rs: Ls + column/long-format helpers - find.rs: Find + parse_find_args + find_recursive + printf - rmdir.rs: Rmdir - glob.rs: glob_match - tests.rs: all 73 tests awk/ (4731 lines → 3 production files + tests): - mod.rs: Awk + Builtin impl + AST types + AwkState + AwkValue + preprocessing (everything except parser and interpreter) - parser.rs: AwkParser (1610 lines) - interpreter.rs: AwkInterpreter + AwkFlow (1287 lines) - tests.rs: all 87 tests No behavioural changes. Internals exposed across sibling modules via pub(super). Existing test count and outcomes are unchanged (2284 passed, same pre-existing rg differential failure as baseline). --- crates/bashkit/src/builtins/awk.rs | 4731 ----------------- .../bashkit/src/builtins/awk/interpreter.rs | 1308 +++++ crates/bashkit/src/builtins/awk/mod.rs | 723 +++ crates/bashkit/src/builtins/awk/parser.rs | 1619 ++++++ crates/bashkit/src/builtins/awk/tests.rs | 1109 ++++ crates/bashkit/src/builtins/iconv.rs | 5 +- crates/bashkit/src/builtins/ls.rs | 3824 ------------- crates/bashkit/src/builtins/ls/find.rs | 596 +++ crates/bashkit/src/builtins/ls/glob.rs | 44 + crates/bashkit/src/builtins/ls/list.rs | 480 ++ crates/bashkit/src/builtins/ls/mod.rs | 14 + crates/bashkit/src/builtins/ls/rmdir.rs | 108 + crates/bashkit/src/builtins/ls/tests.rs | 2620 +++++++++ 13 files changed, 8625 insertions(+), 8556 deletions(-) delete mode 100644 crates/bashkit/src/builtins/awk.rs create mode 100644 crates/bashkit/src/builtins/awk/interpreter.rs create mode 100644 crates/bashkit/src/builtins/awk/mod.rs create mode 100644 crates/bashkit/src/builtins/awk/parser.rs create mode 100644 crates/bashkit/src/builtins/awk/tests.rs delete mode 100644 crates/bashkit/src/builtins/ls.rs create mode 100644 crates/bashkit/src/builtins/ls/find.rs create mode 100644 crates/bashkit/src/builtins/ls/glob.rs create mode 100644 crates/bashkit/src/builtins/ls/list.rs create mode 100644 crates/bashkit/src/builtins/ls/mod.rs create mode 100644 crates/bashkit/src/builtins/ls/rmdir.rs create mode 100644 crates/bashkit/src/builtins/ls/tests.rs diff --git a/crates/bashkit/src/builtins/awk.rs b/crates/bashkit/src/builtins/awk.rs deleted file mode 100644 index 43d51f024..000000000 --- a/crates/bashkit/src/builtins/awk.rs +++ /dev/null @@ -1,4731 +0,0 @@ -//! awk - Pattern scanning and processing builtin -//! -//! Implements basic AWK functionality. -//! -//! Usage: -//! awk '{print $1}' file -//! awk -F: '{print $1}' /etc/passwd -//! echo "a b c" | awk '{print $2}' -//! awk 'BEGIN{print "start"} {print} END{print "end"}' file -//! awk '/pattern/{print}' file -//! awk 'NR==2{print}' file - -// Parser invariant: `pos` is always a byte offset on a UTF-8 char boundary. -// Move across user-controlled text with `advance()`/`consume_while()`, and use -// raw `pos += N` only for known ASCII tokens and delimiters. -#![allow(clippy::unwrap_used)] - -use async_trait::async_trait; -use regex::Regex; -use std::collections::HashMap; - -use super::search_common::build_regex; -use std::path::PathBuf; -use std::sync::Arc; - -use super::limits::{ - AWK_MAX_CALL_DEPTH as MAX_AWK_CALL_DEPTH, - AWK_MAX_GETLINE_CACHED_FILES as MAX_GETLINE_CACHED_FILES, - AWK_MAX_OUTPUT_BYTES as MAX_AWK_OUTPUT_BYTES, - AWK_MAX_PARSER_DEPTH as MAX_AWK_PARSER_DEPTH, -}; -use super::{Builtin, Context, read_text_file}; -use crate::error::{Error, Result}; -use crate::fs::FileSystem; -use crate::interpreter::ExecResult; -use crate::limits::ExecutionLimits; - -/// awk command - pattern scanning and processing -pub struct Awk; - -#[derive(Debug)] -struct AwkProgram { - begin_actions: Vec, - main_rules: Vec, - end_actions: Vec, - functions: HashMap, -} - -#[derive(Debug, Clone)] -struct AwkFunctionDef { - params: Vec, - body: Vec, -} - -#[derive(Debug)] -struct AwkRule { - pattern: Option, - actions: Vec, -} - -#[derive(Debug)] -enum AwkPattern { - Regex(Regex), - Expression(AwkExpr), - /// Range pattern: /start/,/end/ — matches from start to end inclusive. - /// Each sub-pattern can be a Regex or Expression. - Range(Box, Box), -} - -#[derive(Debug, Clone)] -enum AwkExpr { - Number(f64), - String(String), - Field(Box), // $n - Variable(String), // var - BinOp(Box, String, Box), - UnaryOp(String, Box), - Assign(String, Box), - ArrayAssign(String, Box, Box), // arr[key] = val - CompoundArrayAssign(String, Box, String, Box), // arr[key] += val - Concat(Vec), - FuncCall(String, Vec), - Regex(String), - #[allow(dead_code)] // matched in eval but construction deferred to pattern expansion - Match(Box, String), // expr ~ /pattern/ - PostIncrement(String), // var++ - PostDecrement(String), // var-- - PreIncrement(String), // ++var - PreDecrement(String), // --var - InArray(Box, String), // key in arr - FieldAssign(Box, Box), // $n = val - /// getline [var] < file as expression — returns 1 on success, 0 on EOF, -1 on error - GetlineFile { - var: Option, - file: Box, - }, -} - -/// Output target for print/printf redirection (e.g., `> file`, `>> file`). -/// Pipe (`| cmd`) is not supported and returns a clear error. -#[derive(Debug, Clone)] -enum AwkOutputTarget { - /// Truncate/create file: `> file` - Truncate(AwkExpr), - /// Append to file: `>> file` - Append(AwkExpr), -} - -#[derive(Debug, Clone)] -enum AwkAction { - Print(Vec, Option), - Printf(AwkExpr, Vec, Option), - Assign(String, AwkExpr), - ArrayAssign(String, AwkExpr, AwkExpr), // arr[key] = val - If(AwkExpr, Vec, Vec), - While(AwkExpr, Vec), - DoWhile(AwkExpr, Vec), - For(Box, AwkExpr, Box, Vec), - ForIn(String, String, Vec), // for (key in arr) { body } - Next, - Break, - Continue, - Delete(String, AwkExpr), // delete arr[key] - Getline, // getline — read next input record into $0 - /// getline [var] < file — read next line from file - GetlineFile { - var: Option, - file: AwkExpr, - }, - Exit(Option), - Return(Option), - Expression(AwkExpr), -} - -struct AwkState { - variables: HashMap, - fields: Vec, - fs: String, - ofs: String, - ors: String, - nr: usize, - nf: usize, - fnr: usize, - /// When true, fields are split per RFC 4180 CSV rules (--csv flag) - csv_mode: bool, -} - -#[derive(Debug, Clone, PartialEq)] -enum AwkValue { - Number(f64), - String(String), - Uninitialized, -} - -/// Format number using AWK's OFMT (%.6g): 6 significant digits, trim trailing zeros. -fn format_awk_number(n: f64) -> String { - if n.is_nan() { - return "nan".to_string(); - } - if n.is_infinite() { - return if n > 0.0 { "inf" } else { "-inf" }.to_string(); - } - // Integers: no decimal point - if n.fract() == 0.0 && n.abs() < 1e16 { - return format!("{}", n as i64); - } - // %.6g: use 6 significant digits - let abs = n.abs(); - let exp = abs.log10().floor() as i32; - if !(-4..6).contains(&exp) { - // Scientific notation: 5 decimal places = 6 sig digits - let mut s = format!("{:.*e}", 5, n); - // Trim trailing zeros in mantissa - if let Some(e_pos) = s.find('e') { - let (mantissa, exp_part) = s.split_at(e_pos); - let trimmed = mantissa.trim_end_matches('0').trim_end_matches('.'); - s = format!("{}{}", trimmed, exp_part); - } - // Normalize exponent format: e1 -> e+01 etc. to match C printf - // Actually AWK uses e+06 style. Rust uses e6. Fix: - if let Some(e_pos) = s.find('e') { - let exp_str = &s[e_pos + 1..]; - let exp_val: i32 = exp_str.parse().unwrap_or(0); - let mantissa = &s[..e_pos]; - s = format!("{}e{:+03}", mantissa, exp_val); - } - s - } else { - // Fixed notation - let decimal_places = (5 - exp).max(0) as usize; - let mut s = format!("{:.*}", decimal_places, n); - if s.contains('.') { - s = s.trim_end_matches('0').trim_end_matches('.').to_string(); - } - s - } -} - -impl AwkValue { - fn as_number(&self) -> f64 { - match self { - AwkValue::Number(n) => *n, - AwkValue::String(s) => s.parse().unwrap_or(0.0), - AwkValue::Uninitialized => 0.0, - } - } - - fn as_string(&self) -> String { - match self { - AwkValue::Number(n) => format_awk_number(*n), - AwkValue::String(s) => s.clone(), - AwkValue::Uninitialized => String::new(), - } - } - - fn as_bool(&self) -> bool { - match self { - AwkValue::Number(n) => *n != 0.0, - AwkValue::String(s) => { - if s.is_empty() { - return false; - } - // In awk, numeric strings evaluate as numbers in boolean context - if let Ok(n) = s.parse::() { - n != 0.0 - } else { - true - } - } - AwkValue::Uninitialized => false, - } - } -} - -impl Default for AwkState { - fn default() -> Self { - let mut variables = HashMap::new(); - // POSIX SUBSEP: subscript separator for multi-dimensional arrays - variables.insert("SUBSEP".to_string(), AwkValue::String("\x1c".to_string())); - Self { - variables, - fields: Vec::new(), - fs: " ".to_string(), - ofs: " ".to_string(), - ors: "\n".to_string(), - nr: 0, - nf: 0, - fnr: 0, - csv_mode: false, - } - } -} - -/// Parse a CSV line per RFC 4180: handle quoted fields, embedded commas, -/// and double-quote escaping. -fn csv_split_fields(line: &str) -> Vec { - let mut fields = Vec::new(); - let mut field = String::new(); - let mut in_quotes = false; - let mut chars = line.chars().peekable(); - - while let Some(c) = chars.next() { - if in_quotes { - if c == '"' { - if chars.peek() == Some(&'"') { - // Escaped quote - field.push('"'); - chars.next(); - } else { - in_quotes = false; - } - } else { - field.push(c); - } - } else if c == '"' { - in_quotes = true; - } else if c == ',' { - fields.push(std::mem::take(&mut field)); - } else { - field.push(c); - } - } - fields.push(field); - fields -} - -impl AwkState { - /// Split a line into fields based on current mode (CSV or FS) - fn split_fields(&self, line: &str) -> Vec { - if self.csv_mode { - csv_split_fields(line) - } else if self.fs == " " { - line.split_whitespace().map(String::from).collect() - } else { - line.split(&self.fs).map(String::from).collect() - } - } - - fn set_line(&mut self, line: &str) { - self.nr += 1; - self.fnr += 1; - - // Split by field separator - self.fields = self.split_fields(line); - - self.nf = self.fields.len(); - - // Set built-in variables - self.variables - .insert("NR".to_string(), AwkValue::Number(self.nr as f64)); - self.variables - .insert("NF".to_string(), AwkValue::Number(self.nf as f64)); - self.variables - .insert("FNR".to_string(), AwkValue::Number(self.fnr as f64)); - self.variables - .insert("$0".to_string(), AwkValue::String(line.to_string())); - } - - fn get_field(&self, n: usize) -> AwkValue { - if n == 0 { - // $0 is the whole line - self.variables - .get("$0") - .cloned() - .unwrap_or(AwkValue::Uninitialized) - } else if n <= self.fields.len() { - AwkValue::String(self.fields[n - 1].clone()) - } else { - AwkValue::Uninitialized - } - } - - fn get_variable(&self, name: &str) -> AwkValue { - match name { - "NR" => AwkValue::Number(self.nr as f64), - "NF" => AwkValue::Number(self.nf as f64), - "FNR" => AwkValue::Number(self.fnr as f64), - "FS" => AwkValue::String(self.fs.clone()), - "OFS" => AwkValue::String(self.ofs.clone()), - "ORS" => AwkValue::String(self.ors.clone()), - _ => self - .variables - .get(name) - .cloned() - .unwrap_or(AwkValue::Uninitialized), - } - } - - fn set_variable(&mut self, name: &str, value: AwkValue) { - match name { - "FS" => self.fs = value.as_string(), - "OFS" => self.ofs = value.as_string(), - "ORS" => self.ors = value.as_string(), - "$0" => { - let s = value.as_string(); - // Re-split fields when $0 is modified - self.fields = self.split_fields(&s); - self.nf = self.fields.len(); - self.variables - .insert("NF".to_string(), AwkValue::Number(self.nf as f64)); - self.variables.insert(name.to_string(), value); - } - _ => { - self.variables.insert(name.to_string(), value); - } - } - } -} - -/// THREAT[TM-DOS-027]: Maximum recursion depth for awk expression parser. -// Parser-depth limit moved to `super::limits::AWK_MAX_PARSER_DEPTH` -// (TM-DOS guard against deeply nested expressions). - -/// Preprocess awk program: replace newlines with semicolons inside action blocks. -/// This makes newlines act as statement separators per POSIX awk spec. -/// Respects string literals, regex literals, and nested braces. -fn normalize_awk_newlines(input: &str) -> String { - let mut result = String::with_capacity(input.len()); - let chars: Vec = input.chars().collect(); - let mut i = 0; - let mut brace_depth = 0; - - while i < chars.len() { - match chars[i] { - '{' => { - brace_depth += 1; - result.push('{'); - i += 1; - } - '}' => { - if brace_depth > 0 { - brace_depth -= 1; - } - result.push('}'); - i += 1; - } - '"' => { - // String literal — pass through unchanged - result.push('"'); - i += 1; - while i < chars.len() && chars[i] != '"' { - if chars[i] == '\\' && i + 1 < chars.len() { - result.push(chars[i]); - i += 1; - } - result.push(chars[i]); - i += 1; - } - if i < chars.len() { - result.push(chars[i]); // closing " - i += 1; - } - } - '/' => { - // Regex literal — pass through unchanged (both pattern and expression context) - result.push('/'); - i += 1; - while i < chars.len() && chars[i] != '/' { - if chars[i] == '\\' && i + 1 < chars.len() { - result.push(chars[i]); - i += 1; - } - result.push(chars[i]); - i += 1; - } - if i < chars.len() { - result.push(chars[i]); // closing / - i += 1; - } - } - '#' => { - // Comment — skip to end of line, replace with newline/semicolon - while i < chars.len() && chars[i] != '\n' { - i += 1; - } - if i < chars.len() { - if brace_depth > 0 { - result.push(';'); - } else { - result.push('\n'); - } - i += 1; - } - } - '\\' if i + 1 < chars.len() && chars[i + 1] == '\n' => { - // Backslash-newline: line continuation — join lines - i += 2; - } - '\n' if brace_depth > 0 => { - // Inside action block: replace newline with semicolon - result.push(';'); - i += 1; - } - _ => { - result.push(chars[i]); - i += 1; - } - } - } - result -} - -struct AwkParser<'a> { - input: &'a str, - pos: usize, - /// Current recursion depth for expression parsing - depth: usize, - /// When true, `>` and `>>` are output redirection, not comparison ops. - /// Set during print/printf argument parsing per POSIX awk semantics. - in_print_context: bool, -} - -impl<'a> AwkParser<'a> { - fn is_identifier_start(c: char) -> bool { - c.is_alphabetic() || c == '_' - } - - fn is_identifier_continue(c: char) -> bool { - c.is_alphanumeric() || c == '_' - } - - fn new(input: &'a str) -> Self { - Self { - input, - pos: 0, - depth: 0, - in_print_context: false, - } - } - - /// Get the character at the current byte position (char-boundary safe). - /// Returns None if pos is at or past end of input. - fn current_char(&self) -> Option { - self.input[self.pos..].chars().next() - } - - /// Advance pos past the current character (handles multi-byte UTF-8). - fn advance(&mut self) { - if let Some(c) = self.current_char() { - self.pos += c.len_utf8(); - } - } - - fn consume_while(&mut self, predicate: fn(char) -> bool) { - while self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if predicate(c) { - self.advance(); - } else { - break; - } - } - } - - /// THREAT[TM-DOS-027]: Increment depth, error if limit exceeded - fn push_depth(&mut self) -> Result<()> { - self.depth += 1; - if self.depth > MAX_AWK_PARSER_DEPTH { - return Err(Error::Execution(format!( - "awk: expression nesting too deep ({} levels, max {})", - self.depth, MAX_AWK_PARSER_DEPTH - ))); - } - Ok(()) - } - - /// Decrement depth after leaving a recursive parse - fn pop_depth(&mut self) { - if self.depth > 0 { - self.depth -= 1; - } - } - - fn parse(&mut self) -> Result { - let mut program = AwkProgram { - begin_actions: Vec::new(), - main_rules: Vec::new(), - end_actions: Vec::new(), - functions: HashMap::new(), - }; - - self.skip_whitespace(); - - while self.pos < self.input.len() { - self.skip_whitespace(); - if self.pos >= self.input.len() { - break; - } - - // Check for function/BEGIN/END - if self.matches_keyword("function") { - self.skip_whitespace(); - let (name, func_def) = self.parse_function_def()?; - program.functions.insert(name, func_def); - } else if self.matches_keyword("BEGIN") { - self.skip_whitespace(); - let actions = self.parse_action_block()?; - program.begin_actions.extend(actions); - } else if self.matches_keyword("END") { - self.skip_whitespace(); - let actions = self.parse_action_block()?; - program.end_actions.extend(actions); - } else { - // Pattern-action rule - let rule = self.parse_rule()?; - program.main_rules.push(rule); - } - - self.skip_whitespace(); - } - - // If no rules, add default print rule - if program.main_rules.is_empty() - && program.begin_actions.is_empty() - && program.end_actions.is_empty() - { - program.main_rules.push(AwkRule { - pattern: None, - actions: vec![AwkAction::Print( - vec![AwkExpr::Field(Box::new(AwkExpr::Number(0.0)))], - None, - )], - }); - } - - Ok(program) - } - - /// Parse a user-defined function: function name(params) { body } - fn parse_function_def(&mut self) -> Result<(String, AwkFunctionDef)> { - // Parse function name - let name = self.read_identifier()?; - self.skip_whitespace(); - - // Expect '(' - if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { - return Err(Error::Execution( - "awk: expected '(' after function name".to_string(), - )); - } - self.pos += 1; - - // Parse parameter list - let mut params = Vec::new(); - self.skip_whitespace(); - while self.pos < self.input.len() && self.current_char().unwrap() != ')' { - if !params.is_empty() { - if self.current_char().unwrap() == ',' { - self.pos += 1; - } - self.skip_whitespace(); - } - if self.pos < self.input.len() && self.current_char().unwrap() != ')' { - params.push(self.read_identifier()?); - self.skip_whitespace(); - } - } - - // Expect ')' - if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { - return Err(Error::Execution( - "awk: expected ')' after function parameters".to_string(), - )); - } - self.pos += 1; - self.skip_whitespace(); - - // Parse function body as action block - let body = self.parse_action_block()?; - - Ok((name, AwkFunctionDef { params, body })) - } - - /// Read an identifier (alphanumeric + underscore) - fn read_identifier(&mut self) -> Result { - let start = self.pos; - self.consume_while(Self::is_identifier_continue); - if self.pos == start { - return Err(Error::Execution("awk: expected identifier".to_string())); - } - Ok(self.input[start..self.pos].to_string()) - } - - fn matches_keyword(&mut self, keyword: &str) -> bool { - if self.input[self.pos..].starts_with(keyword) { - let after = self.pos + keyword.len(); - if after >= self.input.len() || { - let c = self.input[after..].chars().next().unwrap(); - !c.is_alphanumeric() && c != '_' - } { - self.pos = after; - return true; - } - } - false - } - - fn skip_whitespace(&mut self) { - while self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if c.is_whitespace() { - self.advance(); - } else if c == '#' { - // Comment - skip to end of line (may contain multi-byte chars) - while self.pos < self.input.len() && self.current_char().unwrap() != '\n' { - self.advance(); - } - } else { - break; - } - } - } - - fn parse_rule(&mut self) -> Result { - let pattern = self.parse_pattern()?; - self.skip_whitespace(); - - let actions = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { - self.parse_action_block()? - } else if pattern.is_some() { - // Default action is print - vec![AwkAction::Print( - vec![AwkExpr::Field(Box::new(AwkExpr::Number(0.0)))], - None, - )] - } else { - Vec::new() - }; - - Ok(AwkRule { pattern, actions }) - } - - /// Parse a `/regex/` literal into an `AwkPattern::Regex`. - /// Assumes the leading `/` is at `self.pos`. - fn parse_regex_pattern(&mut self) -> Result { - self.pos += 1; // skip opening '/' - let start = self.pos; - while self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if c == '/' { - let pattern = &self.input[start..self.pos]; - self.pos += 1; - let regex = build_regex(pattern) - .map_err(|e| Error::Execution(format!("awk: invalid regex: {}", e)))?; - return Ok(AwkPattern::Regex(regex)); - } else if c == '\\' { - self.pos += 1; // skip '\\' (ASCII) - self.advance(); // skip next char (may be multi-byte) - } else { - self.advance(); // regex content may be multi-byte - } - } - Err(Error::Execution("awk: unterminated regex".to_string())) - } - - fn parse_pattern(&mut self) -> Result> { - self.skip_whitespace(); - - if self.pos >= self.input.len() { - return Ok(None); - } - - let c = self.current_char().unwrap(); - - // Check for regex pattern - let first: Option = if c == '/' { - Some(self.parse_regex_pattern()?) - } else if c == '{' { - // Check for opening brace (no pattern) - return Ok(None); - } else { - // Expression pattern - let expr = self.parse_expression()?; - Some(AwkPattern::Expression(expr)) - }; - - // Check for range pattern: pattern1 , pattern2 - if let Some(first_pat) = first { - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == ',' { - self.pos += 1; // consume ',' - let second = self.parse_pattern()?; - let second_pat = second.ok_or_else(|| { - Error::Execution("awk: expected second pattern in range".to_string()) - })?; - Ok(Some(AwkPattern::Range( - Box::new(first_pat), - Box::new(second_pat), - ))) - } else { - Ok(Some(first_pat)) - } - } else { - Ok(None) - } - } - - fn parse_action_block(&mut self) -> Result> { - self.skip_whitespace(); - - if self.pos >= self.input.len() || self.current_char().unwrap() != '{' { - return Err(Error::Execution("awk: expected '{'".to_string())); - } - self.pos += 1; - - let mut actions = Vec::new(); - - loop { - self.skip_whitespace(); - if self.pos >= self.input.len() { - return Err(Error::Execution( - "awk: unterminated action block".to_string(), - )); - } - - // Skip empty statements (consecutive semicolons from newline normalization) - while self.pos < self.input.len() && self.current_char().unwrap() == ';' { - self.pos += 1; - self.skip_whitespace(); - } - if self.pos >= self.input.len() { - return Err(Error::Execution( - "awk: unterminated action block".to_string(), - )); - } - - let c = self.current_char().unwrap(); - if c == '}' { - self.pos += 1; - break; - } - - let action = self.parse_action()?; - actions.push(action); - - self.skip_whitespace(); - // Allow semicolon separator - if self.pos < self.input.len() && self.current_char().unwrap() == ';' { - self.pos += 1; - } - } - - Ok(actions) - } - - fn parse_action(&mut self) -> Result { - self.skip_whitespace(); - - // Check for keywords - if self.matches_keyword("print") { - return self.parse_print(); - } - if self.matches_keyword("printf") { - return self.parse_printf(); - } - if self.matches_keyword("next") { - return Ok(AwkAction::Next); - } - if self.matches_keyword("break") { - return Ok(AwkAction::Break); - } - if self.matches_keyword("continue") { - return Ok(AwkAction::Continue); - } - if self.matches_keyword("delete") { - return self.parse_delete(); - } - if self.matches_keyword("getline") { - return self.parse_getline(); - } - if self.matches_keyword("exit") { - self.skip_whitespace(); - if self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if c != '}' && c != ';' { - let expr = self.parse_expression()?; - return Ok(AwkAction::Exit(Some(expr))); - } - } - return Ok(AwkAction::Exit(None)); - } - if self.matches_keyword("return") { - self.skip_whitespace(); - if self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if c != '}' && c != ';' { - let expr = self.parse_expression()?; - return Ok(AwkAction::Return(Some(expr))); - } - } - return Ok(AwkAction::Return(None)); - } - if self.matches_keyword("if") { - return self.parse_if(); - } - if self.matches_keyword("for") { - return self.parse_for(); - } - if self.matches_keyword("while") { - return self.parse_while(); - } - if self.matches_keyword("do") { - return self.parse_do_while(); - } - - // Otherwise it's an expression (including assignment) - let expr = self.parse_expression()?; - - // Check if it's an assignment - match expr { - AwkExpr::Assign(name, val) => Ok(AwkAction::Assign(name, *val)), - AwkExpr::ArrayAssign(name, key, val) => Ok(AwkAction::ArrayAssign(name, *key, *val)), - _ => Ok(AwkAction::Expression(expr)), - } - } - - fn parse_print(&mut self) -> Result { - self.skip_whitespace(); - let mut args = Vec::new(); - - // Enable print context so `>` and `>>` are not parsed as comparison - self.in_print_context = true; - - loop { - if self.pos >= self.input.len() { - break; - } - let c = self.current_char().unwrap(); - if c == '}' || c == ';' { - break; - } - // Stop at output redirection operators - if c == '>' || c == '|' { - break; - } - - let expr = self.parse_expression()?; - args.push(expr); - - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == ',' { - self.pos += 1; - self.skip_whitespace(); - } else { - break; - } - } - - if args.is_empty() { - args.push(AwkExpr::Field(Box::new(AwkExpr::Number(0.0)))); - } - - let target = self.parse_output_target()?; - self.in_print_context = false; - - Ok(AwkAction::Print(args, target)) - } - - fn parse_printf(&mut self) -> Result { - self.skip_whitespace(); - - // Enable print context so `>` and `>>` are not parsed as comparison - self.in_print_context = true; - - // Handle optional parenthesized form: printf("format", args) - let has_parens = self.pos < self.input.len() && self.current_char().unwrap() == '('; - if has_parens { - self.pos += 1; - self.skip_whitespace(); - } - - // Parse format string — accepts string literals or expressions - if self.pos >= self.input.len() { - self.in_print_context = false; - return Err(Error::Execution( - "awk: printf requires format string".to_string(), - )); - } - - let format_expr = if self.current_char().unwrap() == '"' { - // String literal format — parse directly - let s = self.parse_string()?; - AwkExpr::String(s) - } else { - // Expression as format string (e.g., printf substr($1,1,1)) - self.parse_expression()? - }; - let mut args = Vec::new(); - - self.skip_whitespace(); - while self.pos < self.input.len() && self.current_char().unwrap() == ',' { - self.pos += 1; - self.skip_whitespace(); - let expr = self.parse_expression()?; - args.push(expr); - self.skip_whitespace(); - } - - if has_parens && self.pos < self.input.len() && self.current_char().unwrap() == ')' { - self.pos += 1; - } - - let target = self.parse_output_target()?; - self.in_print_context = false; - - Ok(AwkAction::Printf(format_expr, args, target)) - } - - /// Parse optional output target after print/printf arguments: `> file`, `>> file`, `| cmd`. - /// Pipe is unsupported and returns a clear error. - fn parse_output_target(&mut self) -> Result> { - self.skip_whitespace(); - if self.pos >= self.input.len() { - return Ok(None); - } - - let c = self.current_char().unwrap(); - if c == '>' { - self.pos += 1; - // Check for >> - let append = self.pos < self.input.len() && self.current_char().unwrap() == '>'; - if append { - self.pos += 1; - } - self.skip_whitespace(); - // Temporarily disable print context to parse the target as a normal expression - self.in_print_context = false; - let target = self.parse_expression()?; - self.in_print_context = true; - if append { - Ok(Some(AwkOutputTarget::Append(target))) - } else { - Ok(Some(AwkOutputTarget::Truncate(target))) - } - } else if c == '|' { - // Pipe output (e.g., `print ... | "cmd"`) not supported in virtual mode - Err(Error::Execution( - "awk: pipe output redirection (|) is not supported".to_string(), - )) - } else { - Ok(None) - } - } - - /// THREAT[TM-DOS-027]: Track depth for nested if/action blocks - fn parse_if(&mut self) -> Result { - self.push_depth()?; - - self.skip_whitespace(); - - if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { - self.pop_depth(); - return Err(Error::Execution("awk: expected '(' after if".to_string())); - } - self.pos += 1; - - let condition = self.parse_expression()?; - - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { - self.pop_depth(); - return Err(Error::Execution( - "awk: expected ')' after condition".to_string(), - )); - } - self.pos += 1; - - self.skip_whitespace(); - let then_actions = if self.current_char().unwrap() == '{' { - self.parse_action_block()? - } else { - vec![self.parse_action()?] - }; - - self.skip_whitespace(); - // Consume optional ';' before else - if self.pos < self.input.len() && self.current_char().unwrap() == ';' { - self.pos += 1; - self.skip_whitespace(); - } - let else_actions = if self.matches_keyword("else") { - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == '{' { - self.parse_action_block()? - } else { - vec![self.parse_action()?] - } - } else { - Vec::new() - }; - - self.pop_depth(); - Ok(AwkAction::If(condition, then_actions, else_actions)) - } - - fn parse_for(&mut self) -> Result { - self.skip_whitespace(); - - if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { - return Err(Error::Execution("awk: expected '(' after for".to_string())); - } - self.pos += 1; - self.skip_whitespace(); - - // Check for `for (key in arr)` syntax - let saved_pos = self.pos; - if let Ok(AwkExpr::Variable(var_name)) = self.parse_primary() { - self.skip_whitespace(); - if self.matches_keyword("in") { - self.skip_whitespace(); - // Parse array name - let start = self.pos; - self.consume_while(Self::is_identifier_continue); - let arr_name = self.input[start..self.pos].to_string(); - - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { - return Err(Error::Execution("awk: expected ')' in for-in".to_string())); - } - self.pos += 1; - - self.skip_whitespace(); - let body = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { - self.parse_action_block()? - } else { - vec![self.parse_action()?] - }; - - return Ok(AwkAction::ForIn(var_name, arr_name, body)); - } - } - - // Not for-in, backtrack and parse C-style for - self.pos = saved_pos; - - // Parse init - let init_expr = self.parse_expression()?; - let init = match init_expr { - AwkExpr::Assign(name, val) => AwkAction::Assign(name, *val), - AwkExpr::ArrayAssign(name, key, val) => AwkAction::ArrayAssign(name, *key, *val), - _ => AwkAction::Expression(init_expr), - }; - - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ';' { - return Err(Error::Execution( - "awk: expected ';' in for statement".to_string(), - )); - } - self.pos += 1; - - // Parse condition - self.skip_whitespace(); - let condition = self.parse_expression()?; - - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ';' { - return Err(Error::Execution( - "awk: expected ';' in for statement".to_string(), - )); - } - self.pos += 1; - - // Parse update - self.skip_whitespace(); - let update_expr = self.parse_expression()?; - let update = match update_expr { - AwkExpr::Assign(name, val) => AwkAction::Assign(name, *val), - AwkExpr::ArrayAssign(name, key, val) => AwkAction::ArrayAssign(name, *key, *val), - _ => AwkAction::Expression(update_expr), - }; - - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { - return Err(Error::Execution( - "awk: expected ')' in for statement".to_string(), - )); - } - self.pos += 1; - - self.skip_whitespace(); - let body = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { - self.parse_action_block()? - } else { - vec![self.parse_action()?] - }; - - Ok(AwkAction::For( - Box::new(init), - condition, - Box::new(update), - body, - )) - } - - fn parse_while(&mut self) -> Result { - self.skip_whitespace(); - - if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { - return Err(Error::Execution( - "awk: expected '(' after while".to_string(), - )); - } - self.pos += 1; - - let condition = self.parse_expression()?; - - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { - return Err(Error::Execution( - "awk: expected ')' after while condition".to_string(), - )); - } - self.pos += 1; - - self.skip_whitespace(); - let body = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { - self.parse_action_block()? - } else { - vec![self.parse_action()?] - }; - - Ok(AwkAction::While(condition, body)) - } - - fn parse_do_while(&mut self) -> Result { - self.skip_whitespace(); - - let body = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { - self.parse_action_block()? - } else { - vec![self.parse_action()?] - }; - - self.skip_whitespace(); - if !self.matches_keyword("while") { - return Err(Error::Execution( - "awk: expected 'while' after do body".to_string(), - )); - } - - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { - return Err(Error::Execution( - "awk: expected '(' after do-while".to_string(), - )); - } - self.pos += 1; - - let condition = self.parse_expression()?; - - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { - return Err(Error::Execution( - "awk: expected ')' in do-while".to_string(), - )); - } - self.pos += 1; - - Ok(AwkAction::DoWhile(condition, body)) - } - - fn parse_delete(&mut self) -> Result { - self.skip_whitespace(); - - // Parse array name - let start = self.pos; - self.consume_while(Self::is_identifier_continue); - let arr_name = self.input[start..self.pos].to_string(); - - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == '[' { - self.pos += 1; - let index = self.parse_expression()?; - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ']' { - return Err(Error::Execution("awk: expected ']'".to_string())); - } - self.pos += 1; - Ok(AwkAction::Delete(arr_name, index)) - } else { - // delete entire array - Ok(AwkAction::Delete( - arr_name, - AwkExpr::String("*".to_string()), - )) - } - } - - /// Parse `getline [var] [< file]`. - /// - /// Forms: - /// - `getline` — read next input record into $0 - /// - `getline var` — read next input record into var - /// - `getline var < file` — read next line from file into var - /// - `getline < file` — read next line from file into $0 - fn parse_getline(&mut self) -> Result { - self.skip_whitespace(); - - // Check what follows: variable name, '<', or end of statement - let mut var: Option = None; - - if self.pos < self.input.len() { - let c = self.current_char().unwrap(); - // If next char is an identifier start (not '<', ';', '}', etc.), parse variable - if Self::is_identifier_start(c) { - let start = self.pos; - self.advance(); - self.consume_while(Self::is_identifier_continue); - var = Some(self.input[start..self.pos].to_string()); - self.skip_whitespace(); - } - } - - // Check for '< file' redirection - if self.pos < self.input.len() && self.current_char().unwrap() == '<' { - self.pos += 1; // consume '<' - self.skip_whitespace(); - let file = self.parse_primary()?; - return Ok(AwkAction::GetlineFile { var, file }); - } - - // Plain getline (with optional var — but plain `getline var` without - // file redirection just reads next input line into var; for now we - // treat that the same as plain getline which updates $0). - // TODO: `getline var` should store into var without updating $0 - Ok(AwkAction::Getline) - } - - /// Parse `getline [var] [< file]` as an expression returning 1/0/-1. - fn parse_getline_expr(&mut self) -> Result { - self.skip_whitespace(); - - let mut var: Option = None; - - if self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if Self::is_identifier_start(c) { - let start = self.pos; - self.advance(); - self.consume_while(Self::is_identifier_continue); - var = Some(self.input[start..self.pos].to_string()); - self.skip_whitespace(); - } - } - - // Check for '< file' redirection - if self.pos < self.input.len() && self.current_char().unwrap() == '<' { - self.pos += 1; // consume '<' - self.skip_whitespace(); - let file = self.parse_primary()?; - return Ok(AwkExpr::GetlineFile { - var, - file: Box::new(file), - }); - } - - // Plain getline expression without file — use FuncCall to represent - // TODO: support plain getline as expression (advance input line) - Ok(AwkExpr::FuncCall("__getline".to_string(), vec![])) - } - - /// THREAT[TM-DOS-027]: Track depth on every expression entry - fn parse_expression(&mut self) -> Result { - self.push_depth()?; - let result = self.parse_assignment(); - self.pop_depth(); - result - } - - fn parse_assignment(&mut self) -> Result { - let expr = self.parse_ternary()?; - - self.skip_whitespace(); - if self.pos >= self.input.len() { - return Ok(expr); - } - - // Check for compound assignment operators (+=, -=, *=, /=, %=) - let compound_ops = ["+=", "-=", "*=", "/=", "%="]; - for op in compound_ops { - if self.input[self.pos..].starts_with(op) { - self.pos += op.len(); - self.skip_whitespace(); - let value = self.parse_assignment()?; - - match &expr { - AwkExpr::Variable(name) => { - let bin_op = &op[..1]; - let current = AwkExpr::Variable(name.clone()); - let combined = - AwkExpr::BinOp(Box::new(current), bin_op.to_string(), Box::new(value)); - return Ok(AwkExpr::Assign(name.clone(), Box::new(combined))); - } - AwkExpr::FuncCall(fname, args) - if fname == "__array_access" && args.len() == 2 => - { - if let AwkExpr::Variable(arr_name) = &args[0] { - let bin_op = &op[..1]; - return Ok(AwkExpr::CompoundArrayAssign( - arr_name.clone(), - Box::new(args[1].clone()), - bin_op.to_string(), - Box::new(value), - )); - } - return Err(Error::Execution( - "awk: invalid assignment target".to_string(), - )); - } - AwkExpr::Field(index) => { - let bin_op = &op[..1]; - let current = AwkExpr::Field(index.clone()); - let combined = - AwkExpr::BinOp(Box::new(current), bin_op.to_string(), Box::new(value)); - return Ok(AwkExpr::FieldAssign(index.clone(), Box::new(combined))); - } - _ => { - return Err(Error::Execution( - "awk: invalid assignment target".to_string(), - )); - } - } - } - } - - // Simple assignment - if self.current_char().unwrap() == '=' { - let next = self.input[self.pos..].chars().nth(1); - if next != Some('=') && next != Some('~') { - self.pos += 1; - self.skip_whitespace(); - let value = self.parse_assignment()?; - - match expr { - AwkExpr::Variable(name) => { - return Ok(AwkExpr::Assign(name, Box::new(value))); - } - AwkExpr::FuncCall(ref fname, ref args) - if fname == "__array_access" && args.len() == 2 => - { - if let AwkExpr::Variable(arr_name) = &args[0] { - return Ok(AwkExpr::ArrayAssign( - arr_name.clone(), - Box::new(args[1].clone()), - Box::new(value), - )); - } - return Err(Error::Execution( - "awk: invalid assignment target".to_string(), - )); - } - AwkExpr::Field(index) => { - return Ok(AwkExpr::FieldAssign(index, Box::new(value))); - } - _ => { - return Err(Error::Execution( - "awk: invalid assignment target".to_string(), - )); - } - } - } - } - - Ok(expr) - } - - fn parse_ternary(&mut self) -> Result { - let expr = self.parse_or()?; - - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == '?' { - self.pos += 1; - self.skip_whitespace(); - let then_expr = self.parse_expression()?; - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == ':' { - self.pos += 1; - self.skip_whitespace(); - let else_expr = self.parse_expression()?; - // Encode ternary as a function call for evaluation - return Ok(AwkExpr::FuncCall( - "__ternary".to_string(), - vec![expr, then_expr, else_expr], - )); - } - } - - Ok(expr) - } - - fn parse_or(&mut self) -> Result { - let mut left = self.parse_and()?; - - loop { - self.skip_whitespace(); - if self.input[self.pos..].starts_with("||") { - self.pos += 2; - self.skip_whitespace(); - let right = self.parse_and()?; - left = AwkExpr::BinOp(Box::new(left), "||".to_string(), Box::new(right)); - } else { - break; - } - } - - Ok(left) - } - - fn parse_and(&mut self) -> Result { - let mut left = self.parse_comparison()?; - - loop { - self.skip_whitespace(); - if self.input[self.pos..].starts_with("&&") { - self.pos += 2; - self.skip_whitespace(); - let right = self.parse_comparison()?; - left = AwkExpr::BinOp(Box::new(left), "&&".to_string(), Box::new(right)); - } else { - break; - } - } - - Ok(left) - } - - fn parse_comparison(&mut self) -> Result { - let left = self.parse_concat()?; - - self.skip_whitespace(); - - // Check for `in` operator: (key in arr) - if self.matches_keyword("in") { - self.skip_whitespace(); - let start = self.pos; - self.consume_while(Self::is_identifier_continue); - let arr_name = self.input[start..self.pos].to_string(); - return Ok(AwkExpr::InArray(Box::new(left), arr_name)); - } - - // In print context, `>` and `>>` are output redirection, not comparison. - // `>=` remains a comparison operator even in print context. - let ops: &[&str] = if self.in_print_context { - &["==", "!=", "<=", ">=", "<", "~", "!~"] - } else { - &["==", "!=", "<=", ">=", "<", ">", "~", "!~"] - }; - - for op in ops { - if self.input[self.pos..].starts_with(op) { - self.pos += op.len(); - self.skip_whitespace(); - let right = self.parse_concat()?; - return Ok(AwkExpr::BinOp( - Box::new(left), - op.to_string(), - Box::new(right), - )); - } - } - - Ok(left) - } - - fn is_keyword_at_pos(&self) -> bool { - let remaining = &self.input[self.pos..]; - let keywords = [ - "in", "if", "else", "while", "for", "do", "break", "continue", "next", "exit", - "return", "delete", "getline", "print", "printf", "function", - ]; - for kw in keywords { - if remaining.starts_with(kw) { - let after = self.pos + kw.len(); - if after >= self.input.len() || { - let c = self.input[after..].chars().next().unwrap(); - !c.is_alphanumeric() && c != '_' - } { - return true; - } - } - } - false - } - - fn parse_concat(&mut self) -> Result { - let mut parts = vec![self.parse_additive()?]; - - loop { - self.skip_whitespace(); - if self.pos >= self.input.len() { - break; - } - - let c = self.current_char().unwrap(); - // Check if this could be the start of another value for concatenation - if c == '"' || c == '$' || Self::is_identifier_start(c) || c == '(' { - // But not if it's a keyword or operator - let remaining = &self.input[self.pos..]; - if !remaining.starts_with("||") - && !remaining.starts_with("&&") - && !remaining.starts_with("==") - && !remaining.starts_with("!=") - && !self.is_keyword_at_pos() - && let Ok(next) = self.parse_additive() - { - parts.push(next); - continue; - } - } - break; - } - - if parts.len() == 1 { - Ok(parts.remove(0)) - } else { - Ok(AwkExpr::Concat(parts)) - } - } - - fn parse_additive(&mut self) -> Result { - let mut left = self.parse_multiplicative()?; - - loop { - self.skip_whitespace(); - if self.pos >= self.input.len() { - break; - } - - let c = self.current_char().unwrap(); - if c == '+' || c == '-' { - // Don't consume if it's a compound assignment operator (+=, -=) - let next = self.input[self.pos..].chars().nth(1); - if next == Some('=') { - break; - } - self.pos += 1; - self.skip_whitespace(); - let right = self.parse_multiplicative()?; - left = AwkExpr::BinOp(Box::new(left), c.to_string(), Box::new(right)); - } else { - break; - } - } - - Ok(left) - } - - fn parse_multiplicative(&mut self) -> Result { - let mut left = self.parse_power()?; - - loop { - self.skip_whitespace(); - if self.pos >= self.input.len() { - break; - } - - let c = self.current_char().unwrap(); - if c == '*' || c == '/' || c == '%' { - // Don't consume ** (power operator) - if c == '*' && self.input[self.pos..].chars().nth(1) == Some('*') { - break; - } - // Don't consume if it's a compound assignment operator (*=, /=, %=) - let next = self.input[self.pos..].chars().nth(1); - if next == Some('=') { - break; - } - self.pos += 1; - self.skip_whitespace(); - let right = self.parse_power()?; - left = AwkExpr::BinOp(Box::new(left), c.to_string(), Box::new(right)); - } else { - break; - } - } - - Ok(left) - } - - fn parse_power(&mut self) -> Result { - let base = self.parse_unary()?; - - self.skip_whitespace(); - if self.pos >= self.input.len() { - return Ok(base); - } - - // Check for ^ or ** - if self.current_char().unwrap() == '^' { - self.pos += 1; - self.skip_whitespace(); - let exp = self.parse_unary()?; - return Ok(AwkExpr::BinOp( - Box::new(base), - "^".to_string(), - Box::new(exp), - )); - } - if self.input[self.pos..].starts_with("**") { - self.pos += 2; - self.skip_whitespace(); - let exp = self.parse_unary()?; - return Ok(AwkExpr::BinOp( - Box::new(base), - "^".to_string(), - Box::new(exp), - )); - } - - Ok(base) - } - - /// THREAT[TM-DOS-027]: Track depth on unary self-recursion - fn parse_unary(&mut self) -> Result { - self.skip_whitespace(); - - if self.pos >= self.input.len() { - return Err(Error::Execution( - "awk: unexpected end of expression".to_string(), - )); - } - - // Pre-increment: ++var or ++arr[key] - if self.input[self.pos..].starts_with("++") { - self.pos += 2; - self.skip_whitespace(); - match self.parse_primary()? { - AwkExpr::Variable(name) => return Ok(AwkExpr::PreIncrement(name)), - AwkExpr::FuncCall(ref fname, ref args) - if fname == "__array_access" && args.len() == 2 => - { - if let AwkExpr::Variable(arr_name) = &args[0] { - return Ok(AwkExpr::CompoundArrayAssign( - arr_name.clone(), - Box::new(args[1].clone()), - "+".to_string(), - Box::new(AwkExpr::Number(1.0)), - )); - } - return Err(Error::Execution( - "awk: expected variable after ++".to_string(), - )); - } - _ => { - return Err(Error::Execution( - "awk: expected variable after ++".to_string(), - )); - } - } - } - - // Pre-decrement: --var or --arr[key] - if self.input[self.pos..].starts_with("--") { - self.pos += 2; - self.skip_whitespace(); - match self.parse_primary()? { - AwkExpr::Variable(name) => return Ok(AwkExpr::PreDecrement(name)), - AwkExpr::FuncCall(ref fname, ref args) - if fname == "__array_access" && args.len() == 2 => - { - if let AwkExpr::Variable(arr_name) = &args[0] { - return Ok(AwkExpr::CompoundArrayAssign( - arr_name.clone(), - Box::new(args[1].clone()), - "-".to_string(), - Box::new(AwkExpr::Number(1.0)), - )); - } - return Err(Error::Execution( - "awk: expected variable after --".to_string(), - )); - } - _ => { - return Err(Error::Execution( - "awk: expected variable after --".to_string(), - )); - } - } - } - - let c = self.current_char().unwrap(); - - if c == '-' { - self.pos += 1; - self.push_depth()?; - let expr = self.parse_unary(); - self.pop_depth(); - return Ok(AwkExpr::UnaryOp("-".to_string(), Box::new(expr?))); - } - - if c == '!' { - self.pos += 1; - self.push_depth()?; - let expr = self.parse_unary(); - self.pop_depth(); - return Ok(AwkExpr::UnaryOp("!".to_string(), Box::new(expr?))); - } - - if c == '+' { - self.pos += 1; - self.push_depth()?; - let result = self.parse_unary(); - self.pop_depth(); - return result; - } - - self.parse_postfix() - } - - fn parse_postfix(&mut self) -> Result { - let expr = self.parse_primary()?; - - // Check for postfix ++ / -- - if self.pos + 1 < self.input.len() { - if self.input[self.pos..].starts_with("++") { - match &expr { - AwkExpr::Variable(name) => { - self.pos += 2; - return Ok(AwkExpr::PostIncrement(name.clone())); - } - AwkExpr::FuncCall(fname, args) - if fname == "__array_access" && args.len() == 2 => - { - // arr[key]++ → compound array assign with +1 - if let AwkExpr::Variable(arr_name) = &args[0] { - self.pos += 2; - return Ok(AwkExpr::CompoundArrayAssign( - arr_name.clone(), - Box::new(args[1].clone()), - "+".to_string(), - Box::new(AwkExpr::Number(1.0)), - )); - } - } - _ => {} - } - } - if self.input[self.pos..].starts_with("--") { - match &expr { - AwkExpr::Variable(name) => { - self.pos += 2; - return Ok(AwkExpr::PostDecrement(name.clone())); - } - AwkExpr::FuncCall(fname, args) - if fname == "__array_access" && args.len() == 2 => - { - if let AwkExpr::Variable(arr_name) = &args[0] { - self.pos += 2; - return Ok(AwkExpr::CompoundArrayAssign( - arr_name.clone(), - Box::new(args[1].clone()), - "-".to_string(), - Box::new(AwkExpr::Number(1.0)), - )); - } - } - _ => {} - } - } - } - - Ok(expr) - } - - fn parse_primary(&mut self) -> Result { - self.skip_whitespace(); - - if self.pos >= self.input.len() { - return Err(Error::Execution( - "awk: unexpected end of expression".to_string(), - )); - } - - let c = self.current_char().unwrap(); - - // Field reference $ - if c == '$' { - self.pos += 1; - self.push_depth()?; - let index = self.parse_primary()?; - self.pop_depth(); - return Ok(AwkExpr::Field(Box::new(index))); - } - - // Number - if c.is_ascii_digit() || c == '.' { - return self.parse_number(); - } - - // String - if c == '"' { - let s = self.parse_string()?; - return Ok(AwkExpr::String(s)); - } - - // Regex literal /pattern/ - if c == '/' { - self.pos += 1; - let start = self.pos; - while self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if c == '/' { - let pattern = &self.input[start..self.pos]; - self.pos += 1; - return Ok(AwkExpr::Regex(pattern.to_string())); - } else if c == '\\' { - self.pos += 1; // skip '\\' (ASCII) - self.advance(); // skip next char (may be multi-byte) - } else { - self.advance(); // regex content may be multi-byte - } - } - return Err(Error::Execution("awk: unterminated regex".to_string())); - } - - // Parenthesized expression: `>` inside parens is comparison, not redirection - if c == '(' { - self.pos += 1; - self.push_depth()?; - let saved_print_ctx = self.in_print_context; - self.in_print_context = false; - let expr = self.parse_expression()?; - self.in_print_context = saved_print_ctx; - self.pop_depth(); - self.skip_whitespace(); - if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { - return Err(Error::Execution("awk: expected ')'".to_string())); - } - self.pos += 1; - return Ok(expr); - } - - // Variable or function call - if Self::is_identifier_start(c) { - let start = self.pos; - self.advance(); - self.consume_while(Self::is_identifier_continue); - let name = self.input[start..self.pos].to_string(); - - // getline [var] [< file] as expression (returns 1/0/-1) - if name == "getline" { - return self.parse_getline_expr(); - } - - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == '(' { - // Function call - self.pos += 1; - let mut args = Vec::new(); - loop { - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == ')' { - self.pos += 1; - break; - } - let arg = self.parse_expression()?; - args.push(arg); - self.skip_whitespace(); - if self.pos < self.input.len() && self.current_char().unwrap() == ',' { - self.pos += 1; - } - } - return Ok(AwkExpr::FuncCall(name, args)); - } - - // Array indexing: arr[index] or arr[e1,e2,...] (multi-subscript with SUBSEP) - if self.pos < self.input.len() && self.current_char().unwrap() == '[' { - self.pos += 1; // consume '[' - let mut subscripts = vec![self.parse_expression()?]; - self.skip_whitespace(); - // Handle multi-subscript: arr[e1, e2, ...] joined by SUBSEP - while self.pos < self.input.len() && self.current_char().unwrap() == ',' { - self.pos += 1; // consume ',' - self.skip_whitespace(); - subscripts.push(self.parse_expression()?); - self.skip_whitespace(); - } - if self.pos >= self.input.len() || self.current_char().unwrap() != ']' { - return Err(Error::Execution("awk: expected ']'".to_string())); - } - self.pos += 1; // consume ']' - let index_expr = if subscripts.len() == 1 { - subscripts.remove(0) - } else { - // Join multiple subscripts with SUBSEP - let mut result = subscripts.remove(0); - for sub in subscripts { - result = AwkExpr::BinOp( - Box::new(result), - "SUBSEP_CONCAT".to_string(), - Box::new(sub), - ); - } - result - }; - return Ok(AwkExpr::FuncCall( - "__array_access".to_string(), - vec![AwkExpr::Variable(name), index_expr], - )); - } - - return Ok(AwkExpr::Variable(name)); - } - - Err(Error::Execution(format!( - "awk: unexpected character: {}", - c - ))) - } - - fn parse_number(&mut self) -> Result { - let start = self.pos; - while self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '-' || c == '+' { - self.pos += 1; - } else { - break; - } - } - - let num_str = &self.input[start..self.pos]; - let num: f64 = num_str - .parse() - .map_err(|_| Error::Execution(format!("awk: invalid number: {}", num_str)))?; - - Ok(AwkExpr::Number(num)) - } - - fn parse_string(&mut self) -> Result { - if self.pos >= self.input.len() || self.current_char().unwrap() != '"' { - return Err(Error::Execution("awk: expected string".to_string())); - } - self.pos += 1; // skip opening '"' (ASCII) - - let mut result = String::new(); - while self.pos < self.input.len() { - let c = self.current_char().unwrap(); - if c == '"' { - self.pos += 1; // skip closing '"' (ASCII) - return Ok(result); - } else if c == '\\' { - self.pos += 1; // skip '\\' (ASCII) - if self.pos < self.input.len() { - let escaped = self.current_char().unwrap(); - match escaped { - 'n' => { - result.push('\n'); - self.advance(); - } - 't' => { - result.push('\t'); - self.advance(); - } - 'r' => { - result.push('\r'); - self.advance(); - } - '\\' => { - result.push('\\'); - self.advance(); - } - '"' => { - result.push('"'); - self.advance(); - } - 'u' => { - // gawk 5.3+ Unicode escape: \u followed by 1-8 hex digits - self.advance(); // skip 'u' - let mut hex = String::new(); - while hex.len() < 8 - && self.pos < self.input.len() - && self.current_char().is_some_and(|c| c.is_ascii_hexdigit()) - { - hex.push(self.current_char().unwrap()); - self.pos += 1; // hex digits are ASCII - } - if hex.is_empty() { - result.push('\\'); - result.push('u'); - } else if let Ok(cp) = u32::from_str_radix(&hex, 16) { - if let Some(ch) = char::from_u32(cp) { - result.push(ch); - } else { - // Invalid Unicode code point — keep literal - result.push('\\'); - result.push('u'); - result.push_str(&hex); - } - } else { - result.push('\\'); - result.push('u'); - result.push_str(&hex); - } - } - _ => { - result.push('\\'); - result.push(escaped); - self.advance(); - } - } - } - } else { - result.push(c); - self.advance(); // character may be multi-byte - } - } - - Err(Error::Execution("awk: unterminated string".to_string())) - } -} - -/// Flow control signal from action execution -#[derive(Debug, PartialEq)] -enum AwkFlow { - Continue, // Normal execution - Next, // Skip to next record - Break, // Break out of loop - LoopContinue, // Continue to next loop iteration - Exit(Option), // Exit program with optional code - Return(AwkValue), // Return from user-defined function -} - -// Awk runtime limits (TM-DOS-027, TM-DOS-028) live in `super::limits`: -// - AWK_MAX_CALL_DEPTH (user-function recursion) -// - AWK_MAX_OUTPUT_BYTES (total stdout+stderr+file redirects, 10 MB) -// - AWK_MAX_GETLINE_CACHED_FILES (distinct files held open by `getline`). - -struct AwkInterpreter { - state: AwkState, - output: String, - /// Stderr output buffer for `/dev/stderr` redirection - stderr_output: String, - /// Lines of current input file (set before main loop) - input_lines: Vec, - /// Current line index within input_lines - line_index: usize, - /// User-defined functions - functions: HashMap, - /// Current function call depth for recursion limiting - call_depth: usize, - /// Buffered file outputs for `>` redirection (path -> content). - /// Truncate mode: first write creates entry, subsequent writes append to buffer. - /// Flushed to VFS after execution completes. - file_outputs: HashMap, - /// Buffered file outputs for `>>` redirection (path -> content). - /// Append mode: content is appended to existing file on flush. - file_appends: HashMap, - /// Cached file inputs for `getline var < file` redirection. - /// Maps resolved path -> (lines, current_position). - file_inputs: HashMap, usize)>, - /// VFS reference for lazy file reads (getline < file). - fs: Option>, - /// Working directory for resolving relative paths. - cwd: PathBuf, - /// Tracks active state for range patterns (rule index -> is_active). - /// When start pattern matches, range becomes active. When end pattern - /// matches, that line is included but range becomes inactive. - range_active: HashMap, - /// Max iterations for a single loop, inherited from execution limits. - max_loop_iterations: usize, -} - -impl AwkInterpreter { - fn new() -> Self { - Self { - state: AwkState::default(), - output: String::new(), - stderr_output: String::new(), - input_lines: Vec::new(), - line_index: 0, - functions: HashMap::new(), - file_outputs: HashMap::new(), - file_appends: HashMap::new(), - file_inputs: HashMap::new(), - call_depth: 0, - fs: None, - cwd: PathBuf::from("/"), - range_active: HashMap::new(), - max_loop_iterations: ExecutionLimits::default().max_loop_iterations, - } - } - - /// Load a file into the `file_inputs` cache if not already present. - /// Uses a separate thread + tokio runtime to bridge async VFS → sync context. - /// Returns true on success, false on error. - fn ensure_file_loaded(&mut self, resolved: &str) -> bool { - if self.file_inputs.contains_key(resolved) { - return true; - } - // Guard: cap number of cached files to prevent memory exhaustion - if self.file_inputs.len() >= MAX_GETLINE_CACHED_FILES { - return false; - } - let Some(fs) = &self.fs else { - return false; - }; - let fs = fs.clone(); - let p = PathBuf::from(resolved); - // Spawn a thread with its own runtime to avoid blocking the current async runtime. - let result = std::thread::spawn(move || { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .unwrap() - .block_on(fs.read_file(&p)) - }) - .join(); - match result { - Ok(Ok(bytes)) => { - let text = String::from_utf8_lossy(&bytes).into_owned(); - let lines: Vec = text.lines().map(|l| l.to_string()).collect(); - self.file_inputs.insert(resolved.to_string(), (lines, 0)); - true - } - _ => false, - } - } - - /// Evaluate an expression as a boolean, with special handling for regex - /// literals: `/regex/` is matched against $0 in boolean context (e.g. && / ||). - fn eval_expr_as_bool(&mut self, expr: &AwkExpr) -> bool { - if let AwkExpr::Regex(pattern) = expr { - let line = self.state.get_field(0).as_string(); - if let Ok(re) = build_regex(pattern) { - return re.is_match(&line); - } - return false; - } - self.eval_expr(expr).as_bool() - } - - fn eval_expr(&mut self, expr: &AwkExpr) -> AwkValue { - match expr { - AwkExpr::Number(n) => AwkValue::Number(*n), - AwkExpr::String(s) => AwkValue::String(s.clone()), - AwkExpr::Field(index) => { - let n = self.eval_expr(index).as_number() as usize; - self.state.get_field(n) - } - AwkExpr::Variable(name) => self.state.get_variable(name), - AwkExpr::Assign(name, val) => { - let value = self.eval_expr(val); - self.state.set_variable(name, value.clone()); - value - } - AwkExpr::BinOp(left, op, right) => { - if op == "&&" { - let lb = self.eval_expr_as_bool(left); - if !lb { - return AwkValue::Number(0.0); - } - let rb = self.eval_expr_as_bool(right); - return AwkValue::Number(if rb { 1.0 } else { 0.0 }); - } - if op == "||" { - let lb = self.eval_expr_as_bool(left); - if lb { - return AwkValue::Number(1.0); - } - let rb = self.eval_expr_as_bool(right); - return AwkValue::Number(if rb { 1.0 } else { 0.0 }); - } - - let l = self.eval_expr(left); - let r = self.eval_expr(right); - - match op.as_str() { - "+" => AwkValue::Number(l.as_number() + r.as_number()), - "-" => AwkValue::Number(l.as_number() - r.as_number()), - "*" => AwkValue::Number(l.as_number() * r.as_number()), - "/" => AwkValue::Number(l.as_number() / r.as_number()), - "%" => AwkValue::Number(l.as_number() % r.as_number()), - "^" => AwkValue::Number(l.as_number().powf(r.as_number())), - "==" => AwkValue::Number(if l.as_string() == r.as_string() { - 1.0 - } else { - 0.0 - }), - "!=" => AwkValue::Number(if l.as_string() != r.as_string() { - 1.0 - } else { - 0.0 - }), - "<" => AwkValue::Number(if l.as_number() < r.as_number() { - 1.0 - } else { - 0.0 - }), - ">" => AwkValue::Number(if l.as_number() > r.as_number() { - 1.0 - } else { - 0.0 - }), - "<=" => AwkValue::Number(if l.as_number() <= r.as_number() { - 1.0 - } else { - 0.0 - }), - ">=" => AwkValue::Number(if l.as_number() >= r.as_number() { - 1.0 - } else { - 0.0 - }), - "~" => { - if let Ok(re) = build_regex(&r.as_string()) { - AwkValue::Number(if re.is_match(&l.as_string()) { - 1.0 - } else { - 0.0 - }) - } else { - AwkValue::Number(0.0) - } - } - "!~" => { - if let Ok(re) = build_regex(&r.as_string()) { - AwkValue::Number(if !re.is_match(&l.as_string()) { - 1.0 - } else { - 0.0 - }) - } else { - AwkValue::Number(1.0) - } - } - "SUBSEP_CONCAT" => { - let subsep = self.state.get_variable("SUBSEP").as_string(); - AwkValue::String(format!("{}{}{}", l.as_string(), subsep, r.as_string())) - } - _ => AwkValue::Uninitialized, - } - } - AwkExpr::UnaryOp(op, expr) => match op.as_str() { - "-" => { - let v = self.eval_expr(expr); - AwkValue::Number(-v.as_number()) - } - "!" => { - let b = self.eval_expr_as_bool(expr); - AwkValue::Number(if b { 0.0 } else { 1.0 }) - } - _ => self.eval_expr(expr), - }, - AwkExpr::Concat(parts) => { - let s: String = parts - .iter() - .map(|p| self.eval_expr(p).as_string()) - .collect(); - AwkValue::String(s) - } - AwkExpr::ArrayAssign(name, key, val) => { - let k = self.eval_expr(key).as_string(); - let v = self.eval_expr(val); - let full_key = format!("{}[{}]", name, k); - self.state.set_variable(&full_key, v.clone()); - v - } - AwkExpr::CompoundArrayAssign(name, key, op, val) => { - let k = self.eval_expr(key).as_string(); - let full_key = format!("{}[{}]", name, k); - let current = self.state.get_variable(&full_key).as_number(); - let rhs = self.eval_expr(val).as_number(); - let result = match op.as_str() { - "+" => current + rhs, - "-" => current - rhs, - "*" => current * rhs, - "/" => current / rhs, - "%" => current % rhs, - _ => rhs, - }; - let v = AwkValue::Number(result); - self.state.set_variable(&full_key, v.clone()); - v - } - AwkExpr::FieldAssign(index, val) => { - let n = self.eval_expr(index).as_number() as usize; - let v = self.eval_expr(val); - if n == 0 { - self.state.set_variable("$0", v.clone()); - } else { - // Extend fields if needed - while self.state.fields.len() < n { - self.state.fields.push(String::new()); - } - self.state.fields[n - 1] = v.as_string(); - self.state.nf = self.state.fields.len(); - // Rebuild $0 - let new_line = self.state.fields.join(&self.state.ofs); - self.state.set_variable("$0", AwkValue::String(new_line)); - } - v - } - AwkExpr::PostIncrement(name) => { - let current = self.state.get_variable(name).as_number(); - self.state - .set_variable(name, AwkValue::Number(current + 1.0)); - AwkValue::Number(current) // Return old value - } - AwkExpr::PostDecrement(name) => { - let current = self.state.get_variable(name).as_number(); - self.state - .set_variable(name, AwkValue::Number(current - 1.0)); - AwkValue::Number(current) // Return old value - } - AwkExpr::PreIncrement(name) => { - let current = self.state.get_variable(name).as_number(); - let new_val = current + 1.0; - self.state.set_variable(name, AwkValue::Number(new_val)); - AwkValue::Number(new_val) // Return new value - } - AwkExpr::PreDecrement(name) => { - let current = self.state.get_variable(name).as_number(); - let new_val = current - 1.0; - self.state.set_variable(name, AwkValue::Number(new_val)); - AwkValue::Number(new_val) // Return new value - } - AwkExpr::InArray(key, arr_name) => { - let k = self.eval_expr(key).as_string(); - let full_key = format!("{}[{}]", arr_name, k); - let exists = !matches!(self.state.get_variable(&full_key), AwkValue::Uninitialized); - AwkValue::Number(if exists { 1.0 } else { 0.0 }) - } - AwkExpr::FuncCall(name, args) => self.call_function(name, args), - AwkExpr::Regex(pattern) => { - // When used as a standalone expression, /regex/ matches against $0. - // When used as a function argument (gsub, sub, match, split), - // it's evaluated as a string pattern, so return the pattern string. - AwkValue::String(pattern.clone()) - } - AwkExpr::Match(expr, pattern) => { - let s = self.eval_expr(expr).as_string(); - if let Ok(re) = build_regex(pattern) { - AwkValue::Number(if re.is_match(&s) { 1.0 } else { 0.0 }) - } else { - AwkValue::Number(0.0) - } - } - AwkExpr::GetlineFile { var, file } => { - let path_str = self.eval_expr(file).as_string(); - let resolved = if path_str.starts_with('/') { - path_str.clone() - } else { - self.cwd.join(&path_str).to_string_lossy().to_string() - }; - - if !self.ensure_file_loaded(&resolved) { - return AwkValue::Number(-1.0); - } - - let entry = self.file_inputs.get_mut(&resolved).unwrap(); - if entry.1 < entry.0.len() { - let line = entry.0[entry.1].clone(); - entry.1 += 1; - match var { - Some(v) => { - self.state - .variables - .insert(v.clone(), AwkValue::String(line)); - } - None => { - self.state.set_line(&line); - } - } - AwkValue::Number(1.0) // success - } else { - AwkValue::Number(0.0) // EOF - } - } - } - } - - fn call_function(&mut self, name: &str, args: &[AwkExpr]) -> AwkValue { - match name { - "length" => { - if args.is_empty() { - AwkValue::Number(self.state.get_field(0).as_string().len() as f64) - } else { - // Check if the argument is an array name - if so, return element count - if let AwkExpr::Variable(ref arr_name) = args[0] { - let prefix = format!("{}[", arr_name); - let count = self - .state - .variables - .keys() - .filter(|k| k.starts_with(&prefix)) - .count(); - if count > 0 { - return AwkValue::Number(count as f64); - } - } - AwkValue::Number(self.eval_expr(&args[0]).as_string().len() as f64) - } - } - "substr" => { - if args.len() < 2 { - return AwkValue::Uninitialized; - } - let s = self.eval_expr(&args[0]).as_string(); - let start = (self.eval_expr(&args[1]).as_number() as usize).saturating_sub(1); - let len = if args.len() > 2 { - self.eval_expr(&args[2]).as_number() as usize - } else { - s.len() - }; - let end = (start + len).min(s.len()); - AwkValue::String(s.chars().skip(start).take(end - start).collect()) - } - "index" => { - if args.len() < 2 { - return AwkValue::Number(0.0); - } - let s = self.eval_expr(&args[0]).as_string(); - let t = self.eval_expr(&args[1]).as_string(); - match s.find(&t) { - Some(i) => AwkValue::Number((i + 1) as f64), - None => AwkValue::Number(0.0), - } - } - "split" => { - if args.len() < 2 { - return AwkValue::Number(0.0); - } - let s = self.eval_expr(&args[0]).as_string(); - let sep = if args.len() > 2 { - self.eval_expr(&args[2]).as_string() - } else { - self.state.fs.clone() - }; - - let parts: Vec<&str> = if sep == " " { - s.split_whitespace().collect() - } else { - s.split(&sep).collect() - }; - - // Store in array variable - if let AwkExpr::Variable(arr_name) = &args[1] { - for (i, part) in parts.iter().enumerate() { - let key = format!("{}[{}]", arr_name, i + 1); - self.state - .set_variable(&key, AwkValue::String(part.to_string())); - } - } - - AwkValue::Number(parts.len() as f64) - } - "sprintf" => { - if args.is_empty() { - return AwkValue::String(String::new()); - } - let format = self.eval_expr(&args[0]).as_string(); - let values: Vec = args[1..].iter().map(|a| self.eval_expr(a)).collect(); - match self.format_string(&format, &values) { - Ok(s) => AwkValue::String(s), - Err(e) => { - self.stderr_output.push_str(&e); - self.stderr_output.push('\n'); - AwkValue::String(String::new()) - } - } - } - "toupper" => { - if args.is_empty() { - return AwkValue::Uninitialized; - } - AwkValue::String(self.eval_expr(&args[0]).as_string().to_uppercase()) - } - "tolower" => { - if args.is_empty() { - return AwkValue::Uninitialized; - } - AwkValue::String(self.eval_expr(&args[0]).as_string().to_lowercase()) - } - "gsub" | "sub" => { - // gsub(regexp, replacement, target) - if args.len() < 2 { - return AwkValue::Number(0.0); - } - let pattern = self.eval_expr(&args[0]).as_string(); - let replacement = self.eval_expr(&args[1]).as_string(); - - let target_expr = if args.len() > 2 { - args[2].clone() - } else { - AwkExpr::Field(Box::new(AwkExpr::Number(0.0))) - }; - - let target = self.eval_expr(&target_expr).as_string(); - - if let Ok(re) = build_regex(&pattern) { - let (result, count) = if name == "gsub" { - let count = re.find_iter(&target).count(); - ( - re.replace_all(&target, replacement.as_str()).to_string(), - count, - ) - } else { - let count = if re.is_match(&target) { 1 } else { 0 }; - (re.replace(&target, replacement.as_str()).to_string(), count) - }; - - // Update the target variable or field - match &target_expr { - AwkExpr::Variable(name) => { - self.state.set_variable(name, AwkValue::String(result)); - } - AwkExpr::Field(index) => { - let n = self.eval_expr(index).as_number() as usize; - if n == 0 { - // $0 is stored as a variable - self.state.set_variable("$0", AwkValue::String(result)); - } - // For other fields, we'd need to update the fields vec - // and rebuild $0, but for now we just support $0 - } - _ => {} - } - - AwkValue::Number(count as f64) - } else { - AwkValue::Number(0.0) - } - } - "int" => { - if args.is_empty() { - return AwkValue::Number(0.0); - } - AwkValue::Number(self.eval_expr(&args[0]).as_number().trunc()) - } - "sqrt" => { - if args.is_empty() { - return AwkValue::Number(0.0); - } - AwkValue::Number(self.eval_expr(&args[0]).as_number().sqrt()) - } - "sin" => { - if args.is_empty() { - return AwkValue::Number(0.0); - } - AwkValue::Number(self.eval_expr(&args[0]).as_number().sin()) - } - "cos" => { - if args.is_empty() { - return AwkValue::Number(0.0); - } - AwkValue::Number(self.eval_expr(&args[0]).as_number().cos()) - } - "log" => { - if args.is_empty() { - return AwkValue::Number(0.0); - } - AwkValue::Number(self.eval_expr(&args[0]).as_number().ln()) - } - "exp" => { - if args.is_empty() { - return AwkValue::Number(0.0); - } - AwkValue::Number(self.eval_expr(&args[0]).as_number().exp()) - } - "match" => { - if args.len() < 2 { - return AwkValue::Number(0.0); - } - let s = self.eval_expr(&args[0]).as_string(); - let pattern = self.eval_expr(&args[1]).as_string(); - // Extract capture array name from 3rd arg (gawk extension) - let arr_name = if args.len() >= 3 { - if let AwkExpr::Variable(name) = &args[2] { - Some(name.clone()) - } else { - None - } - } else { - None - }; - if let Ok(re) = build_regex(&pattern) { - if let Some(caps) = re.captures(&s) { - let m = caps.get(0).unwrap(); - let rstart = m.start() + 1; // awk is 1-indexed - let rlength = m.end() - m.start(); - self.state - .set_variable("RSTART", AwkValue::Number(rstart as f64)); - self.state - .set_variable("RLENGTH", AwkValue::Number(rlength as f64)); - // Populate capture array if 3rd arg provided - if let Some(ref arr) = arr_name { - // arr[0] = entire match - let full_key = format!("{}[0]", arr); - self.state - .set_variable(&full_key, AwkValue::String(m.as_str().to_string())); - // arr[1..N] = capture groups - for i in 1..caps.len() { - let key = format!("{}[{}]", arr, i); - let val = caps - .get(i) - .map(|c| c.as_str().to_string()) - .unwrap_or_default(); - self.state.set_variable(&key, AwkValue::String(val)); - } - } - AwkValue::Number(rstart as f64) - } else { - self.state.set_variable("RSTART", AwkValue::Number(0.0)); - self.state.set_variable("RLENGTH", AwkValue::Number(-1.0)); - AwkValue::Number(0.0) - } - } else { - AwkValue::Number(0.0) - } - } - "gensub" => { - // gensub(regexp, replacement, how [, target]) - if args.len() < 3 { - return AwkValue::Uninitialized; - } - let pattern = self.eval_expr(&args[0]).as_string(); - let replacement = self.eval_expr(&args[1]).as_string(); - let how = self.eval_expr(&args[2]).as_string(); - let target = if args.len() > 3 { - self.eval_expr(&args[3]).as_string() - } else { - self.state.get_field(0).as_string() - }; - if let Ok(re) = build_regex(&pattern) { - if how == "g" || how == "G" { - AwkValue::String(re.replace_all(&target, replacement.as_str()).to_string()) - } else { - // Replace nth occurrence (default 1st) - let n = how.parse::().unwrap_or(1); - let mut count = 0; - let result = re.replace_all(&target, |caps: ®ex::Captures| -> String { - count += 1; - if count == n { - replacement.clone() - } else { - caps[0].to_string() - } - }); - AwkValue::String(result.to_string()) - } - } else { - AwkValue::String(target) - } - } - "__getline" => { - // Plain getline as expression — advance to next input line, return 1/0 - self.line_index += 1; - if self.line_index < self.input_lines.len() { - let line = self.input_lines[self.line_index].clone(); - self.state.set_line(&line); - AwkValue::Number(1.0) - } else { - AwkValue::Number(0.0) - } - } - "__array_access" => { - // Internal function for array indexing: arr[index] - if args.len() < 2 { - return AwkValue::Uninitialized; - } - let arr_name = if let AwkExpr::Variable(name) = &args[0] { - name.clone() - } else { - return AwkValue::Uninitialized; - }; - let index = self.eval_expr(&args[1]); - let key = format!("{}[{}]", arr_name, index.as_string()); - self.state.get_variable(&key) - } - "__ternary" => { - // Ternary operator: cond ? then : else - if args.len() < 3 { - return AwkValue::Uninitialized; - } - let cond = self.eval_expr(&args[0]); - if cond.as_bool() { - self.eval_expr(&args[1]) - } else { - self.eval_expr(&args[2]) - } - } - _ => { - // Check for user-defined function - if let Some(func) = self.functions.get(name).cloned() { - self.call_user_function(&func, args) - } else { - AwkValue::Uninitialized - } - } - } - } - - fn call_user_function(&mut self, func: &AwkFunctionDef, args: &[AwkExpr]) -> AwkValue { - // THREAT[TM-DOS-027]: Limit recursion depth to prevent stack overflow - if self.call_depth >= MAX_AWK_CALL_DEPTH { - return AwkValue::Uninitialized; - } - self.call_depth += 1; - - // Save current local variables that will be shadowed - let mut saved: Vec<(String, AwkValue)> = Vec::new(); - for param in &func.params { - saved.push((param.clone(), self.state.get_variable(param))); - } - - // Bind arguments to parameters - for (i, param) in func.params.iter().enumerate() { - let val = if i < args.len() { - self.eval_expr(&args[i]) - } else { - AwkValue::Uninitialized - }; - self.state.set_variable(param, val); - } - - // Execute function body, capture return value - let mut return_value = AwkValue::Uninitialized; - for action in &func.body.clone() { - match self.exec_action(action) { - AwkFlow::Return(val) => { - return_value = val; - break; - } - AwkFlow::Exit(_) => break, - _ => {} - } - } - - // Restore saved variables - for (name, val) in saved { - self.state.set_variable(&name, val); - } - - self.call_depth -= 1; - return_value - } - - /// Max width/precision for format specifiers to prevent memory exhaustion - const MAX_FORMAT_WIDTH: usize = super::MAX_FORMAT_WIDTH; - - fn format_string( - &self, - format: &str, - values: &[AwkValue], - ) -> std::result::Result { - let mut result = String::new(); - let mut chars = format.chars().peekable(); - let mut value_idx = 0; - - while let Some(c) = chars.next() { - if c == '\\' { - // Handle escape sequences in format strings - match chars.peek() { - Some('n') => { - chars.next(); - result.push('\n'); - } - Some('t') => { - chars.next(); - result.push('\t'); - } - Some('r') => { - chars.next(); - result.push('\r'); - } - Some('\\') => { - chars.next(); - result.push('\\'); - } - _ => result.push('\\'), - } - } else if c == '%' { - if chars.peek() == Some(&'%') { - chars.next(); - result.push('%'); - continue; - } - - // Parse format specifier: %[flags][width][.precision]type - let mut left_align = false; - let mut zero_pad = false; - let mut plus_sign = false; - let mut width: Option = None; - let mut precision: Option = None; - let mut conversion = ' '; - - // Parse flags - loop { - match chars.peek() { - Some(&'-') => { - left_align = true; - chars.next(); - } - Some(&'0') if width.is_none() => { - zero_pad = true; - chars.next(); - } - Some(&'+') => { - plus_sign = true; - chars.next(); - } - _ => break, - } - } - - // Parse width - let mut w = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_digit() { - w.push(c); - chars.next(); - } else { - break; - } - } - if !w.is_empty() - && let Ok(w_val) = w.parse::() - { - if w_val > Self::MAX_FORMAT_WIDTH { - return Err(format!( - "awk: format width {} exceeds maximum ({})", - w_val, - Self::MAX_FORMAT_WIDTH - )); - } - width = Some(w_val); - } - - // Parse precision - if chars.peek() == Some(&'.') { - chars.next(); - let mut p = String::new(); - while let Some(&c) = chars.peek() { - if c.is_ascii_digit() { - p.push(c); - chars.next(); - } else { - break; - } - } - precision = if p.is_empty() { - Some(0) - } else if let Ok(p_val) = p.parse::() { - if p_val > Self::MAX_FORMAT_WIDTH { - return Err(format!( - "awk: format precision {} exceeds maximum ({})", - p_val, - Self::MAX_FORMAT_WIDTH - )); - } - Some(p_val) - } else { - None - }; - } - - // Parse conversion character - if let Some(&c) = chars.peek() - && c.is_ascii_alphabetic() - { - conversion = c; - chars.next(); - } - - if value_idx < values.len() { - let val = &values[value_idx]; - value_idx += 1; - - let formatted = match conversion { - 'd' | 'i' => { - let n = val.as_number() as i64; - if plus_sign && n >= 0 { - format!("+{}", n) - } else { - format!("{}", n) - } - } - 'f' => { - let n = val.as_number(); - let prec = precision.unwrap_or(6); - format!("{:.prec$}", n) - } - 'g' => { - let n = val.as_number(); - let prec = precision.unwrap_or(6); - // %g: use shorter of %e or %f, strip trailing zeros - let s = format!("{:.prec$e}", n); - let f = format!("{:.prec$}", n); - if s.len() < f.len() { s } else { f } - } - 'e' | 'E' => { - let n = val.as_number(); - let prec = precision.unwrap_or(6); - format!("{:.prec$e}", n) - } - 's' => { - let mut s = val.as_string(); - if let Some(p) = precision { - s = s.chars().take(p).collect(); - } - s - } - 'c' => { - // %c: print character from ASCII code or first char of string - let n = val.as_number(); - if n > 0.0 && n < 128.0 { - String::from(n as u8 as char) - } else { - let s = val.as_string(); - s.chars().next().map(String::from).unwrap_or_default() - } - } - 'x' | 'X' => { - let n = val.as_number() as i64; - if conversion == 'X' { - format!("{:X}", n) - } else { - format!("{:x}", n) - } - } - 'o' => { - let n = val.as_number() as i64; - format!("{:o}", n) - } - _ => val.as_string(), - }; - - // Apply width and alignment - if let Some(w) = width { - if formatted.len() < w { - let padding = w - formatted.len(); - if left_align { - result.push_str(&formatted); - for _ in 0..padding { - result.push(' '); - } - } else if zero_pad - && matches!(conversion, 'd' | 'i' | 'f' | 'x' | 'X' | 'o') - { - for _ in 0..padding { - result.push('0'); - } - result.push_str(&formatted); - } else { - for _ in 0..padding { - result.push(' '); - } - result.push_str(&formatted); - } - } else { - result.push_str(&formatted); - } - } else { - result.push_str(&formatted); - } - } - } else { - result.push(c); - } - } - - Ok(result) - } - - /// Total bytes buffered across all output streams. - fn total_output_bytes(&self) -> usize { - self.output.len() - + self.stderr_output.len() - + self.file_outputs.values().map(|v| v.len()).sum::() - + self.file_appends.values().map(|v| v.len()).sum::() - } - - /// Write text to stdout buffer or to a file output buffer based on the target. - /// Returns `false` if the write would exceed [`MAX_AWK_OUTPUT_BYTES`]. - fn write_output(&mut self, text: &str, target: &Option) -> bool { - if self.total_output_bytes() + text.len() > MAX_AWK_OUTPUT_BYTES { - self.stderr_output - .push_str("awk: output limit exceeded (max 10MB)\n"); - return false; - } - match target { - None => self.output.push_str(text), - Some(AwkOutputTarget::Truncate(expr)) | Some(AwkOutputTarget::Append(expr)) => { - let path = self.eval_expr(expr).as_string(); - // Intercept /dev/stderr and /dev/stdout — route to streams, not VFS - if path == "/dev/stderr" { - self.stderr_output.push_str(text); - } else if path == "/dev/stdout" { - self.output.push_str(text); - } else if matches!(target, Some(AwkOutputTarget::Append(_))) { - self.file_appends.entry(path).or_default().push_str(text); - } else { - self.file_outputs.entry(path).or_default().push_str(text); - } - } - } - true - } - - /// Execute action. Returns flow control signal. - fn exec_action(&mut self, action: &AwkAction) -> AwkFlow { - match action { - AwkAction::Print(exprs, target) => { - let parts: Vec = exprs - .iter() - .map(|e| self.eval_expr(e).as_string()) - .collect(); - let mut text = parts.join(&self.state.ofs); - text.push_str(&self.state.ors); - if !self.write_output(&text, target) { - return AwkFlow::Exit(Some(2)); - } - AwkFlow::Continue - } - AwkAction::Printf(format_expr, args, target) => { - let format_str = self.eval_expr(format_expr).as_string(); - let values: Vec = args.iter().map(|a| self.eval_expr(a)).collect(); - match self.format_string(&format_str, &values) { - Ok(text) => { - if !self.write_output(&text, target) { - return AwkFlow::Exit(Some(2)); - } - AwkFlow::Continue - } - Err(e) => { - self.stderr_output.push_str(&e); - self.stderr_output.push('\n'); - AwkFlow::Exit(Some(2)) - } - } - } - AwkAction::Assign(name, expr) => { - let value = self.eval_expr(expr); - self.state.set_variable(name, value); - AwkFlow::Continue - } - AwkAction::ArrayAssign(name, key, val) => { - let k = self.eval_expr(key).as_string(); - let v = self.eval_expr(val); - let full_key = format!("{}[{}]", name, k); - self.state.set_variable(&full_key, v); - AwkFlow::Continue - } - AwkAction::If(cond, then_actions, else_actions) => { - let actions = if self.eval_expr(cond).as_bool() { - then_actions - } else { - else_actions - }; - for action in actions { - match self.exec_action(action) { - AwkFlow::Continue => {} - flow => return flow, - } - } - AwkFlow::Continue - } - AwkAction::While(cond, actions) => { - let mut iters = 0; - while self.eval_expr(cond).as_bool() { - iters += 1; - if iters > self.max_loop_iterations { - break; - } - let mut do_break = false; - for action in actions { - match self.exec_action(action) { - AwkFlow::Continue => {} - AwkFlow::Break => { - do_break = true; - break; - } - AwkFlow::LoopContinue => break, - flow => return flow, - } - } - if do_break { - break; - } - } - AwkFlow::Continue - } - AwkAction::DoWhile(cond, actions) => { - let mut iters = 0; - loop { - iters += 1; - if iters > self.max_loop_iterations { - break; - } - let mut do_break = false; - for action in actions { - match self.exec_action(action) { - AwkFlow::Continue => {} - AwkFlow::Break => { - do_break = true; - break; - } - AwkFlow::LoopContinue => break, - flow => return flow, - } - } - if do_break || !self.eval_expr(cond).as_bool() { - break; - } - } - AwkFlow::Continue - } - AwkAction::For(init, cond, update, actions) => { - self.exec_action(init); - let mut iters = 0; - while self.eval_expr(cond).as_bool() { - iters += 1; - if iters > self.max_loop_iterations { - break; - } - let mut do_break = false; - for action in actions { - match self.exec_action(action) { - AwkFlow::Continue => {} - AwkFlow::Break => { - do_break = true; - break; - } - AwkFlow::LoopContinue => break, - flow => return flow, - } - } - if do_break { - break; - } - self.exec_action(update); - } - AwkFlow::Continue - } - AwkAction::ForIn(var, arr_name, actions) => { - // Collect array keys matching the pattern arr_name[*] - let prefix = format!("{}[", arr_name); - let mut keys: Vec = self - .state - .variables - .keys() - .filter(|k| k.starts_with(&prefix) && k.ends_with(']')) - .map(|k| k[prefix.len()..k.len() - 1].to_string()) - .collect(); - // Sort for deterministic iteration: numeric keys first, then lexical - keys.sort_by(|a, b| match (a.parse::(), b.parse::()) { - (Ok(na), Ok(nb)) => na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal), - _ => a.cmp(b), - }); - - let mut iters = 0; - for key in keys { - iters += 1; - if iters > self.max_loop_iterations { - break; - } - self.state.set_variable(var, AwkValue::String(key)); - let mut do_break = false; - for action in actions { - match self.exec_action(action) { - AwkFlow::Continue => {} - AwkFlow::Break => { - do_break = true; - break; - } - AwkFlow::LoopContinue => break, - flow => return flow, - } - } - if do_break { - break; - } - } - AwkFlow::Continue - } - AwkAction::Delete(arr_name, key) => { - let k = self.eval_expr(key).as_string(); - if k == "*" { - // Delete all entries in the array - let prefix = format!("{}[", arr_name); - let keys: Vec = self - .state - .variables - .keys() - .filter(|k| k.starts_with(&prefix)) - .cloned() - .collect(); - for key in keys { - self.state.variables.remove(&key); - } - } else { - let full_key = format!("{}[{}]", arr_name, k); - self.state.variables.remove(&full_key); - } - AwkFlow::Continue - } - AwkAction::Next => AwkFlow::Next, - AwkAction::Getline => { - // Advance to next input line and update $0, NR, NF, FNR - self.line_index += 1; - if self.line_index < self.input_lines.len() { - let line = self.input_lines[self.line_index].clone(); - self.state.set_line(&line); - } - AwkFlow::Continue - } - AwkAction::GetlineFile { var, file } => { - // Read next line from file (action context — return value discarded). - let path_str = self.eval_expr(file).as_string(); - let resolved = if path_str.starts_with('/') { - path_str.clone() - } else { - self.cwd.join(&path_str).to_string_lossy().to_string() - }; - - if !self.ensure_file_loaded(&resolved) { - return AwkFlow::Continue; - } - - let entry = self.file_inputs.get_mut(&resolved).unwrap(); - if entry.1 < entry.0.len() { - let line = entry.0[entry.1].clone(); - entry.1 += 1; - match var { - Some(v) => { - self.state - .variables - .insert(v.clone(), AwkValue::String(line)); - } - None => { - self.state.set_line(&line); - } - } - } - AwkFlow::Continue - } - AwkAction::Break => AwkFlow::Break, - AwkAction::Continue => AwkFlow::LoopContinue, - AwkAction::Exit(expr) => { - let code = expr.as_ref().map(|e| self.eval_expr(e).as_number() as i32); - AwkFlow::Exit(code) - } - AwkAction::Return(expr) => { - let val = expr - .as_ref() - .map(|e| self.eval_expr(e)) - .unwrap_or(AwkValue::Uninitialized); - AwkFlow::Return(val) - } - AwkAction::Expression(expr) => { - self.eval_expr(expr); - AwkFlow::Continue - } - } - } - - fn matches_pattern(&mut self, pattern: &AwkPattern) -> bool { - match pattern { - AwkPattern::Regex(re) => { - let line = self.state.get_field(0).as_string(); - re.is_match(&line) - } - AwkPattern::Expression(expr) => self.eval_expr(expr).as_bool(), - // Range patterns are handled specially via matches_pattern_with_index - // which tracks state. This arm shouldn't normally be reached for ranges - // in the main loop, but handle it defensively. - AwkPattern::Range(_, _) => false, - } - } - - /// Check if a rule's pattern matches, with range state tracking by rule index. - fn matches_pattern_with_index(&mut self, pattern: &AwkPattern, rule_idx: usize) -> bool { - match pattern { - AwkPattern::Range(start, end) => { - let active = *self.range_active.get(&rule_idx).unwrap_or(&false); - if active { - // Already in range — check if end pattern matches - if self.matches_pattern(end) { - // End pattern matched: include this line, deactivate range - self.range_active.insert(rule_idx, false); - } - true - } else { - // Not in range — check if start pattern matches - if self.matches_pattern(start) { - // If end also matches this same line, range closes immediately. - let end_matches = self.matches_pattern(end); - self.range_active.insert(rule_idx, !end_matches); - true - } else { - false - } - } - } - other => self.matches_pattern(other), - } - } -} - -impl Awk { - /// Process C-style escape sequences in a string (e.g., \t → tab, \n → newline) - fn process_escape_sequences(s: &str) -> String { - let mut result = String::new(); - let mut chars = s.chars(); - while let Some(c) = chars.next() { - if c == '\\' { - match chars.next() { - Some('t') => result.push('\t'), - Some('n') => result.push('\n'), - Some('r') => result.push('\r'), - Some('\\') => result.push('\\'), - Some('a') => result.push('\x07'), - Some('b') => result.push('\x08'), - Some('f') => result.push('\x0C'), - Some(other) => { - result.push('\\'); - result.push(other); - } - None => result.push('\\'), - } - } else { - result.push(c); - } - } - result - } -} - -#[async_trait] -impl Builtin for Awk { - async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( - ctx.args, - "Usage: awk [OPTION]... 'program' [FILE]...\nPattern scanning and processing language.\n\n -F SEP\t\tuse SEP as field separator\n -v var=val\tassign variable before execution\n -f progfile\tread program from file\n --csv, -k\tCSV mode (set field separator to comma)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", - Some("awk (bashkit) 0.1"), - ) { - return Ok(r); - } - let mut program_str = String::new(); - let mut files: Vec = Vec::new(); - let mut field_sep = " ".to_string(); - let mut pre_vars: Vec<(String, String)> = Vec::new(); - let mut csv_mode = false; - let mut i = 0; - - while i < ctx.args.len() { - let arg = &ctx.args[i]; - if arg == "--csv" || arg == "-k" { - csv_mode = true; - field_sep = ",".to_string(); - } else if arg == "-F" { - i += 1; - if i < ctx.args.len() { - field_sep = ctx.args[i].clone(); - } - } else if let Some(sep) = arg.strip_prefix("-F") { - field_sep = sep.to_string(); - } else if arg == "-v" { - // Variable assignment: -v var=value - i += 1; - if i < ctx.args.len() - && let Some(eq_pos) = ctx.args[i].find('=') - { - let name = ctx.args[i][..eq_pos].to_string(); - let mut value = ctx.args[i][eq_pos + 1..].to_string(); - // Strip surrounding quotes if present (shell may pass them) - if (value.starts_with('"') && value.ends_with('"')) - || (value.starts_with('\'') && value.ends_with('\'')) - { - value = value[1..value.len() - 1].to_string(); - } - pre_vars.push((name, value)); - } - } else if arg == "-f" { - // Read program from file - i += 1; - if i < ctx.args.len() { - let path = if ctx.args[i].starts_with('/') { - std::path::PathBuf::from(&ctx.args[i]) - } else { - ctx.cwd.join(&ctx.args[i]) - }; - program_str = match read_text_file(&*ctx.fs, &path, "awk").await { - Ok(t) => t, - Err(e) => return Ok(e), - }; - } - } else if arg.starts_with('-') { - // Unknown option - ignore - } else if program_str.is_empty() { - program_str = arg.clone(); - } else { - files.push(arg.clone()); - } - i += 1; - } - - if program_str.is_empty() { - return Err(Error::Execution("awk: no program given".to_string())); - } - - let program_str = normalize_awk_newlines(&program_str); - let mut parser = AwkParser::new(&program_str); - let program = parser.parse()?; - - let mut interp = AwkInterpreter::new(); - interp.max_loop_iterations = ctx - .execution_extension::() - .map(|limits| limits.max_loop_iterations) - .unwrap_or_else(|| ExecutionLimits::default().max_loop_iterations); - interp.functions = program.functions.clone(); - interp.state.fs = Self::process_escape_sequences(&field_sep); - interp.fs = Some(ctx.fs.clone()); - interp.cwd = ctx.cwd.clone(); - if csv_mode { - interp.state.csv_mode = true; - interp.state.ofs = ",".to_string(); - } - - // Set pre-assigned variables (-v) - for (name, value) in &pre_vars { - let awk_val = if let Ok(n) = value.parse::() { - AwkValue::Number(n) - } else { - AwkValue::String(value.clone()) - }; - interp.state.set_variable(name, awk_val); - } - - // Run BEGIN actions - let mut exit_code: Option = None; - for action in &program.begin_actions { - if let AwkFlow::Exit(code) = interp.exec_action(action) { - exit_code = code; - // Run END actions even after exit - for end_action in &program.end_actions { - if let AwkFlow::Exit(_) = interp.exec_action(end_action) { - break; - } - } - Self::flush_file_outputs(&interp, &ctx).await?; - let mut result = ExecResult::with_code(interp.output, exit_code.unwrap_or(0)); - result.stderr = interp.stderr_output; - return Ok(result); - } - } - - // Process input - let inputs: Vec = if files.is_empty() { - vec![ctx.stdin.unwrap_or("").to_string()] - } else { - let mut inputs = Vec::new(); - for file in &files { - let path = if file.starts_with('/') { - std::path::PathBuf::from(file) - } else { - ctx.cwd.join(file) - }; - - let text = match read_text_file(&*ctx.fs, &path, "awk").await { - Ok(t) => t, - Err(e) => return Ok(e), - }; - inputs.push(text); - } - inputs - }; - - 'files: for (file_idx, input) in inputs.iter().enumerate() { - interp.state.fnr = 0; - // Set FILENAME to current file path, or empty for stdin - if !files.is_empty() { - interp.state.variables.insert( - "FILENAME".to_string(), - AwkValue::String(files[file_idx].clone()), - ); - } else { - interp - .state - .variables - .insert("FILENAME".to_string(), AwkValue::String(String::new())); - } - // Index-based iteration so getline can advance the index - interp.input_lines = input.lines().map(|l| l.to_string()).collect(); - interp.line_index = 0; - - while interp.line_index < interp.input_lines.len() { - let line = interp.input_lines[interp.line_index].clone(); - interp.state.set_line(&line); - - for (rule_idx, rule) in program.main_rules.iter().enumerate() { - // Check pattern (with range state tracking) - let matches = match &rule.pattern { - Some(pattern) => interp.matches_pattern_with_index(pattern, rule_idx), - None => true, - }; - - if matches { - let mut next_record = false; - for action in &rule.actions { - match interp.exec_action(action) { - AwkFlow::Continue => {} - AwkFlow::Next => { - next_record = true; - break; - } - AwkFlow::Exit(code) => { - exit_code = code; - break 'files; - } - _ => {} - } - } - if next_record { - break; - } - } - } - interp.line_index += 1; - } - } - - // Run END actions (awk runs END even after exit in main body) - for action in &program.end_actions { - if let AwkFlow::Exit(code) = interp.exec_action(action) { - if exit_code.is_none() { - exit_code = code; - } - break; - } - } - - Self::flush_file_outputs(&interp, &ctx).await?; - let mut result = ExecResult::with_code(interp.output, exit_code.unwrap_or(0)); - result.stderr = interp.stderr_output; - Ok(result) - } -} - -impl Awk { - /// Flush buffered file outputs to VFS. - async fn flush_file_outputs(interp: &AwkInterpreter, ctx: &Context<'_>) -> Result<()> { - // Truncate mode: write entire buffer as file content - for (path, content) in &interp.file_outputs { - let path = if path.starts_with('/') { - std::path::PathBuf::from(path) - } else { - ctx.cwd.join(path) - }; - ctx.fs.write_file(&path, content.as_bytes()).await?; - } - // Append mode: append buffer to existing file (or create) - for (path, content) in &interp.file_appends { - let path = if path.starts_with('/') { - std::path::PathBuf::from(path) - } else { - ctx.cwd.join(path) - }; - ctx.fs.append_file(&path, content.as_bytes()).await?; - } - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::fs::{FileSystem, InMemoryFs}; - use std::collections::HashMap; - use std::path::PathBuf; - use std::sync::Arc; - - async fn run_awk(args: &[&str], stdin: Option<&str>) -> Result { - let awk = Awk; - let fs = Arc::new(InMemoryFs::new()); - let mut vars = HashMap::new(); - let mut cwd = PathBuf::from("/"); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - - let ctx = Context { - args: &args, - env: &HashMap::new(), - variables: &mut vars, - cwd: &mut cwd, - fs, - stdin, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - awk.execute(ctx).await - } - - #[tokio::test] - async fn test_awk_print_all() { - let result = run_awk(&["{print}"], Some("hello\nworld")).await.unwrap(); - assert_eq!(result.stdout, "hello\nworld\n"); - } - - #[tokio::test] - async fn test_awk_print_field() { - let result = run_awk(&["{print $1}"], Some("hello world\nfoo bar")) - .await - .unwrap(); - assert_eq!(result.stdout, "hello\nfoo\n"); - } - - #[tokio::test] - async fn test_awk_print_multiple_fields() { - let result = run_awk(&["{print $2, $1}"], Some("hello world")) - .await - .unwrap(); - assert_eq!(result.stdout, "world hello\n"); - } - - #[tokio::test] - async fn test_awk_field_separator() { - let result = run_awk(&["-F:", "{print $1}"], Some("root:x:0:0")) - .await - .unwrap(); - assert_eq!(result.stdout, "root\n"); - } - - #[tokio::test] - async fn test_awk_nr() { - let result = run_awk(&["{print NR, $0}"], Some("a\nb\nc")).await.unwrap(); - assert_eq!(result.stdout, "1 a\n2 b\n3 c\n"); - } - - #[tokio::test] - async fn test_awk_nf() { - let result = run_awk(&["{print NF}"], Some("a b c\nd e")).await.unwrap(); - assert_eq!(result.stdout, "3\n2\n"); - } - - #[tokio::test] - async fn test_awk_begin_end() { - let result = run_awk( - &["BEGIN{print \"start\"} {print} END{print \"end\"}"], - Some("middle"), - ) - .await - .unwrap(); - assert_eq!(result.stdout, "start\nmiddle\nend\n"); - } - - #[tokio::test] - async fn test_awk_pattern() { - let result = run_awk(&["/hello/{print}"], Some("hello\nworld\nhello again")) - .await - .unwrap(); - assert_eq!(result.stdout, "hello\nhello again\n"); - } - - #[tokio::test] - async fn test_awk_condition() { - let result = run_awk(&["NR==2{print}"], Some("line1\nline2\nline3")) - .await - .unwrap(); - assert_eq!(result.stdout, "line2\n"); - } - - #[tokio::test] - async fn test_awk_arithmetic() { - let result = run_awk(&["{print $1 + $2}"], Some("1 2\n3 4")) - .await - .unwrap(); - assert_eq!(result.stdout, "3\n7\n"); - } - - #[tokio::test] - async fn test_awk_variables() { - let result = run_awk(&["{sum += $1} END{print sum}"], Some("1\n2\n3\n4")) - .await - .unwrap(); - assert_eq!(result.stdout, "10\n"); - } - - #[tokio::test] - async fn test_awk_unicode_identifier_no_panic() { - let result = run_awk(&["BEGIN{café=7; print café}"], None).await.unwrap(); - assert_eq!(result.stdout, "7\n"); - } - - #[tokio::test] - async fn test_awk_length() { - let result = run_awk(&["{print length($0)}"], Some("hello\nhi")) - .await - .unwrap(); - assert_eq!(result.stdout, "5\n2\n"); - } - - #[tokio::test] - async fn test_awk_substr() { - let result = run_awk(&["{print substr($0, 2, 3)}"], Some("hello")) - .await - .unwrap(); - assert_eq!(result.stdout, "ell\n"); - } - - #[tokio::test] - async fn test_awk_toupper() { - let result = run_awk(&["{print toupper($0)}"], Some("hello")) - .await - .unwrap(); - assert_eq!(result.stdout, "HELLO\n"); - } - - #[tokio::test] - async fn test_awk_multi_statement() { - // Test multiple statements separated by semicolon - let result = run_awk(&["{x=1; print x}"], Some("test")).await.unwrap(); - assert_eq!(result.stdout, "1\n"); - } - - #[tokio::test] - async fn test_awk_gsub_with_print() { - // gsub with regex literal followed by print - let result = run_awk( - &[r#"{gsub(/hello/, "hi"); print}"#], - Some("hello hello hello"), - ) - .await - .unwrap(); - assert_eq!(result.stdout, "hi hi hi\n"); - } - - #[tokio::test] - async fn test_awk_split_with_array_access() { - // split with array indexing - let result = run_awk( - &[r#"{n = split($0, arr, ":"); print arr[2]}"#], - Some("a:b:c"), - ) - .await - .unwrap(); - assert_eq!(result.stdout, "b\n"); - } - - /// TM-DOS-027: Deeply nested parenthesized expressions must be rejected - #[test] - fn test_awk_parser_depth_limit_parens() { - // Build expression with 150 nested parens: (((((...(1)...)))) - let depth = 150; - let open = "(".repeat(depth); - let close = ")".repeat(depth); - let program = format!("{{print {open}1{close}}}"); - - let mut parser = AwkParser::new(&program); - let result = parser.parse(); - assert!(result.is_err(), "deeply nested parens must be rejected"); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("nesting too deep"), - "error should mention nesting: {err}" - ); - } - - /// TM-DOS-027: Deeply chained unary operators must be rejected - #[test] - fn test_awk_parser_depth_limit_unary() { - // Build expression with 200 chained negations: - - - ... - 1 - let depth = 200; - let prefix = "- ".repeat(depth); - let program = format!("{{print {prefix}1}}"); - - let mut parser = AwkParser::new(&program); - let result = parser.parse(); - assert!(result.is_err(), "deeply chained unary ops must be rejected"); - let err = result.unwrap_err().to_string(); - assert!( - err.contains("nesting too deep"), - "error should mention nesting: {err}" - ); - } - - /// TM-DOS-027: Moderate nesting within limit still works - #[test] - fn test_awk_parser_moderate_nesting_ok() { - // 10 levels of parens should be fine - let depth = 10; - let open = "(".repeat(depth); - let close = ")".repeat(depth); - let program = format!("{{print {open}1{close}}}"); - - let mut parser = AwkParser::new(&program); - let result = parser.parse(); - assert!( - result.is_ok(), - "moderate nesting should succeed: {:?}", // debug-ok: assert-failure message - result.err() - ); - } - - // === New tests for added features === - - #[tokio::test] - async fn test_awk_for_c_style() { - let result = run_awk(&["BEGIN{for(i=1;i<=5;i++) print i}"], None) - .await - .unwrap(); - assert_eq!(result.stdout, "1\n2\n3\n4\n5\n"); - } - - #[tokio::test] - async fn test_awk_for_with_body_block() { - let result = run_awk(&["BEGIN{for(i=0;i<3;i++){print i}}"], None) - .await - .unwrap(); - assert_eq!(result.stdout, "0\n1\n2\n"); - } - - #[tokio::test] - async fn test_awk_while_loop() { - let result = run_awk(&["BEGIN{i=1; while(i<=3){print i; i++}}"], None) - .await - .unwrap(); - assert_eq!(result.stdout, "1\n2\n3\n"); - } - - #[tokio::test] - async fn test_awk_do_while() { - let result = run_awk(&["BEGIN{i=1; do{print i; i++}while(i<=3)}"], None) - .await - .unwrap(); - assert_eq!(result.stdout, "1\n2\n3\n"); - } - - #[tokio::test] - async fn test_awk_post_increment() { - let result = run_awk(&["{print i++}"], Some("a\nb\nc")).await.unwrap(); - assert_eq!(result.stdout, "0\n1\n2\n"); - } - - #[tokio::test] - async fn test_awk_pre_increment() { - let result = run_awk(&["{print ++i}"], Some("a\nb\nc")).await.unwrap(); - assert_eq!(result.stdout, "1\n2\n3\n"); - } - - #[tokio::test] - async fn test_awk_post_decrement() { - let result = run_awk(&["BEGIN{x=3; print x--; print x}"], None) - .await - .unwrap(); - assert_eq!(result.stdout, "3\n2\n"); - } - - #[tokio::test] - async fn test_awk_array_assign() { - let result = run_awk(&[r#"BEGIN{a[1]="x"; a[2]="y"; print a[1], a[2]}"#], None) - .await - .unwrap(); - assert_eq!(result.stdout, "x y\n"); - } - - #[tokio::test] - async fn test_awk_array_in_operator() { - let result = run_awk( - &[r#"BEGIN{a["foo"]=1; if("foo" in a) print "yes"; if("bar" in a) print "no"}"#], - None, - ) - .await - .unwrap(); - assert_eq!(result.stdout, "yes\n"); - } - - #[tokio::test] - async fn test_awk_for_in_loop() { - let result = run_awk( - &[r#"BEGIN{a[1]="x"; a[2]="y"; for(k in a) count++; print count}"#], - None, - ) - .await - .unwrap(); - assert_eq!(result.stdout, "2\n"); - } - - #[tokio::test] - async fn test_awk_delete_array_element() { - let result = run_awk( - &[r#"BEGIN{a[1]=1; a[2]=2; delete a[1]; for(k in a) print k, a[k]}"#], - None, - ) - .await - .unwrap(); - assert_eq!(result.stdout, "2 2\n"); - } - - #[tokio::test] - async fn test_awk_v_flag() { - let result = run_awk(&["-v", "x=hello", "BEGIN{print x}"], None) - .await - .unwrap(); - assert_eq!(result.stdout, "hello\n"); - } - - #[tokio::test] - async fn test_awk_v_flag_numeric() { - let result = run_awk(&["-v", "n=42", "BEGIN{print n+1}"], None) - .await - .unwrap(); - assert_eq!(result.stdout, "43\n"); - } - - #[tokio::test] - async fn test_awk_break_in_for() { - let result = run_awk(&["BEGIN{for(i=1;i<=10;i++){if(i>3) break; print i}}"], None) - .await - .unwrap(); - assert_eq!(result.stdout, "1\n2\n3\n"); - } - - #[tokio::test] - async fn test_awk_continue_in_for() { - let result = run_awk( - &["BEGIN{for(i=1;i<=5;i++){if(i==3) continue; print i}}"], - None, - ) - .await - .unwrap(); - assert_eq!(result.stdout, "1\n2\n4\n5\n"); - } - - #[tokio::test] - async fn test_awk_ternary() { - let result = run_awk(&[r#"{print ($1>2 ? "big" : "small")}"#], Some("1\n3\n2")) - .await - .unwrap(); - assert_eq!(result.stdout, "small\nbig\nsmall\n"); - } - - #[tokio::test] - async fn test_awk_field_assignment() { - let result = run_awk(&[r#"{$2="new"; print}"#], Some("one two three")) - .await - .unwrap(); - assert_eq!(result.stdout, "one new three\n"); - } - - #[tokio::test] - async fn test_awk_csv_to_json_pattern() { - // This is the pattern LLMs use for CSV→JSON conversion - let result = run_awk( - &[ - "-F,", - r#"NR==1{for(i=1;i<=NF;i++) h[i]=$i; next} {for(i=1;i<=NF;i++) printf "%s=%s ", h[i], $i; print ""}"#, - ], - Some("name,age\nalice,30\nbob,25"), - ) - .await - .unwrap(); - assert!(result.stdout.contains("name=alice")); - assert!(result.stdout.contains("age=30")); - assert!(result.stdout.contains("name=bob")); - } - - #[tokio::test] - async fn test_awk_compound_array_assign() { - let result = run_awk( - &[r#"{count[$1]++} END{for(k in count) print k, count[k]}"#], - Some("a\nb\na\nc\nb\na"), - ) - .await - .unwrap(); - // Order may vary, so check contents - assert!(result.stdout.contains("a 3")); - assert!(result.stdout.contains("b 2")); - assert!(result.stdout.contains("c 1")); - } - - #[tokio::test] - async fn test_awk_next_statement() { - let result = run_awk(&["NR==2{next} {print}"], Some("line1\nline2\nline3")) - .await - .unwrap(); - assert_eq!(result.stdout, "line1\nline3\n"); - } - - #[tokio::test] - async fn test_awk_exit_statement() { - let result = run_awk(&["NR==2{exit} {print}"], Some("line1\nline2\nline3")) - .await - .unwrap(); - assert_eq!(result.stdout, "line1\n"); - } - - #[tokio::test] - async fn test_awk_getline_basic() { - let result = run_awk(&["{getline; print}"], Some("line1\nline2")) - .await - .unwrap(); - assert_eq!(result.stdout, "line2\n"); - } - - #[tokio::test] - async fn test_awk_getline_updates_fields() { - let result = run_awk(&["{getline; print $1}"], Some("a b\nc d")) - .await - .unwrap(); - assert_eq!(result.stdout, "c\n"); - } - - #[tokio::test] - async fn test_awk_getline_at_eof() { - // getline at EOF should keep current $0 - let result = run_awk(&["{getline; print}"], Some("only")).await.unwrap(); - assert_eq!(result.stdout, "only\n"); - } - - #[tokio::test] - async fn test_awk_revenue_calculation() { - // This is the exact eval task pattern - let result = run_awk( - &["-F,", "NR>1{total+=$2*$3} END{print total}"], - Some("product,price,quantity\nwidget,10,5\ngadget,25,3\ndoohickey,7,12\nsprocket,15,8"), - ) - .await - .unwrap(); - assert_eq!(result.stdout, "329\n"); - } - - #[tokio::test] - async fn test_awk_printf_parens() { - // printf with parenthesized syntax: printf("format", args) - let result = run_awk( - &[r#"BEGIN{printf("["); printf("%s", "x"); printf("]"); print ""}"#], - Some(""), - ) - .await - .unwrap(); - assert_eq!(result.stdout, "[x]\n"); - } - - #[tokio::test] - async fn test_awk_printf_parens_csv() { - // CSV to JSON pattern using printf with parens - let result = run_awk( - &[ - "-F,", - r#"NR==1{for(i=1;i<=NF;i++) h[i]=$i; next} {printf("%s{", (NR>2?",":"")); for(i=1;i<=NF;i++){printf("%s\"%s\":\"%s\"", (i>1?",":""), h[i], $i)}; printf("}")} END{print ""}"#, - ], - Some("name,age\nalice,30\nbob,25\n"), - ) - .await - .unwrap(); - assert!(result.stdout.contains("alice")); - assert!(result.stdout.contains("bob")); - } - - #[tokio::test] - async fn test_awk_recursive_function_depth_limit() { - // Recursive function should be limited, not stack overflow - let result = run_awk( - &[r#"function r(n) { return r(n+1) } BEGIN { print r(0) }"#], - Some(""), - ) - .await - .unwrap(); - // Should complete without crashing (returns Uninitialized -> empty string) - assert_eq!(result.exit_code, 0); - } - - #[tokio::test] - async fn test_awk_while_loop_limited() { - // Infinite while loop should terminate via default max_loop_iterations - let result = run_awk( - &[r#"BEGIN { i=0; while(1) { i++; if(i>200000) break } print i }"#], - Some(""), - ) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - let count: usize = result.stdout.trim().parse().unwrap(); - // Should be capped at default max_loop_iterations (10_000), not 200_000 - assert!( - count <= 10_001, - "loop ran {} times, expected <= 10001", - count - ); - } - - #[tokio::test] - async fn test_awk_unicode_in_comment() { - // Issue #395: multi-byte Unicode chars in comments should not panic - let result = run_awk(&["# ── header ──────\n{ print $1 }"], Some("hello world\n")) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.trim(), "hello"); - } - - #[tokio::test] - async fn test_awk_unicode_in_string() { - // Multi-byte chars in string literals should not panic - let result = run_awk(&[r#"BEGIN { print "café" }"#], Some("")) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.trim(), "café"); - } - - #[tokio::test] - async fn test_awk_array_assign_field_ref_subscript() { - // Issue #396.1: arr[$1] = $3 should work with field refs as subscripts - let result = run_awk( - &["{ arr[$1] = $2 } END { print arr[\"hello\"] }"], - Some("hello world\n"), - ) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.trim(), "world"); - } - - #[tokio::test] - async fn test_awk_multi_subscript() { - // Issue #396.2: a["x","y"] multi-subscript with SUBSEP - let result = run_awk(&[r#"BEGIN { a["x","y"] = 1; print a["x","y"] }"#], Some("")) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.trim(), "1"); - } - - #[tokio::test] - async fn test_awk_subsep_defined() { - // Issue #396.3: SUBSEP should be defined as \034 - let result = run_awk(&[r#"BEGIN { print length(SUBSEP) }"#], Some("")) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout.trim(), "1"); - } - - #[tokio::test] - async fn test_awk_preincrement_array() { - // Issue #396.4: ++arr[key] should work - let result = run_awk( - &["{ ++count[$1] } END { for (k in count) print k, count[k] }"], - Some("a\nb\na\n"), - ) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("a 2")); - assert!(result.stdout.contains("b 1")); - } - - /// Helper that returns the VFS alongside the result for testing file output. - async fn run_awk_with_fs( - args: &[&str], - stdin: Option<&str>, - ) -> (Result, Arc) { - let awk = Awk; - let fs = Arc::new(InMemoryFs::new()); - let mut vars = HashMap::new(); - let mut cwd = PathBuf::from("/"); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - - let ctx = Context { - args: &args, - env: &HashMap::new(), - variables: &mut vars, - cwd: &mut cwd, - fs: fs.clone(), - stdin, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = awk.execute(ctx).await; - (result, fs) - } - - #[tokio::test] - async fn test_awk_print_redirect_truncate() { - // Issue #607: print ... > file should create file with content - let (result, fs) = run_awk_with_fs(&[r#"BEGIN{print "hello" > "/tmp/out"}"#], None).await; - let result = result.unwrap(); - assert_eq!(result.exit_code, 0); - // stdout should be empty (output went to file) - assert_eq!(result.stdout, ""); - let content = fs - .read_file(std::path::Path::new("/tmp/out")) - .await - .unwrap(); - assert_eq!(String::from_utf8_lossy(&content), "hello\n"); - } - - #[tokio::test] - async fn test_awk_printf_redirect_truncate() { - // Issue #607: printf ... > file should create file with content - let (result, fs) = run_awk_with_fs(&[r#"BEGIN{printf "hello" > "/tmp/out"}"#], None).await; - let result = result.unwrap(); - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout, ""); - let content = fs - .read_file(std::path::Path::new("/tmp/out")) - .await - .unwrap(); - assert_eq!(String::from_utf8_lossy(&content), "hello"); - } - - #[tokio::test] - async fn test_awk_print_redirect_append() { - // Issue #607: print ... >> file should append - let (result, fs) = run_awk_with_fs( - &[r#"BEGIN{print "a" > "/tmp/out"; print "b" >> "/tmp/out"}"#], - None, - ) - .await; - let result = result.unwrap(); - assert_eq!(result.exit_code, 0); - let content = fs - .read_file(std::path::Path::new("/tmp/out")) - .await - .unwrap(); - assert_eq!(String::from_utf8_lossy(&content), "a\nb\n"); - } - - #[tokio::test] - async fn test_awk_print_redirect_multiple_to_same_file() { - // Multiple prints to same file with > should accumulate (AWK keeps file open) - let (result, fs) = run_awk_with_fs( - &[r#"BEGIN{print "line1" > "/tmp/out"; print "line2" > "/tmp/out"}"#], - None, - ) - .await; - let result = result.unwrap(); - assert_eq!(result.exit_code, 0); - let content = fs - .read_file(std::path::Path::new("/tmp/out")) - .await - .unwrap(); - assert_eq!(String::from_utf8_lossy(&content), "line1\nline2\n"); - } - - #[tokio::test] - async fn test_awk_print_redirect_pipe_unsupported() { - // Pipe output should return clear error - let (result, _fs) = run_awk_with_fs(&[r#"BEGIN{print "hello" | "cat"}"#], None).await; - assert!(result.is_err()); - let err_msg = result.unwrap_err().to_string(); - assert!( - err_msg.contains("pipe"), - "expected pipe error, got: {err_msg}" - ); - } - - // ======================================================================== - // --csv flag (issues #617, #618) - // ======================================================================== - - #[tokio::test] - async fn test_awk_csv_basic() { - let result = run_awk(&["--csv", "{print $2}"], Some("a,b,c\nd,e,f")) - .await - .unwrap(); - assert_eq!(result.stdout, "b\ne\n"); - } - - #[tokio::test] - async fn test_awk_csv_quoted_fields() { - // Quoted field with embedded comma - let result = run_awk(&["--csv", "{print $2}"], Some(r#"1,"hello, world",3"#)) - .await - .unwrap(); - assert_eq!(result.stdout, "hello, world\n"); - } - - #[tokio::test] - async fn test_awk_csv_escaped_quotes() { - // Double-quote escaping per RFC 4180 - let result = run_awk(&["--csv", "{print $2}"], Some(r#"1,"she said ""hi""",3"#)) - .await - .unwrap(); - assert_eq!(result.stdout, "she said \"hi\"\n"); - } - - #[tokio::test] - async fn test_awk_csv_nf() { - let result = run_awk(&["--csv", "{print NF}"], Some("a,b,c")) - .await - .unwrap(); - assert_eq!(result.stdout, "3\n"); - } - - #[tokio::test] - async fn test_awk_csv_ofs() { - // In CSV mode, OFS defaults to comma - let result = run_awk(&["--csv", "{print $1, $3}"], Some("a,b,c")) - .await - .unwrap(); - assert_eq!(result.stdout, "a,c\n"); - } - - #[tokio::test] - async fn test_awk_csv_empty_fields() { - let result = run_awk(&["--csv", "{print NF}"], Some("a,,c,")) - .await - .unwrap(); - assert_eq!(result.stdout, "4\n"); - } - - #[tokio::test] - async fn test_awk_csv_k_flag_alias() { - // -k is an alias for --csv - let result = run_awk(&["-k", "{print $2}"], Some("a,b,c")).await.unwrap(); - assert_eq!(result.stdout, "b\n"); - } - - #[test] - fn test_csv_split_fields_unit() { - assert_eq!(csv_split_fields("a,b,c"), vec!["a", "b", "c"]); - assert_eq!( - csv_split_fields(r#"1,"hello, world",3"#), - vec!["1", "hello, world", "3"] - ); - assert_eq!(csv_split_fields(r#""a""b",c"#), vec!["a\"b", "c"]); - assert_eq!(csv_split_fields("a,,c,"), vec!["a", "", "c", ""]); - } - - // ======================================================================== - // gawk 5.3+ Unicode escape sequences (issue #617) - // ======================================================================== - - #[tokio::test] - async fn test_awk_unicode_escape_basic() { - // \u followed by hex digits → Unicode character - let result = run_awk(&[r#"BEGIN{print "\u0041"}"#], None).await.unwrap(); - assert_eq!(result.stdout, "A\n"); - } - - #[tokio::test] - async fn test_awk_unicode_escape_multibyte() { - // \u00E9 → é (Latin small e with acute) - let result = run_awk(&[r#"BEGIN{print "\u00E9"}"#], None).await.unwrap(); - assert_eq!(result.stdout, "é\n"); - } - - #[tokio::test] - async fn test_awk_unicode_escape_emoji() { - // \u1F600 → 😀 (grinning face, 5 hex digits) - let result = run_awk(&[r#"BEGIN{print "\u1F600"}"#], None).await.unwrap(); - assert_eq!(result.stdout, "😀\n"); - } - - #[tokio::test] - async fn test_awk_unicode_escape_bare_u() { - // \u with no hex digits → literal \u - let result = run_awk(&[r#"BEGIN{print "\u "}"#], None).await.unwrap(); - assert_eq!(result.stdout, "\\u \n"); - } - - #[tokio::test] - async fn test_awk_unicode_escape_mixed() { - // Mix of Unicode escapes and regular text - let result = run_awk(&[r#"BEGIN{print "caf\u00E9"}"#], None) - .await - .unwrap(); - assert_eq!(result.stdout, "café\n"); - } - - // ======================================================================== - // getline from file (issue #796) - // ======================================================================== - - /// Helper: create an in-memory FS with a file, then run awk. - async fn run_awk_with_file( - args: &[&str], - stdin: Option<&str>, - file_path: &str, - file_content: &str, - ) -> Result { - let awk = Awk; - let fs = Arc::new(InMemoryFs::new()); - fs.write_file(std::path::Path::new(file_path), file_content.as_bytes()) - .await - .unwrap(); - let mut vars = HashMap::new(); - let mut cwd = PathBuf::from("/"); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - - let ctx = Context { - args: &args, - env: &HashMap::new(), - variables: &mut vars, - cwd: &mut cwd, - fs, - stdin, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - awk.execute(ctx).await - } - - #[tokio::test] - async fn test_awk_getline_file_into_var() { - // getline var < "file" reads lines one at a time - let result = run_awk_with_file( - &[r#"BEGIN{while((getline line < "/tmp/data.txt") > 0) print line}"#], - None, - "/tmp/data.txt", - "alpha\nbeta\ngamma", - ) - .await - .unwrap(); - assert_eq!(result.stdout, "alpha\nbeta\ngamma\n"); - } - - #[tokio::test] - async fn test_awk_getline_file_no_var() { - // getline < "file" without variable updates $0 - let result = run_awk_with_file( - &[r#"BEGIN{getline < "/tmp/data.txt"; print}"#], - None, - "/tmp/data.txt", - "first\nsecond", - ) - .await - .unwrap(); - assert_eq!(result.stdout, "first\n"); - } - - #[tokio::test] - async fn test_awk_getline_file_eof() { - // getline at EOF keeps variable unchanged - let result = run_awk_with_file( - &[r#"BEGIN{getline x < "/tmp/f.txt"; getline x < "/tmp/f.txt"; print x}"#], - None, - "/tmp/f.txt", - "only", - ) - .await - .unwrap(); - assert_eq!(result.stdout, "only\n"); - } - - #[tokio::test] - async fn test_awk_getline_file_missing() { - // getline from non-existent file should not crash (returns -1 / empty) - let result = run_awk(&[r#"BEGIN{getline x < "/no/such/file"; print "ok"}"#], None) - .await - .unwrap(); - assert_eq!(result.stdout, "ok\n"); - } - - #[tokio::test] - async fn test_awk_output_limit_exceeded() { - // Each iteration prints a 1000-char line. 100k iters = ~100MB >> 10MB limit. - let result = run_awk( - &[r#"BEGIN { s = sprintf("%1000s", "x"); for(i=0;i<100000;i++) print s }"#], - None, - ) - .await - .unwrap(); - assert_eq!(result.exit_code, 2); - assert!( - result.stderr.contains("output limit exceeded"), - "stderr should mention output limit: {}", - result.stderr - ); - assert!( - result.stdout.len() <= 11_000_000, - "stdout should be bounded: {} bytes", - result.stdout.len() - ); - } - - #[tokio::test] - async fn test_awk_output_under_limit_ok() { - // Small output well under 10MB should succeed normally - let result = run_awk(&[r#"BEGIN { for(i=0;i<100;i++) print "hello" }"#], None) - .await - .unwrap(); - assert_eq!(result.exit_code, 0); - let lines: Vec<&str> = result.stdout.trim().split('\n').collect(); - assert_eq!(lines.len(), 100); - } - - #[tokio::test] - async fn test_awk_file_redirect_output_limit() { - // File redirect output should also be bounded - let result = run_awk( - &[r#"BEGIN { s = sprintf("%1000s", "x"); for(i=0;i<100000;i++) print s > "/tmp/out" }"#], - None, - ) - .await - .unwrap(); - assert_eq!(result.exit_code, 2); - assert!( - result.stderr.contains("output limit exceeded"), - "stderr should mention output limit: {}", - result.stderr - ); - } - - /// Helper: run AWK with a caller-provided VFS. - async fn run_awk_with_custom_fs( - args: &[&str], - stdin: Option<&str>, - fs: Arc, - ) -> Result { - let awk = Awk; - let mut vars = HashMap::new(); - let mut cwd = PathBuf::from("/"); - let args: Vec = args.iter().map(|s| s.to_string()).collect(); - - let ctx = Context { - args: &args, - env: &HashMap::new(), - variables: &mut vars, - cwd: &mut cwd, - fs, - stdin, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - awk.execute(ctx).await - } - - #[tokio::test] - async fn test_awk_getline_file_cache_limit_exceeded() { - // Opening more than MAX_GETLINE_CACHED_FILES distinct files must fail - // gracefully (getline returns -1 for new files beyond the limit). - use crate::fs::FsLimits; - - let limits = FsLimits { - max_file_count: 200_000, - max_total_bytes: 200_000_000, - ..FsLimits::default() - }; - let fs = Arc::new(InMemoryFs::with_limits(limits)); - let count = MAX_GETLINE_CACHED_FILES + 5; - for i in 0..count { - fs.write_file( - std::path::Path::new(&format!("/tmp/f{i}.txt")), - format!("line{i}").as_bytes(), - ) - .await - .unwrap(); - } - - // AWK program: read one line from each file, count successes - let prog = format!( - r#"BEGIN{{ ok=0; for(i=0;i<{count};i++) {{ f="/tmp/f"i".txt"; if((getline x < f)>0) ok++ }} print ok }}"#, - ); - let result = run_awk_with_custom_fs(&[&prog], None, fs).await.unwrap(); - let ok: usize = result.stdout.trim().parse().unwrap(); - // Exactly MAX_GETLINE_CACHED_FILES should succeed, rest should fail - assert_eq!(ok, MAX_GETLINE_CACHED_FILES); - } - - #[tokio::test] - async fn test_awk_getline_file_cache_within_limit() { - // Opening a reasonable number of files should all succeed. - let fs = Arc::new(InMemoryFs::new()); - let count = 10; - for i in 0..count { - fs.write_file( - std::path::Path::new(&format!("/tmp/f{i}.txt")), - format!("data{i}").as_bytes(), - ) - .await - .unwrap(); - } - - let prog = format!( - r#"BEGIN{{ ok=0; for(i=0;i<{count};i++) {{ f="/tmp/f"i".txt"; if((getline x < f)>0) ok++ }} print ok }}"#, - ); - let result = run_awk_with_custom_fs(&[&prog], None, fs).await.unwrap(); - let ok: usize = result.stdout.trim().parse().unwrap(); - assert_eq!(ok, count); - } - - #[tokio::test] - async fn test_awk_getline_file_size_limit() { - // A file exceeding FsLimits::max_file_size is rejected by getline. - // Defense-in-depth: VFS also enforces limits, so a file at exactly - // the boundary is accepted while one over is rejected at VFS level. - use crate::fs::FsLimits; - - let limits = FsLimits { - max_file_size: 100, - ..FsLimits::unlimited() - }; - let fs = Arc::new(InMemoryFs::with_limits(limits)); - // Write a file within limits -- should be readable via getline. - fs.write_file(std::path::Path::new("/tmp/ok.txt"), &[b'a'; 100]) - .await - .unwrap(); - // Attempt to write an oversized file -- VFS rejects it, so getline - // returns -1 (file not found). - let _ = fs - .write_file(std::path::Path::new("/tmp/big.txt"), &[b'x'; 101]) - .await; - - // Within-limit file succeeds - let result = run_awk_with_custom_fs( - &[r#"BEGIN{r=(getline x < "/tmp/ok.txt"); print r}"#], - None, - fs.clone(), - ) - .await - .unwrap(); - assert_eq!(result.stdout, "1\n"); - - // Over-limit file fails (not stored by VFS) - let result = run_awk_with_custom_fs( - &[r#"BEGIN{r=(getline x < "/tmp/big.txt"); print r}"#], - None, - fs, - ) - .await - .unwrap(); - assert_eq!(result.stdout, "-1\n"); - } - - // TM-INF-022: malformed-input corpus must not leak Debug shapes. - const AWK_BANNED: &[&str] = &["AwkError::", "ParseError {", "Token::"]; - - #[tokio::test] - async fn no_leak_unbalanced_brace() { - let r = crate::builtins::debug_leak_check::run("echo 1 | awk 'BEGIN { print'").await; - crate::builtins::debug_leak_check::assert_no_leak(&r, "awk_unbalanced_brace", AWK_BANNED); - } - - #[tokio::test] - async fn no_leak_invalid_regex() { - let r = crate::builtins::debug_leak_check::run("echo 1 | awk '/[/'").await; - crate::builtins::debug_leak_check::assert_no_leak(&r, "awk_invalid_regex", AWK_BANNED); - } - - #[tokio::test] - async fn no_leak_undefined_function_call() { - let r = crate::builtins::debug_leak_check::run( - "echo 1 | awk 'BEGIN { totally_undefined_fn() }'", - ) - .await; - crate::builtins::debug_leak_check::assert_no_leak( - &r, - "awk_undefined_function_call", - AWK_BANNED, - ); - } -} diff --git a/crates/bashkit/src/builtins/awk/interpreter.rs b/crates/bashkit/src/builtins/awk/interpreter.rs new file mode 100644 index 000000000..a7f2b9655 --- /dev/null +++ b/crates/bashkit/src/builtins/awk/interpreter.rs @@ -0,0 +1,1308 @@ +//! AWK interpreter - executes a parsed `AwkProgram`. + +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +use super::{AwkAction, AwkExpr, AwkFunctionDef, AwkOutputTarget, AwkPattern, AwkState, AwkValue}; +use crate::builtins::MAX_FORMAT_WIDTH; +use crate::builtins::limits::{ + AWK_MAX_CALL_DEPTH as MAX_AWK_CALL_DEPTH, + AWK_MAX_GETLINE_CACHED_FILES as MAX_GETLINE_CACHED_FILES, + AWK_MAX_OUTPUT_BYTES as MAX_AWK_OUTPUT_BYTES, +}; +use crate::builtins::search_common::build_regex; +use crate::fs::FileSystem; +use crate::limits::ExecutionLimits; + +/// Flow control signal from action execution +#[derive(Debug, PartialEq)] +pub(super) enum AwkFlow { + Continue, // Normal execution + Next, // Skip to next record + Break, // Break out of loop + LoopContinue, // Continue to next loop iteration + Exit(Option), // Exit program with optional code + Return(AwkValue), // Return from user-defined function +} + +// Awk runtime limits (TM-DOS-027, TM-DOS-028) live in `super::limits`: +// - AWK_MAX_CALL_DEPTH (user-function recursion) +// - AWK_MAX_OUTPUT_BYTES (total stdout+stderr+file redirects, 10 MB) +// - AWK_MAX_GETLINE_CACHED_FILES (distinct files held open by `getline`). + +pub(super) struct AwkInterpreter { + pub(super) state: AwkState, + pub(super) output: String, + /// Stderr output buffer for `/dev/stderr` redirection + pub(super) stderr_output: String, + /// Lines of current input file (set before main loop) + pub(super) input_lines: Vec, + /// Current line index within input_lines + pub(super) line_index: usize, + /// User-defined functions + pub(super) functions: HashMap, + /// Current function call depth for recursion limiting + call_depth: usize, + /// Buffered file outputs for `>` redirection (path -> content). + /// Truncate mode: first write creates entry, subsequent writes append to buffer. + /// Flushed to VFS after execution completes. + pub(super) file_outputs: HashMap, + /// Buffered file outputs for `>>` redirection (path -> content). + /// Append mode: content is appended to existing file on flush. + pub(super) file_appends: HashMap, + /// Cached file inputs for `getline var < file` redirection. + /// Maps resolved path -> (lines, current_position). + file_inputs: HashMap, usize)>, + /// VFS reference for lazy file reads (getline < file). + pub(super) fs: Option>, + /// Working directory for resolving relative paths. + pub(super) cwd: PathBuf, + /// Tracks active state for range patterns (rule index -> is_active). + /// When start pattern matches, range becomes active. When end pattern + /// matches, that line is included but range becomes inactive. + range_active: HashMap, + /// Max iterations for a single loop, inherited from execution limits. + pub(super) max_loop_iterations: usize, +} + +impl AwkInterpreter { + pub(super) fn new() -> Self { + Self { + state: AwkState::default(), + output: String::new(), + stderr_output: String::new(), + input_lines: Vec::new(), + line_index: 0, + functions: HashMap::new(), + file_outputs: HashMap::new(), + file_appends: HashMap::new(), + file_inputs: HashMap::new(), + call_depth: 0, + fs: None, + cwd: PathBuf::from("/"), + range_active: HashMap::new(), + max_loop_iterations: ExecutionLimits::default().max_loop_iterations, + } + } + + /// Load a file into the `file_inputs` cache if not already present. + /// Uses a separate thread + tokio runtime to bridge async VFS → sync context. + /// Returns true on success, false on error. + fn ensure_file_loaded(&mut self, resolved: &str) -> bool { + if self.file_inputs.contains_key(resolved) { + return true; + } + // Guard: cap number of cached files to prevent memory exhaustion + if self.file_inputs.len() >= MAX_GETLINE_CACHED_FILES { + return false; + } + let Some(fs) = &self.fs else { + return false; + }; + let fs = fs.clone(); + let p = PathBuf::from(resolved); + // Spawn a thread with its own runtime to avoid blocking the current async runtime. + let result = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(fs.read_file(&p)) + }) + .join(); + match result { + Ok(Ok(bytes)) => { + let text = String::from_utf8_lossy(&bytes).into_owned(); + let lines: Vec = text.lines().map(|l| l.to_string()).collect(); + self.file_inputs.insert(resolved.to_string(), (lines, 0)); + true + } + _ => false, + } + } + + /// Evaluate an expression as a boolean, with special handling for regex + /// literals: `/regex/` is matched against $0 in boolean context (e.g. && / ||). + fn eval_expr_as_bool(&mut self, expr: &AwkExpr) -> bool { + if let AwkExpr::Regex(pattern) = expr { + let line = self.state.get_field(0).as_string(); + if let Ok(re) = build_regex(pattern) { + return re.is_match(&line); + } + return false; + } + self.eval_expr(expr).as_bool() + } + + fn eval_expr(&mut self, expr: &AwkExpr) -> AwkValue { + match expr { + AwkExpr::Number(n) => AwkValue::Number(*n), + AwkExpr::String(s) => AwkValue::String(s.clone()), + AwkExpr::Field(index) => { + let n = self.eval_expr(index).as_number() as usize; + self.state.get_field(n) + } + AwkExpr::Variable(name) => self.state.get_variable(name), + AwkExpr::Assign(name, val) => { + let value = self.eval_expr(val); + self.state.set_variable(name, value.clone()); + value + } + AwkExpr::BinOp(left, op, right) => { + if op == "&&" { + let lb = self.eval_expr_as_bool(left); + if !lb { + return AwkValue::Number(0.0); + } + let rb = self.eval_expr_as_bool(right); + return AwkValue::Number(if rb { 1.0 } else { 0.0 }); + } + if op == "||" { + let lb = self.eval_expr_as_bool(left); + if lb { + return AwkValue::Number(1.0); + } + let rb = self.eval_expr_as_bool(right); + return AwkValue::Number(if rb { 1.0 } else { 0.0 }); + } + + let l = self.eval_expr(left); + let r = self.eval_expr(right); + + match op.as_str() { + "+" => AwkValue::Number(l.as_number() + r.as_number()), + "-" => AwkValue::Number(l.as_number() - r.as_number()), + "*" => AwkValue::Number(l.as_number() * r.as_number()), + "/" => AwkValue::Number(l.as_number() / r.as_number()), + "%" => AwkValue::Number(l.as_number() % r.as_number()), + "^" => AwkValue::Number(l.as_number().powf(r.as_number())), + "==" => AwkValue::Number(if l.as_string() == r.as_string() { + 1.0 + } else { + 0.0 + }), + "!=" => AwkValue::Number(if l.as_string() != r.as_string() { + 1.0 + } else { + 0.0 + }), + "<" => AwkValue::Number(if l.as_number() < r.as_number() { + 1.0 + } else { + 0.0 + }), + ">" => AwkValue::Number(if l.as_number() > r.as_number() { + 1.0 + } else { + 0.0 + }), + "<=" => AwkValue::Number(if l.as_number() <= r.as_number() { + 1.0 + } else { + 0.0 + }), + ">=" => AwkValue::Number(if l.as_number() >= r.as_number() { + 1.0 + } else { + 0.0 + }), + "~" => { + if let Ok(re) = build_regex(&r.as_string()) { + AwkValue::Number(if re.is_match(&l.as_string()) { + 1.0 + } else { + 0.0 + }) + } else { + AwkValue::Number(0.0) + } + } + "!~" => { + if let Ok(re) = build_regex(&r.as_string()) { + AwkValue::Number(if !re.is_match(&l.as_string()) { + 1.0 + } else { + 0.0 + }) + } else { + AwkValue::Number(1.0) + } + } + "SUBSEP_CONCAT" => { + let subsep = self.state.get_variable("SUBSEP").as_string(); + AwkValue::String(format!("{}{}{}", l.as_string(), subsep, r.as_string())) + } + _ => AwkValue::Uninitialized, + } + } + AwkExpr::UnaryOp(op, expr) => match op.as_str() { + "-" => { + let v = self.eval_expr(expr); + AwkValue::Number(-v.as_number()) + } + "!" => { + let b = self.eval_expr_as_bool(expr); + AwkValue::Number(if b { 0.0 } else { 1.0 }) + } + _ => self.eval_expr(expr), + }, + AwkExpr::Concat(parts) => { + let s: String = parts + .iter() + .map(|p| self.eval_expr(p).as_string()) + .collect(); + AwkValue::String(s) + } + AwkExpr::ArrayAssign(name, key, val) => { + let k = self.eval_expr(key).as_string(); + let v = self.eval_expr(val); + let full_key = format!("{}[{}]", name, k); + self.state.set_variable(&full_key, v.clone()); + v + } + AwkExpr::CompoundArrayAssign(name, key, op, val) => { + let k = self.eval_expr(key).as_string(); + let full_key = format!("{}[{}]", name, k); + let current = self.state.get_variable(&full_key).as_number(); + let rhs = self.eval_expr(val).as_number(); + let result = match op.as_str() { + "+" => current + rhs, + "-" => current - rhs, + "*" => current * rhs, + "/" => current / rhs, + "%" => current % rhs, + _ => rhs, + }; + let v = AwkValue::Number(result); + self.state.set_variable(&full_key, v.clone()); + v + } + AwkExpr::FieldAssign(index, val) => { + let n = self.eval_expr(index).as_number() as usize; + let v = self.eval_expr(val); + if n == 0 { + self.state.set_variable("$0", v.clone()); + } else { + // Extend fields if needed + while self.state.fields.len() < n { + self.state.fields.push(String::new()); + } + self.state.fields[n - 1] = v.as_string(); + self.state.nf = self.state.fields.len(); + // Rebuild $0 + let new_line = self.state.fields.join(&self.state.ofs); + self.state.set_variable("$0", AwkValue::String(new_line)); + } + v + } + AwkExpr::PostIncrement(name) => { + let current = self.state.get_variable(name).as_number(); + self.state + .set_variable(name, AwkValue::Number(current + 1.0)); + AwkValue::Number(current) // Return old value + } + AwkExpr::PostDecrement(name) => { + let current = self.state.get_variable(name).as_number(); + self.state + .set_variable(name, AwkValue::Number(current - 1.0)); + AwkValue::Number(current) // Return old value + } + AwkExpr::PreIncrement(name) => { + let current = self.state.get_variable(name).as_number(); + let new_val = current + 1.0; + self.state.set_variable(name, AwkValue::Number(new_val)); + AwkValue::Number(new_val) // Return new value + } + AwkExpr::PreDecrement(name) => { + let current = self.state.get_variable(name).as_number(); + let new_val = current - 1.0; + self.state.set_variable(name, AwkValue::Number(new_val)); + AwkValue::Number(new_val) // Return new value + } + AwkExpr::InArray(key, arr_name) => { + let k = self.eval_expr(key).as_string(); + let full_key = format!("{}[{}]", arr_name, k); + let exists = !matches!(self.state.get_variable(&full_key), AwkValue::Uninitialized); + AwkValue::Number(if exists { 1.0 } else { 0.0 }) + } + AwkExpr::FuncCall(name, args) => self.call_function(name, args), + AwkExpr::Regex(pattern) => { + // When used as a standalone expression, /regex/ matches against $0. + // When used as a function argument (gsub, sub, match, split), + // it's evaluated as a string pattern, so return the pattern string. + AwkValue::String(pattern.clone()) + } + AwkExpr::Match(expr, pattern) => { + let s = self.eval_expr(expr).as_string(); + if let Ok(re) = build_regex(pattern) { + AwkValue::Number(if re.is_match(&s) { 1.0 } else { 0.0 }) + } else { + AwkValue::Number(0.0) + } + } + AwkExpr::GetlineFile { var, file } => { + let path_str = self.eval_expr(file).as_string(); + let resolved = if path_str.starts_with('/') { + path_str.clone() + } else { + self.cwd.join(&path_str).to_string_lossy().to_string() + }; + + if !self.ensure_file_loaded(&resolved) { + return AwkValue::Number(-1.0); + } + + let entry = self.file_inputs.get_mut(&resolved).unwrap(); + if entry.1 < entry.0.len() { + let line = entry.0[entry.1].clone(); + entry.1 += 1; + match var { + Some(v) => { + self.state + .variables + .insert(v.clone(), AwkValue::String(line)); + } + None => { + self.state.set_line(&line); + } + } + AwkValue::Number(1.0) // success + } else { + AwkValue::Number(0.0) // EOF + } + } + } + } + + fn call_function(&mut self, name: &str, args: &[AwkExpr]) -> AwkValue { + match name { + "length" => { + if args.is_empty() { + AwkValue::Number(self.state.get_field(0).as_string().len() as f64) + } else { + // Check if the argument is an array name - if so, return element count + if let AwkExpr::Variable(ref arr_name) = args[0] { + let prefix = format!("{}[", arr_name); + let count = self + .state + .variables + .keys() + .filter(|k| k.starts_with(&prefix)) + .count(); + if count > 0 { + return AwkValue::Number(count as f64); + } + } + AwkValue::Number(self.eval_expr(&args[0]).as_string().len() as f64) + } + } + "substr" => { + if args.len() < 2 { + return AwkValue::Uninitialized; + } + let s = self.eval_expr(&args[0]).as_string(); + let start = (self.eval_expr(&args[1]).as_number() as usize).saturating_sub(1); + let len = if args.len() > 2 { + self.eval_expr(&args[2]).as_number() as usize + } else { + s.len() + }; + let end = (start + len).min(s.len()); + AwkValue::String(s.chars().skip(start).take(end - start).collect()) + } + "index" => { + if args.len() < 2 { + return AwkValue::Number(0.0); + } + let s = self.eval_expr(&args[0]).as_string(); + let t = self.eval_expr(&args[1]).as_string(); + match s.find(&t) { + Some(i) => AwkValue::Number((i + 1) as f64), + None => AwkValue::Number(0.0), + } + } + "split" => { + if args.len() < 2 { + return AwkValue::Number(0.0); + } + let s = self.eval_expr(&args[0]).as_string(); + let sep = if args.len() > 2 { + self.eval_expr(&args[2]).as_string() + } else { + self.state.fs.clone() + }; + + let parts: Vec<&str> = if sep == " " { + s.split_whitespace().collect() + } else { + s.split(&sep).collect() + }; + + // Store in array variable + if let AwkExpr::Variable(arr_name) = &args[1] { + for (i, part) in parts.iter().enumerate() { + let key = format!("{}[{}]", arr_name, i + 1); + self.state + .set_variable(&key, AwkValue::String(part.to_string())); + } + } + + AwkValue::Number(parts.len() as f64) + } + "sprintf" => { + if args.is_empty() { + return AwkValue::String(String::new()); + } + let format = self.eval_expr(&args[0]).as_string(); + let values: Vec = args[1..].iter().map(|a| self.eval_expr(a)).collect(); + match self.format_string(&format, &values) { + Ok(s) => AwkValue::String(s), + Err(e) => { + self.stderr_output.push_str(&e); + self.stderr_output.push('\n'); + AwkValue::String(String::new()) + } + } + } + "toupper" => { + if args.is_empty() { + return AwkValue::Uninitialized; + } + AwkValue::String(self.eval_expr(&args[0]).as_string().to_uppercase()) + } + "tolower" => { + if args.is_empty() { + return AwkValue::Uninitialized; + } + AwkValue::String(self.eval_expr(&args[0]).as_string().to_lowercase()) + } + "gsub" | "sub" => { + // gsub(regexp, replacement, target) + if args.len() < 2 { + return AwkValue::Number(0.0); + } + let pattern = self.eval_expr(&args[0]).as_string(); + let replacement = self.eval_expr(&args[1]).as_string(); + + let target_expr = if args.len() > 2 { + args[2].clone() + } else { + AwkExpr::Field(Box::new(AwkExpr::Number(0.0))) + }; + + let target = self.eval_expr(&target_expr).as_string(); + + if let Ok(re) = build_regex(&pattern) { + let (result, count) = if name == "gsub" { + let count = re.find_iter(&target).count(); + ( + re.replace_all(&target, replacement.as_str()).to_string(), + count, + ) + } else { + let count = if re.is_match(&target) { 1 } else { 0 }; + (re.replace(&target, replacement.as_str()).to_string(), count) + }; + + // Update the target variable or field + match &target_expr { + AwkExpr::Variable(name) => { + self.state.set_variable(name, AwkValue::String(result)); + } + AwkExpr::Field(index) => { + let n = self.eval_expr(index).as_number() as usize; + if n == 0 { + // $0 is stored as a variable + self.state.set_variable("$0", AwkValue::String(result)); + } + // For other fields, we'd need to update the fields vec + // and rebuild $0, but for now we just support $0 + } + _ => {} + } + + AwkValue::Number(count as f64) + } else { + AwkValue::Number(0.0) + } + } + "int" => { + if args.is_empty() { + return AwkValue::Number(0.0); + } + AwkValue::Number(self.eval_expr(&args[0]).as_number().trunc()) + } + "sqrt" => { + if args.is_empty() { + return AwkValue::Number(0.0); + } + AwkValue::Number(self.eval_expr(&args[0]).as_number().sqrt()) + } + "sin" => { + if args.is_empty() { + return AwkValue::Number(0.0); + } + AwkValue::Number(self.eval_expr(&args[0]).as_number().sin()) + } + "cos" => { + if args.is_empty() { + return AwkValue::Number(0.0); + } + AwkValue::Number(self.eval_expr(&args[0]).as_number().cos()) + } + "log" => { + if args.is_empty() { + return AwkValue::Number(0.0); + } + AwkValue::Number(self.eval_expr(&args[0]).as_number().ln()) + } + "exp" => { + if args.is_empty() { + return AwkValue::Number(0.0); + } + AwkValue::Number(self.eval_expr(&args[0]).as_number().exp()) + } + "match" => { + if args.len() < 2 { + return AwkValue::Number(0.0); + } + let s = self.eval_expr(&args[0]).as_string(); + let pattern = self.eval_expr(&args[1]).as_string(); + // Extract capture array name from 3rd arg (gawk extension) + let arr_name = if args.len() >= 3 { + if let AwkExpr::Variable(name) = &args[2] { + Some(name.clone()) + } else { + None + } + } else { + None + }; + if let Ok(re) = build_regex(&pattern) { + if let Some(caps) = re.captures(&s) { + let m = caps.get(0).unwrap(); + let rstart = m.start() + 1; // awk is 1-indexed + let rlength = m.end() - m.start(); + self.state + .set_variable("RSTART", AwkValue::Number(rstart as f64)); + self.state + .set_variable("RLENGTH", AwkValue::Number(rlength as f64)); + // Populate capture array if 3rd arg provided + if let Some(ref arr) = arr_name { + // arr[0] = entire match + let full_key = format!("{}[0]", arr); + self.state + .set_variable(&full_key, AwkValue::String(m.as_str().to_string())); + // arr[1..N] = capture groups + for i in 1..caps.len() { + let key = format!("{}[{}]", arr, i); + let val = caps + .get(i) + .map(|c| c.as_str().to_string()) + .unwrap_or_default(); + self.state.set_variable(&key, AwkValue::String(val)); + } + } + AwkValue::Number(rstart as f64) + } else { + self.state.set_variable("RSTART", AwkValue::Number(0.0)); + self.state.set_variable("RLENGTH", AwkValue::Number(-1.0)); + AwkValue::Number(0.0) + } + } else { + AwkValue::Number(0.0) + } + } + "gensub" => { + // gensub(regexp, replacement, how [, target]) + if args.len() < 3 { + return AwkValue::Uninitialized; + } + let pattern = self.eval_expr(&args[0]).as_string(); + let replacement = self.eval_expr(&args[1]).as_string(); + let how = self.eval_expr(&args[2]).as_string(); + let target = if args.len() > 3 { + self.eval_expr(&args[3]).as_string() + } else { + self.state.get_field(0).as_string() + }; + if let Ok(re) = build_regex(&pattern) { + if how == "g" || how == "G" { + AwkValue::String(re.replace_all(&target, replacement.as_str()).to_string()) + } else { + // Replace nth occurrence (default 1st) + let n = how.parse::().unwrap_or(1); + let mut count = 0; + let result = re.replace_all(&target, |caps: ®ex::Captures| -> String { + count += 1; + if count == n { + replacement.clone() + } else { + caps[0].to_string() + } + }); + AwkValue::String(result.to_string()) + } + } else { + AwkValue::String(target) + } + } + "__getline" => { + // Plain getline as expression — advance to next input line, return 1/0 + self.line_index += 1; + if self.line_index < self.input_lines.len() { + let line = self.input_lines[self.line_index].clone(); + self.state.set_line(&line); + AwkValue::Number(1.0) + } else { + AwkValue::Number(0.0) + } + } + "__array_access" => { + // Internal function for array indexing: arr[index] + if args.len() < 2 { + return AwkValue::Uninitialized; + } + let arr_name = if let AwkExpr::Variable(name) = &args[0] { + name.clone() + } else { + return AwkValue::Uninitialized; + }; + let index = self.eval_expr(&args[1]); + let key = format!("{}[{}]", arr_name, index.as_string()); + self.state.get_variable(&key) + } + "__ternary" => { + // Ternary operator: cond ? then : else + if args.len() < 3 { + return AwkValue::Uninitialized; + } + let cond = self.eval_expr(&args[0]); + if cond.as_bool() { + self.eval_expr(&args[1]) + } else { + self.eval_expr(&args[2]) + } + } + _ => { + // Check for user-defined function + if let Some(func) = self.functions.get(name).cloned() { + self.call_user_function(&func, args) + } else { + AwkValue::Uninitialized + } + } + } + } + + fn call_user_function(&mut self, func: &AwkFunctionDef, args: &[AwkExpr]) -> AwkValue { + // THREAT[TM-DOS-027]: Limit recursion depth to prevent stack overflow + if self.call_depth >= MAX_AWK_CALL_DEPTH { + return AwkValue::Uninitialized; + } + self.call_depth += 1; + + // Save current local variables that will be shadowed + let mut saved: Vec<(String, AwkValue)> = Vec::new(); + for param in &func.params { + saved.push((param.clone(), self.state.get_variable(param))); + } + + // Bind arguments to parameters + for (i, param) in func.params.iter().enumerate() { + let val = if i < args.len() { + self.eval_expr(&args[i]) + } else { + AwkValue::Uninitialized + }; + self.state.set_variable(param, val); + } + + // Execute function body, capture return value + let mut return_value = AwkValue::Uninitialized; + for action in &func.body.clone() { + match self.exec_action(action) { + AwkFlow::Return(val) => { + return_value = val; + break; + } + AwkFlow::Exit(_) => break, + _ => {} + } + } + + // Restore saved variables + for (name, val) in saved { + self.state.set_variable(&name, val); + } + + self.call_depth -= 1; + return_value + } + + /// Max width/precision for format specifiers to prevent memory exhaustion + const MAX_FORMAT_WIDTH: usize = MAX_FORMAT_WIDTH; + + fn format_string( + &self, + format: &str, + values: &[AwkValue], + ) -> std::result::Result { + let mut result = String::new(); + let mut chars = format.chars().peekable(); + let mut value_idx = 0; + + while let Some(c) = chars.next() { + if c == '\\' { + // Handle escape sequences in format strings + match chars.peek() { + Some('n') => { + chars.next(); + result.push('\n'); + } + Some('t') => { + chars.next(); + result.push('\t'); + } + Some('r') => { + chars.next(); + result.push('\r'); + } + Some('\\') => { + chars.next(); + result.push('\\'); + } + _ => result.push('\\'), + } + } else if c == '%' { + if chars.peek() == Some(&'%') { + chars.next(); + result.push('%'); + continue; + } + + // Parse format specifier: %[flags][width][.precision]type + let mut left_align = false; + let mut zero_pad = false; + let mut plus_sign = false; + let mut width: Option = None; + let mut precision: Option = None; + let mut conversion = ' '; + + // Parse flags + loop { + match chars.peek() { + Some(&'-') => { + left_align = true; + chars.next(); + } + Some(&'0') if width.is_none() => { + zero_pad = true; + chars.next(); + } + Some(&'+') => { + plus_sign = true; + chars.next(); + } + _ => break, + } + } + + // Parse width + let mut w = String::new(); + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + w.push(c); + chars.next(); + } else { + break; + } + } + if !w.is_empty() + && let Ok(w_val) = w.parse::() + { + if w_val > Self::MAX_FORMAT_WIDTH { + return Err(format!( + "awk: format width {} exceeds maximum ({})", + w_val, + Self::MAX_FORMAT_WIDTH + )); + } + width = Some(w_val); + } + + // Parse precision + if chars.peek() == Some(&'.') { + chars.next(); + let mut p = String::new(); + while let Some(&c) = chars.peek() { + if c.is_ascii_digit() { + p.push(c); + chars.next(); + } else { + break; + } + } + precision = if p.is_empty() { + Some(0) + } else if let Ok(p_val) = p.parse::() { + if p_val > Self::MAX_FORMAT_WIDTH { + return Err(format!( + "awk: format precision {} exceeds maximum ({})", + p_val, + Self::MAX_FORMAT_WIDTH + )); + } + Some(p_val) + } else { + None + }; + } + + // Parse conversion character + if let Some(&c) = chars.peek() + && c.is_ascii_alphabetic() + { + conversion = c; + chars.next(); + } + + if value_idx < values.len() { + let val = &values[value_idx]; + value_idx += 1; + + let formatted = match conversion { + 'd' | 'i' => { + let n = val.as_number() as i64; + if plus_sign && n >= 0 { + format!("+{}", n) + } else { + format!("{}", n) + } + } + 'f' => { + let n = val.as_number(); + let prec = precision.unwrap_or(6); + format!("{:.prec$}", n) + } + 'g' => { + let n = val.as_number(); + let prec = precision.unwrap_or(6); + // %g: use shorter of %e or %f, strip trailing zeros + let s = format!("{:.prec$e}", n); + let f = format!("{:.prec$}", n); + if s.len() < f.len() { s } else { f } + } + 'e' | 'E' => { + let n = val.as_number(); + let prec = precision.unwrap_or(6); + format!("{:.prec$e}", n) + } + 's' => { + let mut s = val.as_string(); + if let Some(p) = precision { + s = s.chars().take(p).collect(); + } + s + } + 'c' => { + // %c: print character from ASCII code or first char of string + let n = val.as_number(); + if n > 0.0 && n < 128.0 { + String::from(n as u8 as char) + } else { + let s = val.as_string(); + s.chars().next().map(String::from).unwrap_or_default() + } + } + 'x' | 'X' => { + let n = val.as_number() as i64; + if conversion == 'X' { + format!("{:X}", n) + } else { + format!("{:x}", n) + } + } + 'o' => { + let n = val.as_number() as i64; + format!("{:o}", n) + } + _ => val.as_string(), + }; + + // Apply width and alignment + if let Some(w) = width { + if formatted.len() < w { + let padding = w - formatted.len(); + if left_align { + result.push_str(&formatted); + for _ in 0..padding { + result.push(' '); + } + } else if zero_pad + && matches!(conversion, 'd' | 'i' | 'f' | 'x' | 'X' | 'o') + { + for _ in 0..padding { + result.push('0'); + } + result.push_str(&formatted); + } else { + for _ in 0..padding { + result.push(' '); + } + result.push_str(&formatted); + } + } else { + result.push_str(&formatted); + } + } else { + result.push_str(&formatted); + } + } + } else { + result.push(c); + } + } + + Ok(result) + } + + /// Total bytes buffered across all output streams. + fn total_output_bytes(&self) -> usize { + self.output.len() + + self.stderr_output.len() + + self.file_outputs.values().map(|v| v.len()).sum::() + + self.file_appends.values().map(|v| v.len()).sum::() + } + + /// Write text to stdout buffer or to a file output buffer based on the target. + /// Returns `false` if the write would exceed [`MAX_AWK_OUTPUT_BYTES`]. + fn write_output(&mut self, text: &str, target: &Option) -> bool { + if self.total_output_bytes() + text.len() > MAX_AWK_OUTPUT_BYTES { + self.stderr_output + .push_str("awk: output limit exceeded (max 10MB)\n"); + return false; + } + match target { + None => self.output.push_str(text), + Some(AwkOutputTarget::Truncate(expr)) | Some(AwkOutputTarget::Append(expr)) => { + let path = self.eval_expr(expr).as_string(); + // Intercept /dev/stderr and /dev/stdout — route to streams, not VFS + if path == "/dev/stderr" { + self.stderr_output.push_str(text); + } else if path == "/dev/stdout" { + self.output.push_str(text); + } else if matches!(target, Some(AwkOutputTarget::Append(_))) { + self.file_appends.entry(path).or_default().push_str(text); + } else { + self.file_outputs.entry(path).or_default().push_str(text); + } + } + } + true + } + + /// Execute action. Returns flow control signal. + pub(super) fn exec_action(&mut self, action: &AwkAction) -> AwkFlow { + match action { + AwkAction::Print(exprs, target) => { + let parts: Vec = exprs + .iter() + .map(|e| self.eval_expr(e).as_string()) + .collect(); + let mut text = parts.join(&self.state.ofs); + text.push_str(&self.state.ors); + if !self.write_output(&text, target) { + return AwkFlow::Exit(Some(2)); + } + AwkFlow::Continue + } + AwkAction::Printf(format_expr, args, target) => { + let format_str = self.eval_expr(format_expr).as_string(); + let values: Vec = args.iter().map(|a| self.eval_expr(a)).collect(); + match self.format_string(&format_str, &values) { + Ok(text) => { + if !self.write_output(&text, target) { + return AwkFlow::Exit(Some(2)); + } + AwkFlow::Continue + } + Err(e) => { + self.stderr_output.push_str(&e); + self.stderr_output.push('\n'); + AwkFlow::Exit(Some(2)) + } + } + } + AwkAction::Assign(name, expr) => { + let value = self.eval_expr(expr); + self.state.set_variable(name, value); + AwkFlow::Continue + } + AwkAction::ArrayAssign(name, key, val) => { + let k = self.eval_expr(key).as_string(); + let v = self.eval_expr(val); + let full_key = format!("{}[{}]", name, k); + self.state.set_variable(&full_key, v); + AwkFlow::Continue + } + AwkAction::If(cond, then_actions, else_actions) => { + let actions = if self.eval_expr(cond).as_bool() { + then_actions + } else { + else_actions + }; + for action in actions { + match self.exec_action(action) { + AwkFlow::Continue => {} + flow => return flow, + } + } + AwkFlow::Continue + } + AwkAction::While(cond, actions) => { + let mut iters = 0; + while self.eval_expr(cond).as_bool() { + iters += 1; + if iters > self.max_loop_iterations { + break; + } + let mut do_break = false; + for action in actions { + match self.exec_action(action) { + AwkFlow::Continue => {} + AwkFlow::Break => { + do_break = true; + break; + } + AwkFlow::LoopContinue => break, + flow => return flow, + } + } + if do_break { + break; + } + } + AwkFlow::Continue + } + AwkAction::DoWhile(cond, actions) => { + let mut iters = 0; + loop { + iters += 1; + if iters > self.max_loop_iterations { + break; + } + let mut do_break = false; + for action in actions { + match self.exec_action(action) { + AwkFlow::Continue => {} + AwkFlow::Break => { + do_break = true; + break; + } + AwkFlow::LoopContinue => break, + flow => return flow, + } + } + if do_break || !self.eval_expr(cond).as_bool() { + break; + } + } + AwkFlow::Continue + } + AwkAction::For(init, cond, update, actions) => { + self.exec_action(init); + let mut iters = 0; + while self.eval_expr(cond).as_bool() { + iters += 1; + if iters > self.max_loop_iterations { + break; + } + let mut do_break = false; + for action in actions { + match self.exec_action(action) { + AwkFlow::Continue => {} + AwkFlow::Break => { + do_break = true; + break; + } + AwkFlow::LoopContinue => break, + flow => return flow, + } + } + if do_break { + break; + } + self.exec_action(update); + } + AwkFlow::Continue + } + AwkAction::ForIn(var, arr_name, actions) => { + // Collect array keys matching the pattern arr_name[*] + let prefix = format!("{}[", arr_name); + let mut keys: Vec = self + .state + .variables + .keys() + .filter(|k| k.starts_with(&prefix) && k.ends_with(']')) + .map(|k| k[prefix.len()..k.len() - 1].to_string()) + .collect(); + // Sort for deterministic iteration: numeric keys first, then lexical + keys.sort_by(|a, b| match (a.parse::(), b.parse::()) { + (Ok(na), Ok(nb)) => na.partial_cmp(&nb).unwrap_or(std::cmp::Ordering::Equal), + _ => a.cmp(b), + }); + + let mut iters = 0; + for key in keys { + iters += 1; + if iters > self.max_loop_iterations { + break; + } + self.state.set_variable(var, AwkValue::String(key)); + let mut do_break = false; + for action in actions { + match self.exec_action(action) { + AwkFlow::Continue => {} + AwkFlow::Break => { + do_break = true; + break; + } + AwkFlow::LoopContinue => break, + flow => return flow, + } + } + if do_break { + break; + } + } + AwkFlow::Continue + } + AwkAction::Delete(arr_name, key) => { + let k = self.eval_expr(key).as_string(); + if k == "*" { + // Delete all entries in the array + let prefix = format!("{}[", arr_name); + let keys: Vec = self + .state + .variables + .keys() + .filter(|k| k.starts_with(&prefix)) + .cloned() + .collect(); + for key in keys { + self.state.variables.remove(&key); + } + } else { + let full_key = format!("{}[{}]", arr_name, k); + self.state.variables.remove(&full_key); + } + AwkFlow::Continue + } + AwkAction::Next => AwkFlow::Next, + AwkAction::Getline => { + // Advance to next input line and update $0, NR, NF, FNR + self.line_index += 1; + if self.line_index < self.input_lines.len() { + let line = self.input_lines[self.line_index].clone(); + self.state.set_line(&line); + } + AwkFlow::Continue + } + AwkAction::GetlineFile { var, file } => { + // Read next line from file (action context — return value discarded). + let path_str = self.eval_expr(file).as_string(); + let resolved = if path_str.starts_with('/') { + path_str.clone() + } else { + self.cwd.join(&path_str).to_string_lossy().to_string() + }; + + if !self.ensure_file_loaded(&resolved) { + return AwkFlow::Continue; + } + + let entry = self.file_inputs.get_mut(&resolved).unwrap(); + if entry.1 < entry.0.len() { + let line = entry.0[entry.1].clone(); + entry.1 += 1; + match var { + Some(v) => { + self.state + .variables + .insert(v.clone(), AwkValue::String(line)); + } + None => { + self.state.set_line(&line); + } + } + } + AwkFlow::Continue + } + AwkAction::Break => AwkFlow::Break, + AwkAction::Continue => AwkFlow::LoopContinue, + AwkAction::Exit(expr) => { + let code = expr.as_ref().map(|e| self.eval_expr(e).as_number() as i32); + AwkFlow::Exit(code) + } + AwkAction::Return(expr) => { + let val = expr + .as_ref() + .map(|e| self.eval_expr(e)) + .unwrap_or(AwkValue::Uninitialized); + AwkFlow::Return(val) + } + AwkAction::Expression(expr) => { + self.eval_expr(expr); + AwkFlow::Continue + } + } + } + + fn matches_pattern(&mut self, pattern: &AwkPattern) -> bool { + match pattern { + AwkPattern::Regex(re) => { + let line = self.state.get_field(0).as_string(); + re.is_match(&line) + } + AwkPattern::Expression(expr) => self.eval_expr(expr).as_bool(), + // Range patterns are handled specially via matches_pattern_with_index + // which tracks state. This arm shouldn't normally be reached for ranges + // in the main loop, but handle it defensively. + AwkPattern::Range(_, _) => false, + } + } + + /// Check if a rule's pattern matches, with range state tracking by rule index. + pub(super) fn matches_pattern_with_index( + &mut self, + pattern: &AwkPattern, + rule_idx: usize, + ) -> bool { + match pattern { + AwkPattern::Range(start, end) => { + let active = *self.range_active.get(&rule_idx).unwrap_or(&false); + if active { + // Already in range — check if end pattern matches + if self.matches_pattern(end) { + // End pattern matched: include this line, deactivate range + self.range_active.insert(rule_idx, false); + } + true + } else { + // Not in range — check if start pattern matches + if self.matches_pattern(start) { + // If end also matches this same line, range closes immediately. + let end_matches = self.matches_pattern(end); + self.range_active.insert(rule_idx, !end_matches); + true + } else { + false + } + } + } + other => self.matches_pattern(other), + } + } +} diff --git a/crates/bashkit/src/builtins/awk/mod.rs b/crates/bashkit/src/builtins/awk/mod.rs new file mode 100644 index 000000000..afd9890c4 --- /dev/null +++ b/crates/bashkit/src/builtins/awk/mod.rs @@ -0,0 +1,723 @@ +//! awk - Pattern scanning and processing builtin +//! +//! Implements basic AWK functionality. +//! +//! Usage: +//! awk '{print $1}' file +//! awk -F: '{print $1}' /etc/passwd +//! echo "a b c" | awk '{print $2}' +//! awk 'BEGIN{print "start"} {print} END{print "end"}' file +//! awk '/pattern/{print}' file +//! awk 'NR==2{print}' file + +// Parser invariant: `pos` is always a byte offset on a UTF-8 char boundary. +// Move across user-controlled text with `advance()`/`consume_while()`, and use +// raw `pos += N` only for known ASCII tokens and delimiters. +#![allow(clippy::unwrap_used)] + +use async_trait::async_trait; +use regex::Regex; +use std::collections::HashMap; + +use super::{Builtin, Context, read_text_file}; +use crate::error::{Error, Result}; +use crate::interpreter::ExecResult; +use crate::limits::ExecutionLimits; + +/// awk command - pattern scanning and processing +pub struct Awk; + +#[derive(Debug)] +struct AwkProgram { + begin_actions: Vec, + main_rules: Vec, + end_actions: Vec, + functions: HashMap, +} + +#[derive(Debug, Clone)] +struct AwkFunctionDef { + params: Vec, + body: Vec, +} + +#[derive(Debug)] +struct AwkRule { + pattern: Option, + actions: Vec, +} + +#[derive(Debug)] +enum AwkPattern { + Regex(Regex), + Expression(AwkExpr), + /// Range pattern: /start/,/end/ — matches from start to end inclusive. + /// Each sub-pattern can be a Regex or Expression. + Range(Box, Box), +} + +#[derive(Debug, Clone)] +enum AwkExpr { + Number(f64), + String(String), + Field(Box), // $n + Variable(String), // var + BinOp(Box, String, Box), + UnaryOp(String, Box), + Assign(String, Box), + ArrayAssign(String, Box, Box), // arr[key] = val + CompoundArrayAssign(String, Box, String, Box), // arr[key] += val + Concat(Vec), + FuncCall(String, Vec), + Regex(String), + #[allow(dead_code)] // matched in eval but construction deferred to pattern expansion + Match(Box, String), // expr ~ /pattern/ + PostIncrement(String), // var++ + PostDecrement(String), // var-- + PreIncrement(String), // ++var + PreDecrement(String), // --var + InArray(Box, String), // key in arr + FieldAssign(Box, Box), // $n = val + /// getline [var] < file as expression — returns 1 on success, 0 on EOF, -1 on error + GetlineFile { + var: Option, + file: Box, + }, +} + +/// Output target for print/printf redirection (e.g., `> file`, `>> file`). +/// Pipe (`| cmd`) is not supported and returns a clear error. +#[derive(Debug, Clone)] +enum AwkOutputTarget { + /// Truncate/create file: `> file` + Truncate(AwkExpr), + /// Append to file: `>> file` + Append(AwkExpr), +} + +#[derive(Debug, Clone)] +enum AwkAction { + Print(Vec, Option), + Printf(AwkExpr, Vec, Option), + Assign(String, AwkExpr), + ArrayAssign(String, AwkExpr, AwkExpr), // arr[key] = val + If(AwkExpr, Vec, Vec), + While(AwkExpr, Vec), + DoWhile(AwkExpr, Vec), + For(Box, AwkExpr, Box, Vec), + ForIn(String, String, Vec), // for (key in arr) { body } + Next, + Break, + Continue, + Delete(String, AwkExpr), // delete arr[key] + Getline, // getline — read next input record into $0 + /// getline [var] < file — read next line from file + GetlineFile { + var: Option, + file: AwkExpr, + }, + Exit(Option), + Return(Option), + Expression(AwkExpr), +} + +struct AwkState { + variables: HashMap, + fields: Vec, + fs: String, + ofs: String, + ors: String, + nr: usize, + nf: usize, + fnr: usize, + /// When true, fields are split per RFC 4180 CSV rules (--csv flag) + csv_mode: bool, +} + +#[derive(Debug, Clone, PartialEq)] +enum AwkValue { + Number(f64), + String(String), + Uninitialized, +} + +/// Format number using AWK's OFMT (%.6g): 6 significant digits, trim trailing zeros. +fn format_awk_number(n: f64) -> String { + if n.is_nan() { + return "nan".to_string(); + } + if n.is_infinite() { + return if n > 0.0 { "inf" } else { "-inf" }.to_string(); + } + // Integers: no decimal point + if n.fract() == 0.0 && n.abs() < 1e16 { + return format!("{}", n as i64); + } + // %.6g: use 6 significant digits + let abs = n.abs(); + let exp = abs.log10().floor() as i32; + if !(-4..6).contains(&exp) { + // Scientific notation: 5 decimal places = 6 sig digits + let mut s = format!("{:.*e}", 5, n); + // Trim trailing zeros in mantissa + if let Some(e_pos) = s.find('e') { + let (mantissa, exp_part) = s.split_at(e_pos); + let trimmed = mantissa.trim_end_matches('0').trim_end_matches('.'); + s = format!("{}{}", trimmed, exp_part); + } + // Normalize exponent format: e1 -> e+01 etc. to match C printf + // Actually AWK uses e+06 style. Rust uses e6. Fix: + if let Some(e_pos) = s.find('e') { + let exp_str = &s[e_pos + 1..]; + let exp_val: i32 = exp_str.parse().unwrap_or(0); + let mantissa = &s[..e_pos]; + s = format!("{}e{:+03}", mantissa, exp_val); + } + s + } else { + // Fixed notation + let decimal_places = (5 - exp).max(0) as usize; + let mut s = format!("{:.*}", decimal_places, n); + if s.contains('.') { + s = s.trim_end_matches('0').trim_end_matches('.').to_string(); + } + s + } +} + +impl AwkValue { + fn as_number(&self) -> f64 { + match self { + AwkValue::Number(n) => *n, + AwkValue::String(s) => s.parse().unwrap_or(0.0), + AwkValue::Uninitialized => 0.0, + } + } + + fn as_string(&self) -> String { + match self { + AwkValue::Number(n) => format_awk_number(*n), + AwkValue::String(s) => s.clone(), + AwkValue::Uninitialized => String::new(), + } + } + + fn as_bool(&self) -> bool { + match self { + AwkValue::Number(n) => *n != 0.0, + AwkValue::String(s) => { + if s.is_empty() { + return false; + } + // In awk, numeric strings evaluate as numbers in boolean context + if let Ok(n) = s.parse::() { + n != 0.0 + } else { + true + } + } + AwkValue::Uninitialized => false, + } + } +} + +impl Default for AwkState { + fn default() -> Self { + let mut variables = HashMap::new(); + // POSIX SUBSEP: subscript separator for multi-dimensional arrays + variables.insert("SUBSEP".to_string(), AwkValue::String("\x1c".to_string())); + Self { + variables, + fields: Vec::new(), + fs: " ".to_string(), + ofs: " ".to_string(), + ors: "\n".to_string(), + nr: 0, + nf: 0, + fnr: 0, + csv_mode: false, + } + } +} + +/// Parse a CSV line per RFC 4180: handle quoted fields, embedded commas, +/// and double-quote escaping. +fn csv_split_fields(line: &str) -> Vec { + let mut fields = Vec::new(); + let mut field = String::new(); + let mut in_quotes = false; + let mut chars = line.chars().peekable(); + + while let Some(c) = chars.next() { + if in_quotes { + if c == '"' { + if chars.peek() == Some(&'"') { + // Escaped quote + field.push('"'); + chars.next(); + } else { + in_quotes = false; + } + } else { + field.push(c); + } + } else if c == '"' { + in_quotes = true; + } else if c == ',' { + fields.push(std::mem::take(&mut field)); + } else { + field.push(c); + } + } + fields.push(field); + fields +} + +impl AwkState { + /// Split a line into fields based on current mode (CSV or FS) + fn split_fields(&self, line: &str) -> Vec { + if self.csv_mode { + csv_split_fields(line) + } else if self.fs == " " { + line.split_whitespace().map(String::from).collect() + } else { + line.split(&self.fs).map(String::from).collect() + } + } + + fn set_line(&mut self, line: &str) { + self.nr += 1; + self.fnr += 1; + + // Split by field separator + self.fields = self.split_fields(line); + + self.nf = self.fields.len(); + + // Set built-in variables + self.variables + .insert("NR".to_string(), AwkValue::Number(self.nr as f64)); + self.variables + .insert("NF".to_string(), AwkValue::Number(self.nf as f64)); + self.variables + .insert("FNR".to_string(), AwkValue::Number(self.fnr as f64)); + self.variables + .insert("$0".to_string(), AwkValue::String(line.to_string())); + } + + fn get_field(&self, n: usize) -> AwkValue { + if n == 0 { + // $0 is the whole line + self.variables + .get("$0") + .cloned() + .unwrap_or(AwkValue::Uninitialized) + } else if n <= self.fields.len() { + AwkValue::String(self.fields[n - 1].clone()) + } else { + AwkValue::Uninitialized + } + } + + fn get_variable(&self, name: &str) -> AwkValue { + match name { + "NR" => AwkValue::Number(self.nr as f64), + "NF" => AwkValue::Number(self.nf as f64), + "FNR" => AwkValue::Number(self.fnr as f64), + "FS" => AwkValue::String(self.fs.clone()), + "OFS" => AwkValue::String(self.ofs.clone()), + "ORS" => AwkValue::String(self.ors.clone()), + _ => self + .variables + .get(name) + .cloned() + .unwrap_or(AwkValue::Uninitialized), + } + } + + fn set_variable(&mut self, name: &str, value: AwkValue) { + match name { + "FS" => self.fs = value.as_string(), + "OFS" => self.ofs = value.as_string(), + "ORS" => self.ors = value.as_string(), + "$0" => { + let s = value.as_string(); + // Re-split fields when $0 is modified + self.fields = self.split_fields(&s); + self.nf = self.fields.len(); + self.variables + .insert("NF".to_string(), AwkValue::Number(self.nf as f64)); + self.variables.insert(name.to_string(), value); + } + _ => { + self.variables.insert(name.to_string(), value); + } + } + } +} + +// THREAT[TM-DOS-027]: parser-depth limit lives in +// `super::limits::AWK_MAX_PARSER_DEPTH` (guards against deeply nested +// expressions). + +/// Preprocess awk program: replace newlines with semicolons inside action blocks. +/// This makes newlines act as statement separators per POSIX awk spec. +/// Respects string literals, regex literals, and nested braces. +fn normalize_awk_newlines(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let chars: Vec = input.chars().collect(); + let mut i = 0; + let mut brace_depth = 0; + + while i < chars.len() { + match chars[i] { + '{' => { + brace_depth += 1; + result.push('{'); + i += 1; + } + '}' => { + if brace_depth > 0 { + brace_depth -= 1; + } + result.push('}'); + i += 1; + } + '"' => { + // String literal — pass through unchanged + result.push('"'); + i += 1; + while i < chars.len() && chars[i] != '"' { + if chars[i] == '\\' && i + 1 < chars.len() { + result.push(chars[i]); + i += 1; + } + result.push(chars[i]); + i += 1; + } + if i < chars.len() { + result.push(chars[i]); // closing " + i += 1; + } + } + '/' => { + // Regex literal — pass through unchanged (both pattern and expression context) + result.push('/'); + i += 1; + while i < chars.len() && chars[i] != '/' { + if chars[i] == '\\' && i + 1 < chars.len() { + result.push(chars[i]); + i += 1; + } + result.push(chars[i]); + i += 1; + } + if i < chars.len() { + result.push(chars[i]); // closing / + i += 1; + } + } + '#' => { + // Comment — skip to end of line, replace with newline/semicolon + while i < chars.len() && chars[i] != '\n' { + i += 1; + } + if i < chars.len() { + if brace_depth > 0 { + result.push(';'); + } else { + result.push('\n'); + } + i += 1; + } + } + '\\' if i + 1 < chars.len() && chars[i + 1] == '\n' => { + // Backslash-newline: line continuation — join lines + i += 2; + } + '\n' if brace_depth > 0 => { + // Inside action block: replace newline with semicolon + result.push(';'); + i += 1; + } + _ => { + result.push(chars[i]); + i += 1; + } + } + } + result +} + +mod interpreter; +mod parser; +use interpreter::{AwkFlow, AwkInterpreter}; +use parser::AwkParser; + +impl Awk { + /// Process C-style escape sequences in a string (e.g., \t → tab, \n → newline) + fn process_escape_sequences(s: &str) -> String { + let mut result = String::new(); + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + match chars.next() { + Some('t') => result.push('\t'), + Some('n') => result.push('\n'), + Some('r') => result.push('\r'), + Some('\\') => result.push('\\'), + Some('a') => result.push('\x07'), + Some('b') => result.push('\x08'), + Some('f') => result.push('\x0C'), + Some(other) => { + result.push('\\'); + result.push(other); + } + None => result.push('\\'), + } + } else { + result.push(c); + } + } + result + } +} + +#[async_trait] +impl Builtin for Awk { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = super::check_help_version( + ctx.args, + "Usage: awk [OPTION]... 'program' [FILE]...\nPattern scanning and processing language.\n\n -F SEP\t\tuse SEP as field separator\n -v var=val\tassign variable before execution\n -f progfile\tread program from file\n --csv, -k\tCSV mode (set field separator to comma)\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + Some("awk (bashkit) 0.1"), + ) { + return Ok(r); + } + let mut program_str = String::new(); + let mut files: Vec = Vec::new(); + let mut field_sep = " ".to_string(); + let mut pre_vars: Vec<(String, String)> = Vec::new(); + let mut csv_mode = false; + let mut i = 0; + + while i < ctx.args.len() { + let arg = &ctx.args[i]; + if arg == "--csv" || arg == "-k" { + csv_mode = true; + field_sep = ",".to_string(); + } else if arg == "-F" { + i += 1; + if i < ctx.args.len() { + field_sep = ctx.args[i].clone(); + } + } else if let Some(sep) = arg.strip_prefix("-F") { + field_sep = sep.to_string(); + } else if arg == "-v" { + // Variable assignment: -v var=value + i += 1; + if i < ctx.args.len() + && let Some(eq_pos) = ctx.args[i].find('=') + { + let name = ctx.args[i][..eq_pos].to_string(); + let mut value = ctx.args[i][eq_pos + 1..].to_string(); + // Strip surrounding quotes if present (shell may pass them) + if (value.starts_with('"') && value.ends_with('"')) + || (value.starts_with('\'') && value.ends_with('\'')) + { + value = value[1..value.len() - 1].to_string(); + } + pre_vars.push((name, value)); + } + } else if arg == "-f" { + // Read program from file + i += 1; + if i < ctx.args.len() { + let path = if ctx.args[i].starts_with('/') { + std::path::PathBuf::from(&ctx.args[i]) + } else { + ctx.cwd.join(&ctx.args[i]) + }; + program_str = match read_text_file(&*ctx.fs, &path, "awk").await { + Ok(t) => t, + Err(e) => return Ok(e), + }; + } + } else if arg.starts_with('-') { + // Unknown option - ignore + } else if program_str.is_empty() { + program_str = arg.clone(); + } else { + files.push(arg.clone()); + } + i += 1; + } + + if program_str.is_empty() { + return Err(Error::Execution("awk: no program given".to_string())); + } + + let program_str = normalize_awk_newlines(&program_str); + let mut parser = AwkParser::new(&program_str); + let program = parser.parse()?; + + let mut interp = AwkInterpreter::new(); + interp.max_loop_iterations = ctx + .execution_extension::() + .map(|limits| limits.max_loop_iterations) + .unwrap_or_else(|| ExecutionLimits::default().max_loop_iterations); + interp.functions = program.functions.clone(); + interp.state.fs = Self::process_escape_sequences(&field_sep); + interp.fs = Some(ctx.fs.clone()); + interp.cwd = ctx.cwd.clone(); + if csv_mode { + interp.state.csv_mode = true; + interp.state.ofs = ",".to_string(); + } + + // Set pre-assigned variables (-v) + for (name, value) in &pre_vars { + let awk_val = if let Ok(n) = value.parse::() { + AwkValue::Number(n) + } else { + AwkValue::String(value.clone()) + }; + interp.state.set_variable(name, awk_val); + } + + // Run BEGIN actions + let mut exit_code: Option = None; + for action in &program.begin_actions { + if let AwkFlow::Exit(code) = interp.exec_action(action) { + exit_code = code; + // Run END actions even after exit + for end_action in &program.end_actions { + if let AwkFlow::Exit(_) = interp.exec_action(end_action) { + break; + } + } + Self::flush_file_outputs(&interp, &ctx).await?; + let mut result = ExecResult::with_code(interp.output, exit_code.unwrap_or(0)); + result.stderr = interp.stderr_output; + return Ok(result); + } + } + + // Process input + let inputs: Vec = if files.is_empty() { + vec![ctx.stdin.unwrap_or("").to_string()] + } else { + let mut inputs = Vec::new(); + for file in &files { + let path = if file.starts_with('/') { + std::path::PathBuf::from(file) + } else { + ctx.cwd.join(file) + }; + + let text = match read_text_file(&*ctx.fs, &path, "awk").await { + Ok(t) => t, + Err(e) => return Ok(e), + }; + inputs.push(text); + } + inputs + }; + + 'files: for (file_idx, input) in inputs.iter().enumerate() { + interp.state.fnr = 0; + // Set FILENAME to current file path, or empty for stdin + if !files.is_empty() { + interp.state.variables.insert( + "FILENAME".to_string(), + AwkValue::String(files[file_idx].clone()), + ); + } else { + interp + .state + .variables + .insert("FILENAME".to_string(), AwkValue::String(String::new())); + } + // Index-based iteration so getline can advance the index + interp.input_lines = input.lines().map(|l| l.to_string()).collect(); + interp.line_index = 0; + + while interp.line_index < interp.input_lines.len() { + let line = interp.input_lines[interp.line_index].clone(); + interp.state.set_line(&line); + + for (rule_idx, rule) in program.main_rules.iter().enumerate() { + // Check pattern (with range state tracking) + let matches = match &rule.pattern { + Some(pattern) => interp.matches_pattern_with_index(pattern, rule_idx), + None => true, + }; + + if matches { + let mut next_record = false; + for action in &rule.actions { + match interp.exec_action(action) { + AwkFlow::Continue => {} + AwkFlow::Next => { + next_record = true; + break; + } + AwkFlow::Exit(code) => { + exit_code = code; + break 'files; + } + _ => {} + } + } + if next_record { + break; + } + } + } + interp.line_index += 1; + } + } + + // Run END actions (awk runs END even after exit in main body) + for action in &program.end_actions { + if let AwkFlow::Exit(code) = interp.exec_action(action) { + if exit_code.is_none() { + exit_code = code; + } + break; + } + } + + Self::flush_file_outputs(&interp, &ctx).await?; + let mut result = ExecResult::with_code(interp.output, exit_code.unwrap_or(0)); + result.stderr = interp.stderr_output; + Ok(result) + } +} + +impl Awk { + /// Flush buffered file outputs to VFS. + async fn flush_file_outputs(interp: &AwkInterpreter, ctx: &Context<'_>) -> Result<()> { + // Truncate mode: write entire buffer as file content + for (path, content) in &interp.file_outputs { + let path = if path.starts_with('/') { + std::path::PathBuf::from(path) + } else { + ctx.cwd.join(path) + }; + ctx.fs.write_file(&path, content.as_bytes()).await?; + } + // Append mode: append buffer to existing file (or create) + for (path, content) in &interp.file_appends { + let path = if path.starts_with('/') { + std::path::PathBuf::from(path) + } else { + ctx.cwd.join(path) + }; + ctx.fs.append_file(&path, content.as_bytes()).await?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/bashkit/src/builtins/awk/parser.rs b/crates/bashkit/src/builtins/awk/parser.rs new file mode 100644 index 000000000..8240b61f7 --- /dev/null +++ b/crates/bashkit/src/builtins/awk/parser.rs @@ -0,0 +1,1619 @@ +//! AWK parser - turns a program string into an `AwkProgram` AST. + +use std::collections::HashMap; + +use super::{AwkAction, AwkExpr, AwkFunctionDef, AwkOutputTarget, AwkPattern, AwkProgram, AwkRule}; +use crate::builtins::limits::AWK_MAX_PARSER_DEPTH as MAX_AWK_PARSER_DEPTH; +use crate::builtins::search_common::build_regex; +use crate::error::{Error, Result}; + +pub(super) struct AwkParser<'a> { + input: &'a str, + pos: usize, + /// Current recursion depth for expression parsing + depth: usize, + /// When true, `>` and `>>` are output redirection, not comparison ops. + /// Set during print/printf argument parsing per POSIX awk semantics. + in_print_context: bool, +} + +impl<'a> AwkParser<'a> { + fn is_identifier_start(c: char) -> bool { + c.is_alphabetic() || c == '_' + } + + fn is_identifier_continue(c: char) -> bool { + c.is_alphanumeric() || c == '_' + } + + pub(super) fn new(input: &'a str) -> Self { + Self { + input, + pos: 0, + depth: 0, + in_print_context: false, + } + } + + /// Get the character at the current byte position (char-boundary safe). + /// Returns None if pos is at or past end of input. + fn current_char(&self) -> Option { + self.input[self.pos..].chars().next() + } + + /// Advance pos past the current character (handles multi-byte UTF-8). + fn advance(&mut self) { + if let Some(c) = self.current_char() { + self.pos += c.len_utf8(); + } + } + + fn consume_while(&mut self, predicate: fn(char) -> bool) { + while self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if predicate(c) { + self.advance(); + } else { + break; + } + } + } + + /// THREAT[TM-DOS-027]: Increment depth, error if limit exceeded + fn push_depth(&mut self) -> Result<()> { + self.depth += 1; + if self.depth > MAX_AWK_PARSER_DEPTH { + return Err(Error::Execution(format!( + "awk: expression nesting too deep ({} levels, max {})", + self.depth, MAX_AWK_PARSER_DEPTH + ))); + } + Ok(()) + } + + /// Decrement depth after leaving a recursive parse + fn pop_depth(&mut self) { + if self.depth > 0 { + self.depth -= 1; + } + } + + pub(super) fn parse(&mut self) -> Result { + let mut program = AwkProgram { + begin_actions: Vec::new(), + main_rules: Vec::new(), + end_actions: Vec::new(), + functions: HashMap::new(), + }; + + self.skip_whitespace(); + + while self.pos < self.input.len() { + self.skip_whitespace(); + if self.pos >= self.input.len() { + break; + } + + // Check for function/BEGIN/END + if self.matches_keyword("function") { + self.skip_whitespace(); + let (name, func_def) = self.parse_function_def()?; + program.functions.insert(name, func_def); + } else if self.matches_keyword("BEGIN") { + self.skip_whitespace(); + let actions = self.parse_action_block()?; + program.begin_actions.extend(actions); + } else if self.matches_keyword("END") { + self.skip_whitespace(); + let actions = self.parse_action_block()?; + program.end_actions.extend(actions); + } else { + // Pattern-action rule + let rule = self.parse_rule()?; + program.main_rules.push(rule); + } + + self.skip_whitespace(); + } + + // If no rules, add default print rule + if program.main_rules.is_empty() + && program.begin_actions.is_empty() + && program.end_actions.is_empty() + { + program.main_rules.push(AwkRule { + pattern: None, + actions: vec![AwkAction::Print( + vec![AwkExpr::Field(Box::new(AwkExpr::Number(0.0)))], + None, + )], + }); + } + + Ok(program) + } + + /// Parse a user-defined function: function name(params) { body } + fn parse_function_def(&mut self) -> Result<(String, AwkFunctionDef)> { + // Parse function name + let name = self.read_identifier()?; + self.skip_whitespace(); + + // Expect '(' + if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { + return Err(Error::Execution( + "awk: expected '(' after function name".to_string(), + )); + } + self.pos += 1; + + // Parse parameter list + let mut params = Vec::new(); + self.skip_whitespace(); + while self.pos < self.input.len() && self.current_char().unwrap() != ')' { + if !params.is_empty() { + if self.current_char().unwrap() == ',' { + self.pos += 1; + } + self.skip_whitespace(); + } + if self.pos < self.input.len() && self.current_char().unwrap() != ')' { + params.push(self.read_identifier()?); + self.skip_whitespace(); + } + } + + // Expect ')' + if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { + return Err(Error::Execution( + "awk: expected ')' after function parameters".to_string(), + )); + } + self.pos += 1; + self.skip_whitespace(); + + // Parse function body as action block + let body = self.parse_action_block()?; + + Ok((name, AwkFunctionDef { params, body })) + } + + /// Read an identifier (alphanumeric + underscore) + fn read_identifier(&mut self) -> Result { + let start = self.pos; + self.consume_while(Self::is_identifier_continue); + if self.pos == start { + return Err(Error::Execution("awk: expected identifier".to_string())); + } + Ok(self.input[start..self.pos].to_string()) + } + + fn matches_keyword(&mut self, keyword: &str) -> bool { + if self.input[self.pos..].starts_with(keyword) { + let after = self.pos + keyword.len(); + if after >= self.input.len() || { + let c = self.input[after..].chars().next().unwrap(); + !c.is_alphanumeric() && c != '_' + } { + self.pos = after; + return true; + } + } + false + } + + fn skip_whitespace(&mut self) { + while self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if c.is_whitespace() { + self.advance(); + } else if c == '#' { + // Comment - skip to end of line (may contain multi-byte chars) + while self.pos < self.input.len() && self.current_char().unwrap() != '\n' { + self.advance(); + } + } else { + break; + } + } + } + + fn parse_rule(&mut self) -> Result { + let pattern = self.parse_pattern()?; + self.skip_whitespace(); + + let actions = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { + self.parse_action_block()? + } else if pattern.is_some() { + // Default action is print + vec![AwkAction::Print( + vec![AwkExpr::Field(Box::new(AwkExpr::Number(0.0)))], + None, + )] + } else { + Vec::new() + }; + + Ok(AwkRule { pattern, actions }) + } + + /// Parse a `/regex/` literal into an `AwkPattern::Regex`. + /// Assumes the leading `/` is at `self.pos`. + fn parse_regex_pattern(&mut self) -> Result { + self.pos += 1; // skip opening '/' + let start = self.pos; + while self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if c == '/' { + let pattern = &self.input[start..self.pos]; + self.pos += 1; + let regex = build_regex(pattern) + .map_err(|e| Error::Execution(format!("awk: invalid regex: {}", e)))?; + return Ok(AwkPattern::Regex(regex)); + } else if c == '\\' { + self.pos += 1; // skip '\\' (ASCII) + self.advance(); // skip next char (may be multi-byte) + } else { + self.advance(); // regex content may be multi-byte + } + } + Err(Error::Execution("awk: unterminated regex".to_string())) + } + + fn parse_pattern(&mut self) -> Result> { + self.skip_whitespace(); + + if self.pos >= self.input.len() { + return Ok(None); + } + + let c = self.current_char().unwrap(); + + // Check for regex pattern + let first: Option = if c == '/' { + Some(self.parse_regex_pattern()?) + } else if c == '{' { + // Check for opening brace (no pattern) + return Ok(None); + } else { + // Expression pattern + let expr = self.parse_expression()?; + Some(AwkPattern::Expression(expr)) + }; + + // Check for range pattern: pattern1 , pattern2 + if let Some(first_pat) = first { + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == ',' { + self.pos += 1; // consume ',' + let second = self.parse_pattern()?; + let second_pat = second.ok_or_else(|| { + Error::Execution("awk: expected second pattern in range".to_string()) + })?; + Ok(Some(AwkPattern::Range( + Box::new(first_pat), + Box::new(second_pat), + ))) + } else { + Ok(Some(first_pat)) + } + } else { + Ok(None) + } + } + + fn parse_action_block(&mut self) -> Result> { + self.skip_whitespace(); + + if self.pos >= self.input.len() || self.current_char().unwrap() != '{' { + return Err(Error::Execution("awk: expected '{'".to_string())); + } + self.pos += 1; + + let mut actions = Vec::new(); + + loop { + self.skip_whitespace(); + if self.pos >= self.input.len() { + return Err(Error::Execution( + "awk: unterminated action block".to_string(), + )); + } + + // Skip empty statements (consecutive semicolons from newline normalization) + while self.pos < self.input.len() && self.current_char().unwrap() == ';' { + self.pos += 1; + self.skip_whitespace(); + } + if self.pos >= self.input.len() { + return Err(Error::Execution( + "awk: unterminated action block".to_string(), + )); + } + + let c = self.current_char().unwrap(); + if c == '}' { + self.pos += 1; + break; + } + + let action = self.parse_action()?; + actions.push(action); + + self.skip_whitespace(); + // Allow semicolon separator + if self.pos < self.input.len() && self.current_char().unwrap() == ';' { + self.pos += 1; + } + } + + Ok(actions) + } + + fn parse_action(&mut self) -> Result { + self.skip_whitespace(); + + // Check for keywords + if self.matches_keyword("print") { + return self.parse_print(); + } + if self.matches_keyword("printf") { + return self.parse_printf(); + } + if self.matches_keyword("next") { + return Ok(AwkAction::Next); + } + if self.matches_keyword("break") { + return Ok(AwkAction::Break); + } + if self.matches_keyword("continue") { + return Ok(AwkAction::Continue); + } + if self.matches_keyword("delete") { + return self.parse_delete(); + } + if self.matches_keyword("getline") { + return self.parse_getline(); + } + if self.matches_keyword("exit") { + self.skip_whitespace(); + if self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if c != '}' && c != ';' { + let expr = self.parse_expression()?; + return Ok(AwkAction::Exit(Some(expr))); + } + } + return Ok(AwkAction::Exit(None)); + } + if self.matches_keyword("return") { + self.skip_whitespace(); + if self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if c != '}' && c != ';' { + let expr = self.parse_expression()?; + return Ok(AwkAction::Return(Some(expr))); + } + } + return Ok(AwkAction::Return(None)); + } + if self.matches_keyword("if") { + return self.parse_if(); + } + if self.matches_keyword("for") { + return self.parse_for(); + } + if self.matches_keyword("while") { + return self.parse_while(); + } + if self.matches_keyword("do") { + return self.parse_do_while(); + } + + // Otherwise it's an expression (including assignment) + let expr = self.parse_expression()?; + + // Check if it's an assignment + match expr { + AwkExpr::Assign(name, val) => Ok(AwkAction::Assign(name, *val)), + AwkExpr::ArrayAssign(name, key, val) => Ok(AwkAction::ArrayAssign(name, *key, *val)), + _ => Ok(AwkAction::Expression(expr)), + } + } + + fn parse_print(&mut self) -> Result { + self.skip_whitespace(); + let mut args = Vec::new(); + + // Enable print context so `>` and `>>` are not parsed as comparison + self.in_print_context = true; + + loop { + if self.pos >= self.input.len() { + break; + } + let c = self.current_char().unwrap(); + if c == '}' || c == ';' { + break; + } + // Stop at output redirection operators + if c == '>' || c == '|' { + break; + } + + let expr = self.parse_expression()?; + args.push(expr); + + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == ',' { + self.pos += 1; + self.skip_whitespace(); + } else { + break; + } + } + + if args.is_empty() { + args.push(AwkExpr::Field(Box::new(AwkExpr::Number(0.0)))); + } + + let target = self.parse_output_target()?; + self.in_print_context = false; + + Ok(AwkAction::Print(args, target)) + } + + fn parse_printf(&mut self) -> Result { + self.skip_whitespace(); + + // Enable print context so `>` and `>>` are not parsed as comparison + self.in_print_context = true; + + // Handle optional parenthesized form: printf("format", args) + let has_parens = self.pos < self.input.len() && self.current_char().unwrap() == '('; + if has_parens { + self.pos += 1; + self.skip_whitespace(); + } + + // Parse format string — accepts string literals or expressions + if self.pos >= self.input.len() { + self.in_print_context = false; + return Err(Error::Execution( + "awk: printf requires format string".to_string(), + )); + } + + let format_expr = if self.current_char().unwrap() == '"' { + // String literal format — parse directly + let s = self.parse_string()?; + AwkExpr::String(s) + } else { + // Expression as format string (e.g., printf substr($1,1,1)) + self.parse_expression()? + }; + let mut args = Vec::new(); + + self.skip_whitespace(); + while self.pos < self.input.len() && self.current_char().unwrap() == ',' { + self.pos += 1; + self.skip_whitespace(); + let expr = self.parse_expression()?; + args.push(expr); + self.skip_whitespace(); + } + + if has_parens && self.pos < self.input.len() && self.current_char().unwrap() == ')' { + self.pos += 1; + } + + let target = self.parse_output_target()?; + self.in_print_context = false; + + Ok(AwkAction::Printf(format_expr, args, target)) + } + + /// Parse optional output target after print/printf arguments: `> file`, `>> file`, `| cmd`. + /// Pipe is unsupported and returns a clear error. + fn parse_output_target(&mut self) -> Result> { + self.skip_whitespace(); + if self.pos >= self.input.len() { + return Ok(None); + } + + let c = self.current_char().unwrap(); + if c == '>' { + self.pos += 1; + // Check for >> + let append = self.pos < self.input.len() && self.current_char().unwrap() == '>'; + if append { + self.pos += 1; + } + self.skip_whitespace(); + // Temporarily disable print context to parse the target as a normal expression + self.in_print_context = false; + let target = self.parse_expression()?; + self.in_print_context = true; + if append { + Ok(Some(AwkOutputTarget::Append(target))) + } else { + Ok(Some(AwkOutputTarget::Truncate(target))) + } + } else if c == '|' { + // Pipe output (e.g., `print ... | "cmd"`) not supported in virtual mode + Err(Error::Execution( + "awk: pipe output redirection (|) is not supported".to_string(), + )) + } else { + Ok(None) + } + } + + /// THREAT[TM-DOS-027]: Track depth for nested if/action blocks + fn parse_if(&mut self) -> Result { + self.push_depth()?; + + self.skip_whitespace(); + + if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { + self.pop_depth(); + return Err(Error::Execution("awk: expected '(' after if".to_string())); + } + self.pos += 1; + + let condition = self.parse_expression()?; + + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { + self.pop_depth(); + return Err(Error::Execution( + "awk: expected ')' after condition".to_string(), + )); + } + self.pos += 1; + + self.skip_whitespace(); + let then_actions = if self.current_char().unwrap() == '{' { + self.parse_action_block()? + } else { + vec![self.parse_action()?] + }; + + self.skip_whitespace(); + // Consume optional ';' before else + if self.pos < self.input.len() && self.current_char().unwrap() == ';' { + self.pos += 1; + self.skip_whitespace(); + } + let else_actions = if self.matches_keyword("else") { + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == '{' { + self.parse_action_block()? + } else { + vec![self.parse_action()?] + } + } else { + Vec::new() + }; + + self.pop_depth(); + Ok(AwkAction::If(condition, then_actions, else_actions)) + } + + fn parse_for(&mut self) -> Result { + self.skip_whitespace(); + + if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { + return Err(Error::Execution("awk: expected '(' after for".to_string())); + } + self.pos += 1; + self.skip_whitespace(); + + // Check for `for (key in arr)` syntax + let saved_pos = self.pos; + if let Ok(AwkExpr::Variable(var_name)) = self.parse_primary() { + self.skip_whitespace(); + if self.matches_keyword("in") { + self.skip_whitespace(); + // Parse array name + let start = self.pos; + self.consume_while(Self::is_identifier_continue); + let arr_name = self.input[start..self.pos].to_string(); + + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { + return Err(Error::Execution("awk: expected ')' in for-in".to_string())); + } + self.pos += 1; + + self.skip_whitespace(); + let body = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { + self.parse_action_block()? + } else { + vec![self.parse_action()?] + }; + + return Ok(AwkAction::ForIn(var_name, arr_name, body)); + } + } + + // Not for-in, backtrack and parse C-style for + self.pos = saved_pos; + + // Parse init + let init_expr = self.parse_expression()?; + let init = match init_expr { + AwkExpr::Assign(name, val) => AwkAction::Assign(name, *val), + AwkExpr::ArrayAssign(name, key, val) => AwkAction::ArrayAssign(name, *key, *val), + _ => AwkAction::Expression(init_expr), + }; + + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ';' { + return Err(Error::Execution( + "awk: expected ';' in for statement".to_string(), + )); + } + self.pos += 1; + + // Parse condition + self.skip_whitespace(); + let condition = self.parse_expression()?; + + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ';' { + return Err(Error::Execution( + "awk: expected ';' in for statement".to_string(), + )); + } + self.pos += 1; + + // Parse update + self.skip_whitespace(); + let update_expr = self.parse_expression()?; + let update = match update_expr { + AwkExpr::Assign(name, val) => AwkAction::Assign(name, *val), + AwkExpr::ArrayAssign(name, key, val) => AwkAction::ArrayAssign(name, *key, *val), + _ => AwkAction::Expression(update_expr), + }; + + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { + return Err(Error::Execution( + "awk: expected ')' in for statement".to_string(), + )); + } + self.pos += 1; + + self.skip_whitespace(); + let body = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { + self.parse_action_block()? + } else { + vec![self.parse_action()?] + }; + + Ok(AwkAction::For( + Box::new(init), + condition, + Box::new(update), + body, + )) + } + + fn parse_while(&mut self) -> Result { + self.skip_whitespace(); + + if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { + return Err(Error::Execution( + "awk: expected '(' after while".to_string(), + )); + } + self.pos += 1; + + let condition = self.parse_expression()?; + + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { + return Err(Error::Execution( + "awk: expected ')' after while condition".to_string(), + )); + } + self.pos += 1; + + self.skip_whitespace(); + let body = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { + self.parse_action_block()? + } else { + vec![self.parse_action()?] + }; + + Ok(AwkAction::While(condition, body)) + } + + fn parse_do_while(&mut self) -> Result { + self.skip_whitespace(); + + let body = if self.pos < self.input.len() && self.current_char().unwrap() == '{' { + self.parse_action_block()? + } else { + vec![self.parse_action()?] + }; + + self.skip_whitespace(); + if !self.matches_keyword("while") { + return Err(Error::Execution( + "awk: expected 'while' after do body".to_string(), + )); + } + + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != '(' { + return Err(Error::Execution( + "awk: expected '(' after do-while".to_string(), + )); + } + self.pos += 1; + + let condition = self.parse_expression()?; + + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { + return Err(Error::Execution( + "awk: expected ')' in do-while".to_string(), + )); + } + self.pos += 1; + + Ok(AwkAction::DoWhile(condition, body)) + } + + fn parse_delete(&mut self) -> Result { + self.skip_whitespace(); + + // Parse array name + let start = self.pos; + self.consume_while(Self::is_identifier_continue); + let arr_name = self.input[start..self.pos].to_string(); + + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == '[' { + self.pos += 1; + let index = self.parse_expression()?; + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ']' { + return Err(Error::Execution("awk: expected ']'".to_string())); + } + self.pos += 1; + Ok(AwkAction::Delete(arr_name, index)) + } else { + // delete entire array + Ok(AwkAction::Delete( + arr_name, + AwkExpr::String("*".to_string()), + )) + } + } + + /// Parse `getline [var] [< file]`. + /// + /// Forms: + /// - `getline` — read next input record into $0 + /// - `getline var` — read next input record into var + /// - `getline var < file` — read next line from file into var + /// - `getline < file` — read next line from file into $0 + fn parse_getline(&mut self) -> Result { + self.skip_whitespace(); + + // Check what follows: variable name, '<', or end of statement + let mut var: Option = None; + + if self.pos < self.input.len() { + let c = self.current_char().unwrap(); + // If next char is an identifier start (not '<', ';', '}', etc.), parse variable + if Self::is_identifier_start(c) { + let start = self.pos; + self.advance(); + self.consume_while(Self::is_identifier_continue); + var = Some(self.input[start..self.pos].to_string()); + self.skip_whitespace(); + } + } + + // Check for '< file' redirection + if self.pos < self.input.len() && self.current_char().unwrap() == '<' { + self.pos += 1; // consume '<' + self.skip_whitespace(); + let file = self.parse_primary()?; + return Ok(AwkAction::GetlineFile { var, file }); + } + + // Plain getline (with optional var — but plain `getline var` without + // file redirection just reads next input line into var; for now we + // treat that the same as plain getline which updates $0). + // TODO: `getline var` should store into var without updating $0 + Ok(AwkAction::Getline) + } + + /// Parse `getline [var] [< file]` as an expression returning 1/0/-1. + fn parse_getline_expr(&mut self) -> Result { + self.skip_whitespace(); + + let mut var: Option = None; + + if self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if Self::is_identifier_start(c) { + let start = self.pos; + self.advance(); + self.consume_while(Self::is_identifier_continue); + var = Some(self.input[start..self.pos].to_string()); + self.skip_whitespace(); + } + } + + // Check for '< file' redirection + if self.pos < self.input.len() && self.current_char().unwrap() == '<' { + self.pos += 1; // consume '<' + self.skip_whitespace(); + let file = self.parse_primary()?; + return Ok(AwkExpr::GetlineFile { + var, + file: Box::new(file), + }); + } + + // Plain getline expression without file — use FuncCall to represent + // TODO: support plain getline as expression (advance input line) + Ok(AwkExpr::FuncCall("__getline".to_string(), vec![])) + } + + /// THREAT[TM-DOS-027]: Track depth on every expression entry + fn parse_expression(&mut self) -> Result { + self.push_depth()?; + let result = self.parse_assignment(); + self.pop_depth(); + result + } + + fn parse_assignment(&mut self) -> Result { + let expr = self.parse_ternary()?; + + self.skip_whitespace(); + if self.pos >= self.input.len() { + return Ok(expr); + } + + // Check for compound assignment operators (+=, -=, *=, /=, %=) + let compound_ops = ["+=", "-=", "*=", "/=", "%="]; + for op in compound_ops { + if self.input[self.pos..].starts_with(op) { + self.pos += op.len(); + self.skip_whitespace(); + let value = self.parse_assignment()?; + + match &expr { + AwkExpr::Variable(name) => { + let bin_op = &op[..1]; + let current = AwkExpr::Variable(name.clone()); + let combined = + AwkExpr::BinOp(Box::new(current), bin_op.to_string(), Box::new(value)); + return Ok(AwkExpr::Assign(name.clone(), Box::new(combined))); + } + AwkExpr::FuncCall(fname, args) + if fname == "__array_access" && args.len() == 2 => + { + if let AwkExpr::Variable(arr_name) = &args[0] { + let bin_op = &op[..1]; + return Ok(AwkExpr::CompoundArrayAssign( + arr_name.clone(), + Box::new(args[1].clone()), + bin_op.to_string(), + Box::new(value), + )); + } + return Err(Error::Execution( + "awk: invalid assignment target".to_string(), + )); + } + AwkExpr::Field(index) => { + let bin_op = &op[..1]; + let current = AwkExpr::Field(index.clone()); + let combined = + AwkExpr::BinOp(Box::new(current), bin_op.to_string(), Box::new(value)); + return Ok(AwkExpr::FieldAssign(index.clone(), Box::new(combined))); + } + _ => { + return Err(Error::Execution( + "awk: invalid assignment target".to_string(), + )); + } + } + } + } + + // Simple assignment + if self.current_char().unwrap() == '=' { + let next = self.input[self.pos..].chars().nth(1); + if next != Some('=') && next != Some('~') { + self.pos += 1; + self.skip_whitespace(); + let value = self.parse_assignment()?; + + match expr { + AwkExpr::Variable(name) => { + return Ok(AwkExpr::Assign(name, Box::new(value))); + } + AwkExpr::FuncCall(ref fname, ref args) + if fname == "__array_access" && args.len() == 2 => + { + if let AwkExpr::Variable(arr_name) = &args[0] { + return Ok(AwkExpr::ArrayAssign( + arr_name.clone(), + Box::new(args[1].clone()), + Box::new(value), + )); + } + return Err(Error::Execution( + "awk: invalid assignment target".to_string(), + )); + } + AwkExpr::Field(index) => { + return Ok(AwkExpr::FieldAssign(index, Box::new(value))); + } + _ => { + return Err(Error::Execution( + "awk: invalid assignment target".to_string(), + )); + } + } + } + } + + Ok(expr) + } + + fn parse_ternary(&mut self) -> Result { + let expr = self.parse_or()?; + + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == '?' { + self.pos += 1; + self.skip_whitespace(); + let then_expr = self.parse_expression()?; + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == ':' { + self.pos += 1; + self.skip_whitespace(); + let else_expr = self.parse_expression()?; + // Encode ternary as a function call for evaluation + return Ok(AwkExpr::FuncCall( + "__ternary".to_string(), + vec![expr, then_expr, else_expr], + )); + } + } + + Ok(expr) + } + + fn parse_or(&mut self) -> Result { + let mut left = self.parse_and()?; + + loop { + self.skip_whitespace(); + if self.input[self.pos..].starts_with("||") { + self.pos += 2; + self.skip_whitespace(); + let right = self.parse_and()?; + left = AwkExpr::BinOp(Box::new(left), "||".to_string(), Box::new(right)); + } else { + break; + } + } + + Ok(left) + } + + fn parse_and(&mut self) -> Result { + let mut left = self.parse_comparison()?; + + loop { + self.skip_whitespace(); + if self.input[self.pos..].starts_with("&&") { + self.pos += 2; + self.skip_whitespace(); + let right = self.parse_comparison()?; + left = AwkExpr::BinOp(Box::new(left), "&&".to_string(), Box::new(right)); + } else { + break; + } + } + + Ok(left) + } + + fn parse_comparison(&mut self) -> Result { + let left = self.parse_concat()?; + + self.skip_whitespace(); + + // Check for `in` operator: (key in arr) + if self.matches_keyword("in") { + self.skip_whitespace(); + let start = self.pos; + self.consume_while(Self::is_identifier_continue); + let arr_name = self.input[start..self.pos].to_string(); + return Ok(AwkExpr::InArray(Box::new(left), arr_name)); + } + + // In print context, `>` and `>>` are output redirection, not comparison. + // `>=` remains a comparison operator even in print context. + let ops: &[&str] = if self.in_print_context { + &["==", "!=", "<=", ">=", "<", "~", "!~"] + } else { + &["==", "!=", "<=", ">=", "<", ">", "~", "!~"] + }; + + for op in ops { + if self.input[self.pos..].starts_with(op) { + self.pos += op.len(); + self.skip_whitespace(); + let right = self.parse_concat()?; + return Ok(AwkExpr::BinOp( + Box::new(left), + op.to_string(), + Box::new(right), + )); + } + } + + Ok(left) + } + + fn is_keyword_at_pos(&self) -> bool { + let remaining = &self.input[self.pos..]; + let keywords = [ + "in", "if", "else", "while", "for", "do", "break", "continue", "next", "exit", + "return", "delete", "getline", "print", "printf", "function", + ]; + for kw in keywords { + if remaining.starts_with(kw) { + let after = self.pos + kw.len(); + if after >= self.input.len() || { + let c = self.input[after..].chars().next().unwrap(); + !c.is_alphanumeric() && c != '_' + } { + return true; + } + } + } + false + } + + fn parse_concat(&mut self) -> Result { + let mut parts = vec![self.parse_additive()?]; + + loop { + self.skip_whitespace(); + if self.pos >= self.input.len() { + break; + } + + let c = self.current_char().unwrap(); + // Check if this could be the start of another value for concatenation + if c == '"' || c == '$' || Self::is_identifier_start(c) || c == '(' { + // But not if it's a keyword or operator + let remaining = &self.input[self.pos..]; + if !remaining.starts_with("||") + && !remaining.starts_with("&&") + && !remaining.starts_with("==") + && !remaining.starts_with("!=") + && !self.is_keyword_at_pos() + && let Ok(next) = self.parse_additive() + { + parts.push(next); + continue; + } + } + break; + } + + if parts.len() == 1 { + Ok(parts.remove(0)) + } else { + Ok(AwkExpr::Concat(parts)) + } + } + + fn parse_additive(&mut self) -> Result { + let mut left = self.parse_multiplicative()?; + + loop { + self.skip_whitespace(); + if self.pos >= self.input.len() { + break; + } + + let c = self.current_char().unwrap(); + if c == '+' || c == '-' { + // Don't consume if it's a compound assignment operator (+=, -=) + let next = self.input[self.pos..].chars().nth(1); + if next == Some('=') { + break; + } + self.pos += 1; + self.skip_whitespace(); + let right = self.parse_multiplicative()?; + left = AwkExpr::BinOp(Box::new(left), c.to_string(), Box::new(right)); + } else { + break; + } + } + + Ok(left) + } + + fn parse_multiplicative(&mut self) -> Result { + let mut left = self.parse_power()?; + + loop { + self.skip_whitespace(); + if self.pos >= self.input.len() { + break; + } + + let c = self.current_char().unwrap(); + if c == '*' || c == '/' || c == '%' { + // Don't consume ** (power operator) + if c == '*' && self.input[self.pos..].chars().nth(1) == Some('*') { + break; + } + // Don't consume if it's a compound assignment operator (*=, /=, %=) + let next = self.input[self.pos..].chars().nth(1); + if next == Some('=') { + break; + } + self.pos += 1; + self.skip_whitespace(); + let right = self.parse_power()?; + left = AwkExpr::BinOp(Box::new(left), c.to_string(), Box::new(right)); + } else { + break; + } + } + + Ok(left) + } + + fn parse_power(&mut self) -> Result { + let base = self.parse_unary()?; + + self.skip_whitespace(); + if self.pos >= self.input.len() { + return Ok(base); + } + + // Check for ^ or ** + if self.current_char().unwrap() == '^' { + self.pos += 1; + self.skip_whitespace(); + let exp = self.parse_unary()?; + return Ok(AwkExpr::BinOp( + Box::new(base), + "^".to_string(), + Box::new(exp), + )); + } + if self.input[self.pos..].starts_with("**") { + self.pos += 2; + self.skip_whitespace(); + let exp = self.parse_unary()?; + return Ok(AwkExpr::BinOp( + Box::new(base), + "^".to_string(), + Box::new(exp), + )); + } + + Ok(base) + } + + /// THREAT[TM-DOS-027]: Track depth on unary self-recursion + fn parse_unary(&mut self) -> Result { + self.skip_whitespace(); + + if self.pos >= self.input.len() { + return Err(Error::Execution( + "awk: unexpected end of expression".to_string(), + )); + } + + // Pre-increment: ++var or ++arr[key] + if self.input[self.pos..].starts_with("++") { + self.pos += 2; + self.skip_whitespace(); + match self.parse_primary()? { + AwkExpr::Variable(name) => return Ok(AwkExpr::PreIncrement(name)), + AwkExpr::FuncCall(ref fname, ref args) + if fname == "__array_access" && args.len() == 2 => + { + if let AwkExpr::Variable(arr_name) = &args[0] { + return Ok(AwkExpr::CompoundArrayAssign( + arr_name.clone(), + Box::new(args[1].clone()), + "+".to_string(), + Box::new(AwkExpr::Number(1.0)), + )); + } + return Err(Error::Execution( + "awk: expected variable after ++".to_string(), + )); + } + _ => { + return Err(Error::Execution( + "awk: expected variable after ++".to_string(), + )); + } + } + } + + // Pre-decrement: --var or --arr[key] + if self.input[self.pos..].starts_with("--") { + self.pos += 2; + self.skip_whitespace(); + match self.parse_primary()? { + AwkExpr::Variable(name) => return Ok(AwkExpr::PreDecrement(name)), + AwkExpr::FuncCall(ref fname, ref args) + if fname == "__array_access" && args.len() == 2 => + { + if let AwkExpr::Variable(arr_name) = &args[0] { + return Ok(AwkExpr::CompoundArrayAssign( + arr_name.clone(), + Box::new(args[1].clone()), + "-".to_string(), + Box::new(AwkExpr::Number(1.0)), + )); + } + return Err(Error::Execution( + "awk: expected variable after --".to_string(), + )); + } + _ => { + return Err(Error::Execution( + "awk: expected variable after --".to_string(), + )); + } + } + } + + let c = self.current_char().unwrap(); + + if c == '-' { + self.pos += 1; + self.push_depth()?; + let expr = self.parse_unary(); + self.pop_depth(); + return Ok(AwkExpr::UnaryOp("-".to_string(), Box::new(expr?))); + } + + if c == '!' { + self.pos += 1; + self.push_depth()?; + let expr = self.parse_unary(); + self.pop_depth(); + return Ok(AwkExpr::UnaryOp("!".to_string(), Box::new(expr?))); + } + + if c == '+' { + self.pos += 1; + self.push_depth()?; + let result = self.parse_unary(); + self.pop_depth(); + return result; + } + + self.parse_postfix() + } + + fn parse_postfix(&mut self) -> Result { + let expr = self.parse_primary()?; + + // Check for postfix ++ / -- + if self.pos + 1 < self.input.len() { + if self.input[self.pos..].starts_with("++") { + match &expr { + AwkExpr::Variable(name) => { + self.pos += 2; + return Ok(AwkExpr::PostIncrement(name.clone())); + } + AwkExpr::FuncCall(fname, args) + if fname == "__array_access" && args.len() == 2 => + { + // arr[key]++ → compound array assign with +1 + if let AwkExpr::Variable(arr_name) = &args[0] { + self.pos += 2; + return Ok(AwkExpr::CompoundArrayAssign( + arr_name.clone(), + Box::new(args[1].clone()), + "+".to_string(), + Box::new(AwkExpr::Number(1.0)), + )); + } + } + _ => {} + } + } + if self.input[self.pos..].starts_with("--") { + match &expr { + AwkExpr::Variable(name) => { + self.pos += 2; + return Ok(AwkExpr::PostDecrement(name.clone())); + } + AwkExpr::FuncCall(fname, args) + if fname == "__array_access" && args.len() == 2 => + { + if let AwkExpr::Variable(arr_name) = &args[0] { + self.pos += 2; + return Ok(AwkExpr::CompoundArrayAssign( + arr_name.clone(), + Box::new(args[1].clone()), + "-".to_string(), + Box::new(AwkExpr::Number(1.0)), + )); + } + } + _ => {} + } + } + } + + Ok(expr) + } + + fn parse_primary(&mut self) -> Result { + self.skip_whitespace(); + + if self.pos >= self.input.len() { + return Err(Error::Execution( + "awk: unexpected end of expression".to_string(), + )); + } + + let c = self.current_char().unwrap(); + + // Field reference $ + if c == '$' { + self.pos += 1; + self.push_depth()?; + let index = self.parse_primary()?; + self.pop_depth(); + return Ok(AwkExpr::Field(Box::new(index))); + } + + // Number + if c.is_ascii_digit() || c == '.' { + return self.parse_number(); + } + + // String + if c == '"' { + let s = self.parse_string()?; + return Ok(AwkExpr::String(s)); + } + + // Regex literal /pattern/ + if c == '/' { + self.pos += 1; + let start = self.pos; + while self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if c == '/' { + let pattern = &self.input[start..self.pos]; + self.pos += 1; + return Ok(AwkExpr::Regex(pattern.to_string())); + } else if c == '\\' { + self.pos += 1; // skip '\\' (ASCII) + self.advance(); // skip next char (may be multi-byte) + } else { + self.advance(); // regex content may be multi-byte + } + } + return Err(Error::Execution("awk: unterminated regex".to_string())); + } + + // Parenthesized expression: `>` inside parens is comparison, not redirection + if c == '(' { + self.pos += 1; + self.push_depth()?; + let saved_print_ctx = self.in_print_context; + self.in_print_context = false; + let expr = self.parse_expression()?; + self.in_print_context = saved_print_ctx; + self.pop_depth(); + self.skip_whitespace(); + if self.pos >= self.input.len() || self.current_char().unwrap() != ')' { + return Err(Error::Execution("awk: expected ')'".to_string())); + } + self.pos += 1; + return Ok(expr); + } + + // Variable or function call + if Self::is_identifier_start(c) { + let start = self.pos; + self.advance(); + self.consume_while(Self::is_identifier_continue); + let name = self.input[start..self.pos].to_string(); + + // getline [var] [< file] as expression (returns 1/0/-1) + if name == "getline" { + return self.parse_getline_expr(); + } + + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == '(' { + // Function call + self.pos += 1; + let mut args = Vec::new(); + loop { + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == ')' { + self.pos += 1; + break; + } + let arg = self.parse_expression()?; + args.push(arg); + self.skip_whitespace(); + if self.pos < self.input.len() && self.current_char().unwrap() == ',' { + self.pos += 1; + } + } + return Ok(AwkExpr::FuncCall(name, args)); + } + + // Array indexing: arr[index] or arr[e1,e2,...] (multi-subscript with SUBSEP) + if self.pos < self.input.len() && self.current_char().unwrap() == '[' { + self.pos += 1; // consume '[' + let mut subscripts = vec![self.parse_expression()?]; + self.skip_whitespace(); + // Handle multi-subscript: arr[e1, e2, ...] joined by SUBSEP + while self.pos < self.input.len() && self.current_char().unwrap() == ',' { + self.pos += 1; // consume ',' + self.skip_whitespace(); + subscripts.push(self.parse_expression()?); + self.skip_whitespace(); + } + if self.pos >= self.input.len() || self.current_char().unwrap() != ']' { + return Err(Error::Execution("awk: expected ']'".to_string())); + } + self.pos += 1; // consume ']' + let index_expr = if subscripts.len() == 1 { + subscripts.remove(0) + } else { + // Join multiple subscripts with SUBSEP + let mut result = subscripts.remove(0); + for sub in subscripts { + result = AwkExpr::BinOp( + Box::new(result), + "SUBSEP_CONCAT".to_string(), + Box::new(sub), + ); + } + result + }; + return Ok(AwkExpr::FuncCall( + "__array_access".to_string(), + vec![AwkExpr::Variable(name), index_expr], + )); + } + + return Ok(AwkExpr::Variable(name)); + } + + Err(Error::Execution(format!( + "awk: unexpected character: {}", + c + ))) + } + + fn parse_number(&mut self) -> Result { + let start = self.pos; + while self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '-' || c == '+' { + self.pos += 1; + } else { + break; + } + } + + let num_str = &self.input[start..self.pos]; + let num: f64 = num_str + .parse() + .map_err(|_| Error::Execution(format!("awk: invalid number: {}", num_str)))?; + + Ok(AwkExpr::Number(num)) + } + + fn parse_string(&mut self) -> Result { + if self.pos >= self.input.len() || self.current_char().unwrap() != '"' { + return Err(Error::Execution("awk: expected string".to_string())); + } + self.pos += 1; // skip opening '"' (ASCII) + + let mut result = String::new(); + while self.pos < self.input.len() { + let c = self.current_char().unwrap(); + if c == '"' { + self.pos += 1; // skip closing '"' (ASCII) + return Ok(result); + } else if c == '\\' { + self.pos += 1; // skip '\\' (ASCII) + if self.pos < self.input.len() { + let escaped = self.current_char().unwrap(); + match escaped { + 'n' => { + result.push('\n'); + self.advance(); + } + 't' => { + result.push('\t'); + self.advance(); + } + 'r' => { + result.push('\r'); + self.advance(); + } + '\\' => { + result.push('\\'); + self.advance(); + } + '"' => { + result.push('"'); + self.advance(); + } + 'u' => { + // gawk 5.3+ Unicode escape: \u followed by 1-8 hex digits + self.advance(); // skip 'u' + let mut hex = String::new(); + while hex.len() < 8 + && self.pos < self.input.len() + && self.current_char().is_some_and(|c| c.is_ascii_hexdigit()) + { + hex.push(self.current_char().unwrap()); + self.pos += 1; // hex digits are ASCII + } + if hex.is_empty() { + result.push('\\'); + result.push('u'); + } else if let Ok(cp) = u32::from_str_radix(&hex, 16) { + if let Some(ch) = char::from_u32(cp) { + result.push(ch); + } else { + // Invalid Unicode code point — keep literal + result.push('\\'); + result.push('u'); + result.push_str(&hex); + } + } else { + result.push('\\'); + result.push('u'); + result.push_str(&hex); + } + } + _ => { + result.push('\\'); + result.push(escaped); + self.advance(); + } + } + } + } else { + result.push(c); + self.advance(); // character may be multi-byte + } + } + + Err(Error::Execution("awk: unterminated string".to_string())) + } +} diff --git a/crates/bashkit/src/builtins/awk/tests.rs b/crates/bashkit/src/builtins/awk/tests.rs new file mode 100644 index 000000000..d05907c2c --- /dev/null +++ b/crates/bashkit/src/builtins/awk/tests.rs @@ -0,0 +1,1109 @@ +//! Tests for awk: parser limits, interpreter limits, end-to-end runs. + +use super::parser::AwkParser; +use super::{Awk, csv_split_fields}; +use crate::builtins::limits::AWK_MAX_GETLINE_CACHED_FILES as MAX_GETLINE_CACHED_FILES; +use crate::builtins::{Builtin, Context}; +use crate::error::Result; +use crate::interpreter::ExecResult; + +use crate::fs::{FileSystem, InMemoryFs}; +use std::collections::HashMap; +use std::path::PathBuf; +use std::sync::Arc; + +async fn run_awk(args: &[&str], stdin: Option<&str>) -> Result { + let awk = Awk; + let fs = Arc::new(InMemoryFs::new()); + let mut vars = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + + let ctx = Context { + args: &args, + env: &HashMap::new(), + variables: &mut vars, + cwd: &mut cwd, + fs, + stdin, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + awk.execute(ctx).await +} + +#[tokio::test] +async fn test_awk_print_all() { + let result = run_awk(&["{print}"], Some("hello\nworld")).await.unwrap(); + assert_eq!(result.stdout, "hello\nworld\n"); +} + +#[tokio::test] +async fn test_awk_print_field() { + let result = run_awk(&["{print $1}"], Some("hello world\nfoo bar")) + .await + .unwrap(); + assert_eq!(result.stdout, "hello\nfoo\n"); +} + +#[tokio::test] +async fn test_awk_print_multiple_fields() { + let result = run_awk(&["{print $2, $1}"], Some("hello world")) + .await + .unwrap(); + assert_eq!(result.stdout, "world hello\n"); +} + +#[tokio::test] +async fn test_awk_field_separator() { + let result = run_awk(&["-F:", "{print $1}"], Some("root:x:0:0")) + .await + .unwrap(); + assert_eq!(result.stdout, "root\n"); +} + +#[tokio::test] +async fn test_awk_nr() { + let result = run_awk(&["{print NR, $0}"], Some("a\nb\nc")).await.unwrap(); + assert_eq!(result.stdout, "1 a\n2 b\n3 c\n"); +} + +#[tokio::test] +async fn test_awk_nf() { + let result = run_awk(&["{print NF}"], Some("a b c\nd e")).await.unwrap(); + assert_eq!(result.stdout, "3\n2\n"); +} + +#[tokio::test] +async fn test_awk_begin_end() { + let result = run_awk( + &["BEGIN{print \"start\"} {print} END{print \"end\"}"], + Some("middle"), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "start\nmiddle\nend\n"); +} + +#[tokio::test] +async fn test_awk_pattern() { + let result = run_awk(&["/hello/{print}"], Some("hello\nworld\nhello again")) + .await + .unwrap(); + assert_eq!(result.stdout, "hello\nhello again\n"); +} + +#[tokio::test] +async fn test_awk_condition() { + let result = run_awk(&["NR==2{print}"], Some("line1\nline2\nline3")) + .await + .unwrap(); + assert_eq!(result.stdout, "line2\n"); +} + +#[tokio::test] +async fn test_awk_arithmetic() { + let result = run_awk(&["{print $1 + $2}"], Some("1 2\n3 4")) + .await + .unwrap(); + assert_eq!(result.stdout, "3\n7\n"); +} + +#[tokio::test] +async fn test_awk_variables() { + let result = run_awk(&["{sum += $1} END{print sum}"], Some("1\n2\n3\n4")) + .await + .unwrap(); + assert_eq!(result.stdout, "10\n"); +} + +#[tokio::test] +async fn test_awk_unicode_identifier_no_panic() { + let result = run_awk(&["BEGIN{café=7; print café}"], None).await.unwrap(); + assert_eq!(result.stdout, "7\n"); +} + +#[tokio::test] +async fn test_awk_length() { + let result = run_awk(&["{print length($0)}"], Some("hello\nhi")) + .await + .unwrap(); + assert_eq!(result.stdout, "5\n2\n"); +} + +#[tokio::test] +async fn test_awk_substr() { + let result = run_awk(&["{print substr($0, 2, 3)}"], Some("hello")) + .await + .unwrap(); + assert_eq!(result.stdout, "ell\n"); +} + +#[tokio::test] +async fn test_awk_toupper() { + let result = run_awk(&["{print toupper($0)}"], Some("hello")) + .await + .unwrap(); + assert_eq!(result.stdout, "HELLO\n"); +} + +#[tokio::test] +async fn test_awk_multi_statement() { + // Test multiple statements separated by semicolon + let result = run_awk(&["{x=1; print x}"], Some("test")).await.unwrap(); + assert_eq!(result.stdout, "1\n"); +} + +#[tokio::test] +async fn test_awk_gsub_with_print() { + // gsub with regex literal followed by print + let result = run_awk( + &[r#"{gsub(/hello/, "hi"); print}"#], + Some("hello hello hello"), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "hi hi hi\n"); +} + +#[tokio::test] +async fn test_awk_split_with_array_access() { + // split with array indexing + let result = run_awk( + &[r#"{n = split($0, arr, ":"); print arr[2]}"#], + Some("a:b:c"), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "b\n"); +} + +/// TM-DOS-027: Deeply nested parenthesized expressions must be rejected +#[test] +fn test_awk_parser_depth_limit_parens() { + // Build expression with 150 nested parens: (((((...(1)...)))) + let depth = 150; + let open = "(".repeat(depth); + let close = ")".repeat(depth); + let program = format!("{{print {open}1{close}}}"); + + let mut parser = AwkParser::new(&program); + let result = parser.parse(); + assert!(result.is_err(), "deeply nested parens must be rejected"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("nesting too deep"), + "error should mention nesting: {err}" + ); +} + +/// TM-DOS-027: Deeply chained unary operators must be rejected +#[test] +fn test_awk_parser_depth_limit_unary() { + // Build expression with 200 chained negations: - - - ... - 1 + let depth = 200; + let prefix = "- ".repeat(depth); + let program = format!("{{print {prefix}1}}"); + + let mut parser = AwkParser::new(&program); + let result = parser.parse(); + assert!(result.is_err(), "deeply chained unary ops must be rejected"); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("nesting too deep"), + "error should mention nesting: {err}" + ); +} + +/// TM-DOS-027: Moderate nesting within limit still works +#[test] +fn test_awk_parser_moderate_nesting_ok() { + // 10 levels of parens should be fine + let depth = 10; + let open = "(".repeat(depth); + let close = ")".repeat(depth); + let program = format!("{{print {open}1{close}}}"); + + let mut parser = AwkParser::new(&program); + let result = parser.parse(); + assert!( + result.is_ok(), + "moderate nesting should succeed: {:?}", // debug-ok: assert-failure message + result.err() + ); +} + +// === New tests for added features === + +#[tokio::test] +async fn test_awk_for_c_style() { + let result = run_awk(&["BEGIN{for(i=1;i<=5;i++) print i}"], None) + .await + .unwrap(); + assert_eq!(result.stdout, "1\n2\n3\n4\n5\n"); +} + +#[tokio::test] +async fn test_awk_for_with_body_block() { + let result = run_awk(&["BEGIN{for(i=0;i<3;i++){print i}}"], None) + .await + .unwrap(); + assert_eq!(result.stdout, "0\n1\n2\n"); +} + +#[tokio::test] +async fn test_awk_while_loop() { + let result = run_awk(&["BEGIN{i=1; while(i<=3){print i; i++}}"], None) + .await + .unwrap(); + assert_eq!(result.stdout, "1\n2\n3\n"); +} + +#[tokio::test] +async fn test_awk_do_while() { + let result = run_awk(&["BEGIN{i=1; do{print i; i++}while(i<=3)}"], None) + .await + .unwrap(); + assert_eq!(result.stdout, "1\n2\n3\n"); +} + +#[tokio::test] +async fn test_awk_post_increment() { + let result = run_awk(&["{print i++}"], Some("a\nb\nc")).await.unwrap(); + assert_eq!(result.stdout, "0\n1\n2\n"); +} + +#[tokio::test] +async fn test_awk_pre_increment() { + let result = run_awk(&["{print ++i}"], Some("a\nb\nc")).await.unwrap(); + assert_eq!(result.stdout, "1\n2\n3\n"); +} + +#[tokio::test] +async fn test_awk_post_decrement() { + let result = run_awk(&["BEGIN{x=3; print x--; print x}"], None) + .await + .unwrap(); + assert_eq!(result.stdout, "3\n2\n"); +} + +#[tokio::test] +async fn test_awk_array_assign() { + let result = run_awk(&[r#"BEGIN{a[1]="x"; a[2]="y"; print a[1], a[2]}"#], None) + .await + .unwrap(); + assert_eq!(result.stdout, "x y\n"); +} + +#[tokio::test] +async fn test_awk_array_in_operator() { + let result = run_awk( + &[r#"BEGIN{a["foo"]=1; if("foo" in a) print "yes"; if("bar" in a) print "no"}"#], + None, + ) + .await + .unwrap(); + assert_eq!(result.stdout, "yes\n"); +} + +#[tokio::test] +async fn test_awk_for_in_loop() { + let result = run_awk( + &[r#"BEGIN{a[1]="x"; a[2]="y"; for(k in a) count++; print count}"#], + None, + ) + .await + .unwrap(); + assert_eq!(result.stdout, "2\n"); +} + +#[tokio::test] +async fn test_awk_delete_array_element() { + let result = run_awk( + &[r#"BEGIN{a[1]=1; a[2]=2; delete a[1]; for(k in a) print k, a[k]}"#], + None, + ) + .await + .unwrap(); + assert_eq!(result.stdout, "2 2\n"); +} + +#[tokio::test] +async fn test_awk_v_flag() { + let result = run_awk(&["-v", "x=hello", "BEGIN{print x}"], None) + .await + .unwrap(); + assert_eq!(result.stdout, "hello\n"); +} + +#[tokio::test] +async fn test_awk_v_flag_numeric() { + let result = run_awk(&["-v", "n=42", "BEGIN{print n+1}"], None) + .await + .unwrap(); + assert_eq!(result.stdout, "43\n"); +} + +#[tokio::test] +async fn test_awk_break_in_for() { + let result = run_awk(&["BEGIN{for(i=1;i<=10;i++){if(i>3) break; print i}}"], None) + .await + .unwrap(); + assert_eq!(result.stdout, "1\n2\n3\n"); +} + +#[tokio::test] +async fn test_awk_continue_in_for() { + let result = run_awk( + &["BEGIN{for(i=1;i<=5;i++){if(i==3) continue; print i}}"], + None, + ) + .await + .unwrap(); + assert_eq!(result.stdout, "1\n2\n4\n5\n"); +} + +#[tokio::test] +async fn test_awk_ternary() { + let result = run_awk(&[r#"{print ($1>2 ? "big" : "small")}"#], Some("1\n3\n2")) + .await + .unwrap(); + assert_eq!(result.stdout, "small\nbig\nsmall\n"); +} + +#[tokio::test] +async fn test_awk_field_assignment() { + let result = run_awk(&[r#"{$2="new"; print}"#], Some("one two three")) + .await + .unwrap(); + assert_eq!(result.stdout, "one new three\n"); +} + +#[tokio::test] +async fn test_awk_csv_to_json_pattern() { + // This is the pattern LLMs use for CSV→JSON conversion + let result = run_awk( + &[ + "-F,", + r#"NR==1{for(i=1;i<=NF;i++) h[i]=$i; next} {for(i=1;i<=NF;i++) printf "%s=%s ", h[i], $i; print ""}"#, + ], + Some("name,age\nalice,30\nbob,25"), + ) + .await + .unwrap(); + assert!(result.stdout.contains("name=alice")); + assert!(result.stdout.contains("age=30")); + assert!(result.stdout.contains("name=bob")); +} + +#[tokio::test] +async fn test_awk_compound_array_assign() { + let result = run_awk( + &[r#"{count[$1]++} END{for(k in count) print k, count[k]}"#], + Some("a\nb\na\nc\nb\na"), + ) + .await + .unwrap(); + // Order may vary, so check contents + assert!(result.stdout.contains("a 3")); + assert!(result.stdout.contains("b 2")); + assert!(result.stdout.contains("c 1")); +} + +#[tokio::test] +async fn test_awk_next_statement() { + let result = run_awk(&["NR==2{next} {print}"], Some("line1\nline2\nline3")) + .await + .unwrap(); + assert_eq!(result.stdout, "line1\nline3\n"); +} + +#[tokio::test] +async fn test_awk_exit_statement() { + let result = run_awk(&["NR==2{exit} {print}"], Some("line1\nline2\nline3")) + .await + .unwrap(); + assert_eq!(result.stdout, "line1\n"); +} + +#[tokio::test] +async fn test_awk_getline_basic() { + let result = run_awk(&["{getline; print}"], Some("line1\nline2")) + .await + .unwrap(); + assert_eq!(result.stdout, "line2\n"); +} + +#[tokio::test] +async fn test_awk_getline_updates_fields() { + let result = run_awk(&["{getline; print $1}"], Some("a b\nc d")) + .await + .unwrap(); + assert_eq!(result.stdout, "c\n"); +} + +#[tokio::test] +async fn test_awk_getline_at_eof() { + // getline at EOF should keep current $0 + let result = run_awk(&["{getline; print}"], Some("only")).await.unwrap(); + assert_eq!(result.stdout, "only\n"); +} + +#[tokio::test] +async fn test_awk_revenue_calculation() { + // This is the exact eval task pattern + let result = run_awk( + &["-F,", "NR>1{total+=$2*$3} END{print total}"], + Some("product,price,quantity\nwidget,10,5\ngadget,25,3\ndoohickey,7,12\nsprocket,15,8"), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "329\n"); +} + +#[tokio::test] +async fn test_awk_printf_parens() { + // printf with parenthesized syntax: printf("format", args) + let result = run_awk( + &[r#"BEGIN{printf("["); printf("%s", "x"); printf("]"); print ""}"#], + Some(""), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "[x]\n"); +} + +#[tokio::test] +async fn test_awk_printf_parens_csv() { + // CSV to JSON pattern using printf with parens + let result = run_awk( + &[ + "-F,", + r#"NR==1{for(i=1;i<=NF;i++) h[i]=$i; next} {printf("%s{", (NR>2?",":"")); for(i=1;i<=NF;i++){printf("%s\"%s\":\"%s\"", (i>1?",":""), h[i], $i)}; printf("}")} END{print ""}"#, + ], + Some("name,age\nalice,30\nbob,25\n"), + ) + .await + .unwrap(); + assert!(result.stdout.contains("alice")); + assert!(result.stdout.contains("bob")); +} + +#[tokio::test] +async fn test_awk_recursive_function_depth_limit() { + // Recursive function should be limited, not stack overflow + let result = run_awk( + &[r#"function r(n) { return r(n+1) } BEGIN { print r(0) }"#], + Some(""), + ) + .await + .unwrap(); + // Should complete without crashing (returns Uninitialized -> empty string) + assert_eq!(result.exit_code, 0); +} + +#[tokio::test] +async fn test_awk_while_loop_limited() { + // Infinite while loop should terminate via default max_loop_iterations + let result = run_awk( + &[r#"BEGIN { i=0; while(1) { i++; if(i>200000) break } print i }"#], + Some(""), + ) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + let count: usize = result.stdout.trim().parse().unwrap(); + // Should be capped at default max_loop_iterations (10_000), not 200_000 + assert!( + count <= 10_001, + "loop ran {} times, expected <= 10001", + count + ); +} + +#[tokio::test] +async fn test_awk_unicode_in_comment() { + // Issue #395: multi-byte Unicode chars in comments should not panic + let result = run_awk(&["# ── header ──────\n{ print $1 }"], Some("hello world\n")) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.trim(), "hello"); +} + +#[tokio::test] +async fn test_awk_unicode_in_string() { + // Multi-byte chars in string literals should not panic + let result = run_awk(&[r#"BEGIN { print "café" }"#], Some("")) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.trim(), "café"); +} + +#[tokio::test] +async fn test_awk_array_assign_field_ref_subscript() { + // Issue #396.1: arr[$1] = $3 should work with field refs as subscripts + let result = run_awk( + &["{ arr[$1] = $2 } END { print arr[\"hello\"] }"], + Some("hello world\n"), + ) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.trim(), "world"); +} + +#[tokio::test] +async fn test_awk_multi_subscript() { + // Issue #396.2: a["x","y"] multi-subscript with SUBSEP + let result = run_awk(&[r#"BEGIN { a["x","y"] = 1; print a["x","y"] }"#], Some("")) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.trim(), "1"); +} + +#[tokio::test] +async fn test_awk_subsep_defined() { + // Issue #396.3: SUBSEP should be defined as \034 + let result = run_awk(&[r#"BEGIN { print length(SUBSEP) }"#], Some("")) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout.trim(), "1"); +} + +#[tokio::test] +async fn test_awk_preincrement_array() { + // Issue #396.4: ++arr[key] should work + let result = run_awk( + &["{ ++count[$1] } END { for (k in count) print k, count[k] }"], + Some("a\nb\na\n"), + ) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("a 2")); + assert!(result.stdout.contains("b 1")); +} + +/// Helper that returns the VFS alongside the result for testing file output. +async fn run_awk_with_fs( + args: &[&str], + stdin: Option<&str>, +) -> (Result, Arc) { + let awk = Awk; + let fs = Arc::new(InMemoryFs::new()); + let mut vars = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + + let ctx = Context { + args: &args, + env: &HashMap::new(), + variables: &mut vars, + cwd: &mut cwd, + fs: fs.clone(), + stdin, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = awk.execute(ctx).await; + (result, fs) +} + +#[tokio::test] +async fn test_awk_print_redirect_truncate() { + // Issue #607: print ... > file should create file with content + let (result, fs) = run_awk_with_fs(&[r#"BEGIN{print "hello" > "/tmp/out"}"#], None).await; + let result = result.unwrap(); + assert_eq!(result.exit_code, 0); + // stdout should be empty (output went to file) + assert_eq!(result.stdout, ""); + let content = fs + .read_file(std::path::Path::new("/tmp/out")) + .await + .unwrap(); + assert_eq!(String::from_utf8_lossy(&content), "hello\n"); +} + +#[tokio::test] +async fn test_awk_printf_redirect_truncate() { + // Issue #607: printf ... > file should create file with content + let (result, fs) = run_awk_with_fs(&[r#"BEGIN{printf "hello" > "/tmp/out"}"#], None).await; + let result = result.unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, ""); + let content = fs + .read_file(std::path::Path::new("/tmp/out")) + .await + .unwrap(); + assert_eq!(String::from_utf8_lossy(&content), "hello"); +} + +#[tokio::test] +async fn test_awk_print_redirect_append() { + // Issue #607: print ... >> file should append + let (result, fs) = run_awk_with_fs( + &[r#"BEGIN{print "a" > "/tmp/out"; print "b" >> "/tmp/out"}"#], + None, + ) + .await; + let result = result.unwrap(); + assert_eq!(result.exit_code, 0); + let content = fs + .read_file(std::path::Path::new("/tmp/out")) + .await + .unwrap(); + assert_eq!(String::from_utf8_lossy(&content), "a\nb\n"); +} + +#[tokio::test] +async fn test_awk_print_redirect_multiple_to_same_file() { + // Multiple prints to same file with > should accumulate (AWK keeps file open) + let (result, fs) = run_awk_with_fs( + &[r#"BEGIN{print "line1" > "/tmp/out"; print "line2" > "/tmp/out"}"#], + None, + ) + .await; + let result = result.unwrap(); + assert_eq!(result.exit_code, 0); + let content = fs + .read_file(std::path::Path::new("/tmp/out")) + .await + .unwrap(); + assert_eq!(String::from_utf8_lossy(&content), "line1\nline2\n"); +} + +#[tokio::test] +async fn test_awk_print_redirect_pipe_unsupported() { + // Pipe output should return clear error + let (result, _fs) = run_awk_with_fs(&[r#"BEGIN{print "hello" | "cat"}"#], None).await; + assert!(result.is_err()); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("pipe"), + "expected pipe error, got: {err_msg}" + ); +} + +// ======================================================================== +// --csv flag (issues #617, #618) +// ======================================================================== + +#[tokio::test] +async fn test_awk_csv_basic() { + let result = run_awk(&["--csv", "{print $2}"], Some("a,b,c\nd,e,f")) + .await + .unwrap(); + assert_eq!(result.stdout, "b\ne\n"); +} + +#[tokio::test] +async fn test_awk_csv_quoted_fields() { + // Quoted field with embedded comma + let result = run_awk(&["--csv", "{print $2}"], Some(r#"1,"hello, world",3"#)) + .await + .unwrap(); + assert_eq!(result.stdout, "hello, world\n"); +} + +#[tokio::test] +async fn test_awk_csv_escaped_quotes() { + // Double-quote escaping per RFC 4180 + let result = run_awk(&["--csv", "{print $2}"], Some(r#"1,"she said ""hi""",3"#)) + .await + .unwrap(); + assert_eq!(result.stdout, "she said \"hi\"\n"); +} + +#[tokio::test] +async fn test_awk_csv_nf() { + let result = run_awk(&["--csv", "{print NF}"], Some("a,b,c")) + .await + .unwrap(); + assert_eq!(result.stdout, "3\n"); +} + +#[tokio::test] +async fn test_awk_csv_ofs() { + // In CSV mode, OFS defaults to comma + let result = run_awk(&["--csv", "{print $1, $3}"], Some("a,b,c")) + .await + .unwrap(); + assert_eq!(result.stdout, "a,c\n"); +} + +#[tokio::test] +async fn test_awk_csv_empty_fields() { + let result = run_awk(&["--csv", "{print NF}"], Some("a,,c,")) + .await + .unwrap(); + assert_eq!(result.stdout, "4\n"); +} + +#[tokio::test] +async fn test_awk_csv_k_flag_alias() { + // -k is an alias for --csv + let result = run_awk(&["-k", "{print $2}"], Some("a,b,c")).await.unwrap(); + assert_eq!(result.stdout, "b\n"); +} + +#[test] +fn test_csv_split_fields_unit() { + assert_eq!(csv_split_fields("a,b,c"), vec!["a", "b", "c"]); + assert_eq!( + csv_split_fields(r#"1,"hello, world",3"#), + vec!["1", "hello, world", "3"] + ); + assert_eq!(csv_split_fields(r#""a""b",c"#), vec!["a\"b", "c"]); + assert_eq!(csv_split_fields("a,,c,"), vec!["a", "", "c", ""]); +} + +// ======================================================================== +// gawk 5.3+ Unicode escape sequences (issue #617) +// ======================================================================== + +#[tokio::test] +async fn test_awk_unicode_escape_basic() { + // \u followed by hex digits → Unicode character + let result = run_awk(&[r#"BEGIN{print "\u0041"}"#], None).await.unwrap(); + assert_eq!(result.stdout, "A\n"); +} + +#[tokio::test] +async fn test_awk_unicode_escape_multibyte() { + // \u00E9 → é (Latin small e with acute) + let result = run_awk(&[r#"BEGIN{print "\u00E9"}"#], None).await.unwrap(); + assert_eq!(result.stdout, "é\n"); +} + +#[tokio::test] +async fn test_awk_unicode_escape_emoji() { + // \u1F600 → 😀 (grinning face, 5 hex digits) + let result = run_awk(&[r#"BEGIN{print "\u1F600"}"#], None).await.unwrap(); + assert_eq!(result.stdout, "😀\n"); +} + +#[tokio::test] +async fn test_awk_unicode_escape_bare_u() { + // \u with no hex digits → literal \u + let result = run_awk(&[r#"BEGIN{print "\u "}"#], None).await.unwrap(); + assert_eq!(result.stdout, "\\u \n"); +} + +#[tokio::test] +async fn test_awk_unicode_escape_mixed() { + // Mix of Unicode escapes and regular text + let result = run_awk(&[r#"BEGIN{print "caf\u00E9"}"#], None) + .await + .unwrap(); + assert_eq!(result.stdout, "café\n"); +} + +// ======================================================================== +// getline from file (issue #796) +// ======================================================================== + +/// Helper: create an in-memory FS with a file, then run awk. +async fn run_awk_with_file( + args: &[&str], + stdin: Option<&str>, + file_path: &str, + file_content: &str, +) -> Result { + let awk = Awk; + let fs = Arc::new(InMemoryFs::new()); + fs.write_file(std::path::Path::new(file_path), file_content.as_bytes()) + .await + .unwrap(); + let mut vars = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + + let ctx = Context { + args: &args, + env: &HashMap::new(), + variables: &mut vars, + cwd: &mut cwd, + fs, + stdin, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + awk.execute(ctx).await +} + +#[tokio::test] +async fn test_awk_getline_file_into_var() { + // getline var < "file" reads lines one at a time + let result = run_awk_with_file( + &[r#"BEGIN{while((getline line < "/tmp/data.txt") > 0) print line}"#], + None, + "/tmp/data.txt", + "alpha\nbeta\ngamma", + ) + .await + .unwrap(); + assert_eq!(result.stdout, "alpha\nbeta\ngamma\n"); +} + +#[tokio::test] +async fn test_awk_getline_file_no_var() { + // getline < "file" without variable updates $0 + let result = run_awk_with_file( + &[r#"BEGIN{getline < "/tmp/data.txt"; print}"#], + None, + "/tmp/data.txt", + "first\nsecond", + ) + .await + .unwrap(); + assert_eq!(result.stdout, "first\n"); +} + +#[tokio::test] +async fn test_awk_getline_file_eof() { + // getline at EOF keeps variable unchanged + let result = run_awk_with_file( + &[r#"BEGIN{getline x < "/tmp/f.txt"; getline x < "/tmp/f.txt"; print x}"#], + None, + "/tmp/f.txt", + "only", + ) + .await + .unwrap(); + assert_eq!(result.stdout, "only\n"); +} + +#[tokio::test] +async fn test_awk_getline_file_missing() { + // getline from non-existent file should not crash (returns -1 / empty) + let result = run_awk(&[r#"BEGIN{getline x < "/no/such/file"; print "ok"}"#], None) + .await + .unwrap(); + assert_eq!(result.stdout, "ok\n"); +} + +#[tokio::test] +async fn test_awk_output_limit_exceeded() { + // Each iteration prints a 1000-char line. 100k iters = ~100MB >> 10MB limit. + let result = run_awk( + &[r#"BEGIN { s = sprintf("%1000s", "x"); for(i=0;i<100000;i++) print s }"#], + None, + ) + .await + .unwrap(); + assert_eq!(result.exit_code, 2); + assert!( + result.stderr.contains("output limit exceeded"), + "stderr should mention output limit: {}", + result.stderr + ); + assert!( + result.stdout.len() <= 11_000_000, + "stdout should be bounded: {} bytes", + result.stdout.len() + ); +} + +#[tokio::test] +async fn test_awk_output_under_limit_ok() { + // Small output well under 10MB should succeed normally + let result = run_awk(&[r#"BEGIN { for(i=0;i<100;i++) print "hello" }"#], None) + .await + .unwrap(); + assert_eq!(result.exit_code, 0); + let lines: Vec<&str> = result.stdout.trim().split('\n').collect(); + assert_eq!(lines.len(), 100); +} + +#[tokio::test] +async fn test_awk_file_redirect_output_limit() { + // File redirect output should also be bounded + let result = run_awk( + &[r#"BEGIN { s = sprintf("%1000s", "x"); for(i=0;i<100000;i++) print s > "/tmp/out" }"#], + None, + ) + .await + .unwrap(); + assert_eq!(result.exit_code, 2); + assert!( + result.stderr.contains("output limit exceeded"), + "stderr should mention output limit: {}", + result.stderr + ); +} + +/// Helper: run AWK with a caller-provided VFS. +async fn run_awk_with_custom_fs( + args: &[&str], + stdin: Option<&str>, + fs: Arc, +) -> Result { + let awk = Awk; + let mut vars = HashMap::new(); + let mut cwd = PathBuf::from("/"); + let args: Vec = args.iter().map(|s| s.to_string()).collect(); + + let ctx = Context { + args: &args, + env: &HashMap::new(), + variables: &mut vars, + cwd: &mut cwd, + fs, + stdin, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + awk.execute(ctx).await +} + +#[tokio::test] +async fn test_awk_getline_file_cache_limit_exceeded() { + // Opening more than MAX_GETLINE_CACHED_FILES distinct files must fail + // gracefully (getline returns -1 for new files beyond the limit). + use crate::fs::FsLimits; + + let limits = FsLimits { + max_file_count: 200_000, + max_total_bytes: 200_000_000, + ..FsLimits::default() + }; + let fs = Arc::new(InMemoryFs::with_limits(limits)); + let count = MAX_GETLINE_CACHED_FILES + 5; + for i in 0..count { + fs.write_file( + std::path::Path::new(&format!("/tmp/f{i}.txt")), + format!("line{i}").as_bytes(), + ) + .await + .unwrap(); + } + + // AWK program: read one line from each file, count successes + let prog = format!( + r#"BEGIN{{ ok=0; for(i=0;i<{count};i++) {{ f="/tmp/f"i".txt"; if((getline x < f)>0) ok++ }} print ok }}"#, + ); + let result = run_awk_with_custom_fs(&[&prog], None, fs).await.unwrap(); + let ok: usize = result.stdout.trim().parse().unwrap(); + // Exactly MAX_GETLINE_CACHED_FILES should succeed, rest should fail + assert_eq!(ok, MAX_GETLINE_CACHED_FILES); +} + +#[tokio::test] +async fn test_awk_getline_file_cache_within_limit() { + // Opening a reasonable number of files should all succeed. + let fs = Arc::new(InMemoryFs::new()); + let count = 10; + for i in 0..count { + fs.write_file( + std::path::Path::new(&format!("/tmp/f{i}.txt")), + format!("data{i}").as_bytes(), + ) + .await + .unwrap(); + } + + let prog = format!( + r#"BEGIN{{ ok=0; for(i=0;i<{count};i++) {{ f="/tmp/f"i".txt"; if((getline x < f)>0) ok++ }} print ok }}"#, + ); + let result = run_awk_with_custom_fs(&[&prog], None, fs).await.unwrap(); + let ok: usize = result.stdout.trim().parse().unwrap(); + assert_eq!(ok, count); +} + +#[tokio::test] +async fn test_awk_getline_file_size_limit() { + // A file exceeding FsLimits::max_file_size is rejected by getline. + // Defense-in-depth: VFS also enforces limits, so a file at exactly + // the boundary is accepted while one over is rejected at VFS level. + use crate::fs::FsLimits; + + let limits = FsLimits { + max_file_size: 100, + ..FsLimits::unlimited() + }; + let fs = Arc::new(InMemoryFs::with_limits(limits)); + // Write a file within limits -- should be readable via getline. + fs.write_file(std::path::Path::new("/tmp/ok.txt"), &[b'a'; 100]) + .await + .unwrap(); + // Attempt to write an oversized file -- VFS rejects it, so getline + // returns -1 (file not found). + let _ = fs + .write_file(std::path::Path::new("/tmp/big.txt"), &[b'x'; 101]) + .await; + + // Within-limit file succeeds + let result = run_awk_with_custom_fs( + &[r#"BEGIN{r=(getline x < "/tmp/ok.txt"); print r}"#], + None, + fs.clone(), + ) + .await + .unwrap(); + assert_eq!(result.stdout, "1\n"); + + // Over-limit file fails (not stored by VFS) + let result = run_awk_with_custom_fs( + &[r#"BEGIN{r=(getline x < "/tmp/big.txt"); print r}"#], + None, + fs, + ) + .await + .unwrap(); + assert_eq!(result.stdout, "-1\n"); +} + +// TM-INF-022: malformed-input corpus must not leak Debug shapes. +const AWK_BANNED: &[&str] = &["AwkError::", "ParseError {", "Token::"]; + +#[tokio::test] +async fn no_leak_unbalanced_brace() { + let r = crate::builtins::debug_leak_check::run("echo 1 | awk 'BEGIN { print'").await; + crate::builtins::debug_leak_check::assert_no_leak(&r, "awk_unbalanced_brace", AWK_BANNED); +} + +#[tokio::test] +async fn no_leak_invalid_regex() { + let r = crate::builtins::debug_leak_check::run("echo 1 | awk '/[/'").await; + crate::builtins::debug_leak_check::assert_no_leak(&r, "awk_invalid_regex", AWK_BANNED); +} + +#[tokio::test] +async fn no_leak_undefined_function_call() { + let r = + crate::builtins::debug_leak_check::run("echo 1 | awk 'BEGIN { totally_undefined_fn() }'") + .await; + crate::builtins::debug_leak_check::assert_no_leak( + &r, + "awk_undefined_function_call", + AWK_BANNED, + ); +} diff --git a/crates/bashkit/src/builtins/iconv.rs b/crates/bashkit/src/builtins/iconv.rs index 7fee821ec..b2dfab105 100644 --- a/crates/bashkit/src/builtins/iconv.rs +++ b/crates/bashkit/src/builtins/iconv.rs @@ -307,7 +307,10 @@ impl Builtin for Iconv { } else if let Some(stdin) = ctx.stdin { stdin.as_bytes().to_vec() } else { - return Ok(Self::err("no input (provide file argument or pipe stdin)", 1)); + return Ok(Self::err( + "no input (provide file argument or pipe stdin)", + 1, + )); }; // Decode from source encoding to UTF-8 string diff --git a/crates/bashkit/src/builtins/ls.rs b/crates/bashkit/src/builtins/ls.rs deleted file mode 100644 index 990d809b9..000000000 --- a/crates/bashkit/src/builtins/ls.rs +++ /dev/null @@ -1,3824 +0,0 @@ -//! Directory listing builtins - ls, find, rmdir - -// Uses unwrap() for validated single-char strings (e.g., "f".chars().next()) -#![allow(clippy::unwrap_used)] - -use async_trait::async_trait; -use std::ffi::OsString; -use std::path::Path; - -use super::clap_env::apply_env_defaults; -use super::generated::ls_args::{LS_ENV_DEFAULTS, ls_command}; -use super::{Builtin, Context, ExecutionPlan, SubCommand, resolve_path}; -use crate::error::Result; -use crate::fs::FileType; -use crate::interpreter::{ControlFlow, ExecResult}; - -/// Argument IDs from the generated `ls_command()` that bashkit currently -/// implements. Anything else clap accepts is reported as "not yet -/// implemented" so scripts get a deterministic error instead of silently -/// missing behavior. See spec `coreutils-args-port.md`. -const LS_SUPPORTED_IDS: &[&str] = &[ - // The 8 short flags the original bashkit ls accepted. - "long", // -l - "all", // -a - "human-readable", // -h - "1", // -1 - "recursive", // -R - "t", // -t - "classify", // -F / --classify - "C", // -C - // Non-flag positional + always-supported infrastructure. - "paths", - "help", -]; - -/// Options for ls command -struct LsOptions { - long: bool, - all: bool, - human: bool, - one_per_line: bool, - recursive: bool, - sort_by_time: bool, - classify: bool, - columns: bool, -} - -/// The ls builtin - list directory contents. -/// -/// Usage: ls [-l] [-a] [-h] [-1] [-R] [-t] [-F] [-C] [PATH...] -/// -/// Options: -/// -l Use long listing format -/// -a Show hidden files (starting with .) -/// -h Human-readable sizes (with -l) -/// -1 One entry per line -/// -R List subdirectories recursively -/// -t Sort by modification time, newest first -/// -F Append indicator (/ for dirs, * for executables, @ for symlinks, | for FIFOs) -/// -C List entries in columns (multi-column output) -pub struct Ls; - -#[async_trait] -impl Builtin for Ls { - async fn execute(&self, ctx: Context<'_>) -> Result { - // clap expects argv[0] = program name; bashkit's ctx.args excludes it. - let argv: Vec = std::iter::once(OsString::from("ls")) - .chain(ctx.args.iter().map(OsString::from)) - .collect(); - // Layer bashkit's virtual env over argv before clap parses it. - // Mirrors uutils' `Arg::env(...)` precedence (argv > env > default) - // but sources values from `ctx.env` instead of the host process — - // see TM-INF-024 and `builtins/clap_env.rs`. - let argv = apply_env_defaults(argv, LS_ENV_DEFAULTS, ctx.env); - - // GNU coreutils' help layout opens with the usage line; clap's - // default template leads with the `about`. uutils handles this via - // uucore's `localized_help_template`, which we drop during codegen - // because it pulls in Fluent. Re-apply a GNU-equivalent template. - let cmd = ls_command().help_template("Usage: {usage}\n{about}\n\n{all-args}\n"); - let matches = match cmd.try_get_matches_from(argv) { - Ok(m) => m, - Err(e) => { - let kind = e.kind(); - let rendered = e.render().to_string(); - if matches!( - kind, - clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion - ) { - return Ok(ExecResult::ok(rendered)); - } - return Ok(ExecResult::err(rendered, 2)); - } - }; - - // Reject any uu_ls flag bashkit hasn't implemented yet. Reporting - // them up front beats silently parsing them and producing GNU- - // incompatible output. Same pattern as `tac -b/-r/-s`. - let unsupported: Vec = matches - .ids() - .filter(|id| { - let name = id.as_str(); - !LS_SUPPORTED_IDS.contains(&name) - && matches.value_source(name) != Some(clap::parser::ValueSource::DefaultValue) - }) - .map(|id| id.as_str().to_string()) - .collect(); - if !unsupported.is_empty() { - return Ok(ExecResult::err( - format!( - "ls: option(s) not yet implemented in bashkit: {}\n", - unsupported.join(", ") - ), - 2, - )); - } - - // -F/--classify takes an optional value (`always`/`auto`/`never`). - // The bashkit implementation only knows "classify or not"; treat - // an explicit `never` as off and anything else (including the - // default "always") as on. - let classify = matches.contains_id("classify") - && matches - .get_one::("classify") - .map(|v| v != "never") - .unwrap_or(true); - - let opts = LsOptions { - long: matches.get_flag("long"), - all: matches.get_flag("all"), - human: matches.get_flag("human-readable"), - one_per_line: matches.get_flag("1"), - recursive: matches.get_flag("recursive"), - sort_by_time: matches.get_flag("t"), - classify, - columns: matches.get_flag("C"), - }; - - // PATHS holds OsString values; convert to owned strings for the - // existing rendering loop. - let paths_owned: Vec = matches - .get_many::("paths") - .map(|vs| vs.map(|v| v.to_string_lossy().into_owned()).collect()) - .unwrap_or_default(); - let mut paths: Vec<&str> = paths_owned.iter().map(String::as_str).collect(); - - // Default to current directory - if paths.is_empty() { - paths.push("."); - } - - let mut output = String::new(); - let multiple_paths = paths.len() > 1 || opts.recursive; - - // Separate file and directory arguments (like real ls) - let mut file_args: Vec<(&str, crate::fs::Metadata)> = Vec::new(); - let mut dir_args: Vec<(usize, &str, std::path::PathBuf)> = Vec::new(); - - for (i, path_str) in paths.iter().enumerate() { - let path = resolve_path(ctx.cwd, path_str); - - // Check if path exists - if !ctx.fs.exists(&path).await.unwrap_or(false) { - return Ok(ExecResult::err( - format!( - "ls: cannot access '{}': No such file or directory\n", - path_str - ), - 2, - )); - } - - let metadata = ctx.fs.stat(&path).await?; - - if metadata.file_type.is_file() { - file_args.push((path_str, metadata)); - } else { - dir_args.push((i, path_str, path)); - } - } - - // Sort file arguments by time if -t, preserving original paths - if opts.sort_by_time { - file_args.sort_by_key(|entry| std::cmp::Reverse(entry.1.modified)); - } - - // Output file arguments first (preserving path as given by user) - if opts.long { - for (path_str, metadata) in &file_args { - let mut entry = format_long_entry(path_str, metadata, opts.human); - if opts.classify { - // Insert suffix before the trailing newline - let suffix = classify_suffix(metadata); - if !suffix.is_empty() { - entry.insert_str(entry.len() - 1, suffix); - } - } - output.push_str(&entry); - } - } else if !file_args.is_empty() { - let names: Vec = file_args - .iter() - .map(|(path_str, metadata)| { - let mut name = (*path_str).to_string(); - if opts.classify { - name.push_str(classify_suffix(metadata)); - } - name - }) - .collect(); - if opts.columns && !opts.one_per_line { - output.push_str(&format_columns(&names, 80)); - } else { - for name in &names { - output.push_str(name); - output.push('\n'); - } - } - } - - // Then output directory listings - for (i, path_str, path) in &dir_args { - if let Err(e) = list_directory( - &ctx, - path, - path_str, - &mut output, - &opts, - multiple_paths, - *i > 0 || !file_args.is_empty(), - ) - .await - { - return Ok(ExecResult::err(format!("ls: {}\n", e), 2)); - } - } - - Ok(ExecResult::ok(output)) - } -} - -async fn list_directory( - ctx: &Context<'_>, - path: &Path, - display_path: &str, - output: &mut String, - opts: &LsOptions, - show_header: bool, - add_newline: bool, -) -> std::result::Result<(), String> { - if add_newline { - output.push('\n'); - } - - if show_header { - output.push_str(&format!("{}:\n", display_path)); - } - - let entries = ctx - .fs - .read_dir(path) - .await - .map_err(|e| format!("cannot open directory '{}': {}", display_path, e))?; - - // Sort entries - let mut sorted_entries = entries; - if opts.sort_by_time { - // Sort by modification time, newest first - sorted_entries.sort_by_key(|entry| std::cmp::Reverse(entry.metadata.modified)); - } else { - // Sort alphabetically - sorted_entries.sort_by(|a, b| a.name.cmp(&b.name)); - } - - // Filter hidden files unless -a - let filtered: Vec<_> = sorted_entries - .iter() - .filter(|e| opts.all || !e.name.starts_with('.')) - .collect(); - - // Collect subdirectories for recursive listing - let mut subdirs: Vec<(std::path::PathBuf, String)> = Vec::new(); - - if opts.long { - for entry in &filtered { - let mut line = format_long_entry(&entry.name, &entry.metadata, opts.human); - if opts.classify { - let suffix = classify_suffix(&entry.metadata); - if !suffix.is_empty() { - line.insert_str(line.len() - 1, suffix); - } - } - output.push_str(&line); - if opts.recursive && entry.metadata.file_type.is_dir() { - subdirs.push(( - path.join(&entry.name), - format!("{}/{}", display_path, entry.name), - )); - } - } - } else { - // Collect entry names for potential column formatting - let mut names: Vec = Vec::new(); - for entry in &filtered { - let mut name = entry.name.clone(); - if opts.classify { - name.push_str(classify_suffix(&entry.metadata)); - } - names.push(name); - if opts.recursive && entry.metadata.file_type.is_dir() { - subdirs.push(( - path.join(&entry.name), - format!("{}/{}", display_path, entry.name), - )); - } - } - - // Precedence: -l > -1 > -C > default (one-per-line) - if opts.columns && !opts.one_per_line { - output.push_str(&format_columns(&names, 80)); - } else { - for name in &names { - output.push_str(name); - output.push('\n'); - } - } - } - - // Recursive listing - if opts.recursive { - for (subpath, display) in subdirs { - // Box the future to avoid infinite recursion type size - Box::pin(list_directory( - ctx, &subpath, &display, output, opts, true, true, - )) - .await?; - } - } - - Ok(()) -} - -/// Return the classify indicator character for a file type. -/// `/` for directories, `*` for executables, `@` for symlinks, `|` for FIFOs. -fn classify_suffix(metadata: &crate::fs::Metadata) -> &'static str { - match metadata.file_type { - FileType::Directory => "/", - FileType::Symlink => "@", - FileType::Fifo => "|", - FileType::File => { - // Executable if any execute bit is set - if metadata.mode & 0o111 != 0 { "*" } else { "" } - } - } -} - -/// Format entries in column-major order, like `ls -C`. -/// Uses a fixed terminal width (80) since VFS has no real terminal. -/// Per-column widths match GNU coreutils behavior. -fn format_columns(entries: &[String], terminal_width: usize) -> String { - if entries.is_empty() { - return String::new(); - } - - // Try fitting as many columns as possible, starting from the maximum - let max_width = entries.iter().map(|e| e.len()).max().unwrap_or(0); - let max_possible_cols = (terminal_width / (max_width.min(1) + 2)).max(1); - - let mut num_cols = 1; - let mut col_widths: Vec = vec![0]; - let mut num_rows = entries.len(); - - // Try increasing column counts to find the best fit - for try_cols in 2..=max_possible_cols.min(entries.len()) { - let try_rows = entries.len().div_ceil(try_cols); - // Calculate per-column widths (max entry width in each column) - let mut widths = vec![0usize; try_cols]; - for (i, entry) in entries.iter().enumerate() { - let col = i / try_rows; - if col < try_cols { - widths[col] = widths[col].max(entry.len()); - } - } - // Total width: each column except last gets 2-space padding - let total: usize = widths.iter().sum::() + (try_cols - 1) * 2; - if total <= terminal_width { - num_cols = try_cols; - col_widths = widths; - num_rows = try_rows; - } - } - - let mut output = String::new(); - for row in 0..num_rows { - for (col, col_w) in col_widths.iter().enumerate() { - // Column-major order: fill columns top-to-bottom, left-to-right - let idx = col * num_rows + row; - if idx < entries.len() { - let is_last = col == num_cols - 1 || idx + num_rows >= entries.len(); - if is_last { - output.push_str(&entries[idx]); - } else { - let width = col_w + 2; // entry width + 2 spaces - output.push_str(&format!("{: String { - let file_type = match metadata.file_type { - FileType::Directory => 'd', - FileType::Symlink => 'l', - FileType::Fifo => 'p', - FileType::File => '-', - }; - - let mode = metadata.mode; - let perms = format!( - "{}{}{}{}{}{}{}{}{}", - if mode & 0o400 != 0 { 'r' } else { '-' }, - if mode & 0o200 != 0 { 'w' } else { '-' }, - if mode & 0o100 != 0 { 'x' } else { '-' }, - if mode & 0o040 != 0 { 'r' } else { '-' }, - if mode & 0o020 != 0 { 'w' } else { '-' }, - if mode & 0o010 != 0 { 'x' } else { '-' }, - if mode & 0o004 != 0 { 'r' } else { '-' }, - if mode & 0o002 != 0 { 'w' } else { '-' }, - if mode & 0o001 != 0 { 'x' } else { '-' }, - ); - - let size = if human { - human_readable_size(metadata.size) - } else { - format!("{:>8}", metadata.size) - }; - - // Format modified time - let modified = metadata - .modified - .duration_since(std::time::UNIX_EPOCH) - .map(|d| { - let secs = d.as_secs(); - // Simple date formatting: YYYY-MM-DD HH:MM - let days = secs / 86400; - let hours = (secs % 86400) / 3600; - let mins = (secs % 3600) / 60; - // Approximate date calculation - let years = 1970 + (days / 365); - let remaining_days = days % 365; - let month = remaining_days / 30 + 1; - let day = remaining_days % 30 + 1; - format!( - "{:04}-{:02}-{:02} {:02}:{:02}", - years, month, day, hours, mins - ) - }) - .unwrap_or_else(|_| "????-??-?? ??:??".to_string()); - - format!("{}{} {} {} {}\n", file_type, perms, size, modified, name) -} - -fn human_readable_size(size: u64) -> String { - const KB: u64 = 1024; - const MB: u64 = 1024 * KB; - const GB: u64 = 1024 * MB; - - if size >= GB { - format!("{:>5.1}G", size as f64 / GB as f64) - } else if size >= MB { - format!("{:>5.1}M", size as f64 / MB as f64) - } else if size >= KB { - format!("{:>5.1}K", size as f64 / KB as f64) - } else { - format!("{:>6}", size) - } -} - -/// Options for find command -struct FindOptions { - name_pattern: Option, - /// -path pattern: match against the full display path - path_pattern: Option, - type_filter: Option, - max_depth: Option, - min_depth: Option, - printf_format: Option, - /// -exec/-execdir command template (args before \; or +) - exec_args: Vec, - /// true if -exec uses + (batch mode), false for \; (per-file mode) - exec_batch: bool, - /// Negate the -name predicate - negate_name: bool, - /// Negate the -path predicate - negate_path: bool, -} - -/// The find builtin - search for files. -/// -/// Usage: find [PATH...] [-name PATTERN] [-type TYPE] [-maxdepth N] [-mindepth N] [-printf FMT] [-exec CMD {} \;] -/// -/// Options: -/// -name PATTERN Match filename against PATTERN (supports * and ?) -/// -type TYPE Match file type: f (file), d (directory), l (link) -/// -maxdepth N Descend at most N levels -/// -mindepth N Do not apply tests at levels less than N -/// -print Print matching paths (default) -/// -printf FMT Print using format string (%f %p %P %s %m %M %y %d %T@) -/// -exec CMD {} \; Execute CMD for each match ({} = path) -/// -exec CMD {} + Execute CMD once with all matches -pub struct Find; - -/// Parse find arguments into search paths and options. -/// Returns (paths, opts) or an error ExecResult. -#[allow(clippy::result_large_err)] -fn parse_find_args(args: &[String]) -> std::result::Result<(Vec, FindOptions), ExecResult> { - let mut paths: Vec = Vec::new(); - let mut opts = FindOptions { - name_pattern: None, - path_pattern: None, - type_filter: None, - max_depth: None, - min_depth: None, - printf_format: None, - exec_args: Vec::new(), - exec_batch: false, - negate_name: false, - negate_path: false, - }; - let mut negate_next = false; - - let mut i = 0; - while i < args.len() { - let arg = &args[i]; - match arg.as_str() { - "-name" => { - i += 1; - if i >= args.len() { - return Err(ExecResult::err( - "find: missing argument to '-name'\n".to_string(), - 1, - )); - } - opts.name_pattern = Some(args[i].clone()); - if negate_next { - opts.negate_name = true; - negate_next = false; - } - } - "-path" => { - i += 1; - if i >= args.len() { - return Err(ExecResult::err( - "find: missing argument to '-path'\n".to_string(), - 1, - )); - } - opts.path_pattern = Some(args[i].clone()); - if negate_next { - opts.negate_path = true; - negate_next = false; - } - } - "-type" => { - i += 1; - if i >= args.len() { - return Err(ExecResult::err( - "find: missing argument to '-type'\n".to_string(), - 1, - )); - } - let t = &args[i]; - match t.as_str() { - "f" | "d" | "l" => opts.type_filter = Some(t.chars().next().unwrap()), - _ => { - return Err(ExecResult::err(format!("find: unknown type '{}'\n", t), 1)); - } - } - } - "-maxdepth" => { - i += 1; - if i >= args.len() { - return Err(ExecResult::err( - "find: missing argument to '-maxdepth'\n".to_string(), - 1, - )); - } - match args[i].parse::() { - Ok(n) => opts.max_depth = Some(n), - Err(_) => { - return Err(ExecResult::err( - format!("find: invalid maxdepth value '{}'\n", args[i]), - 1, - )); - } - } - } - "-mindepth" => { - i += 1; - if i >= args.len() { - return Err(ExecResult::err( - "find: missing argument to '-mindepth'\n".to_string(), - 1, - )); - } - match args[i].parse::() { - Ok(n) => opts.min_depth = Some(n), - Err(_) => { - return Err(ExecResult::err( - format!("find: invalid mindepth value '{}'\n", args[i]), - 1, - )); - } - } - } - "-print" | "-print0" => { - // Default action, ignore - } - "-printf" => { - i += 1; - if i >= args.len() { - return Err(ExecResult::err( - "find: missing argument to '-printf'\n".to_string(), - 1, - )); - } - opts.printf_format = Some(args[i].clone()); - } - "-exec" | "-execdir" => { - i += 1; - while i < args.len() { - let a = &args[i]; - if a == ";" || a == "\\;" { - break; - } - if a == "+" { - opts.exec_batch = true; - break; - } - opts.exec_args.push(a.clone()); - i += 1; - } - } - "-not" | "!" => { - negate_next = true; - } - s if s.starts_with('-') => { - return Err(ExecResult::err( - format!("find: unknown predicate '{}'\n", s), - 1, - )); - } - _ => { - paths.push(arg.clone()); - } - } - i += 1; - } - - if paths.is_empty() { - paths.push(".".to_string()); - } - - Ok((paths, opts)) -} - -struct FindPlanData { - matched_paths: Vec, - errors: String, - had_error: bool, -} - -/// Collect matched paths and path/traversal errors for find execution planning. -async fn collect_find_plan_data( - ctx: &Context<'_>, - search_paths: &[String], - opts: &FindOptions, -) -> Result { - let mut matched: Vec = Vec::new(); - let mut errors = String::new(); - let mut had_error = false; - // Reuse find_recursive but with a temporary output buffer - let temp_opts = FindOptions { - name_pattern: opts.name_pattern.clone(), - path_pattern: opts.path_pattern.clone(), - type_filter: opts.type_filter, - max_depth: opts.max_depth, - min_depth: opts.min_depth, - printf_format: None, // Don't format, just collect paths - exec_args: Vec::new(), - exec_batch: false, - negate_name: opts.negate_name, - negate_path: opts.negate_path, - }; - let mut output = String::new(); - for path_str in search_paths { - let path = resolve_path(ctx.cwd, path_str); - if !ctx.fs.exists(&path).await.unwrap_or(false) { - errors.push_str(&format!( - "find: '{}': No such file or directory\n", - path_str - )); - had_error = true; - continue; - } - if let Err(e) = find_recursive(ctx, &path, path_str, &temp_opts, 0, &mut output).await { - errors.push_str(&format!("find: '{}': {}\n", path_str, e)); - had_error = true; - } - } - // Parse the output back into paths (each line is a path) - for line in output.lines() { - if !line.is_empty() { - matched.push(line.to_string()); - } - } - Ok(FindPlanData { - matched_paths: matched, - errors, - had_error, - }) -} - -/// Build exec sub-commands from matched paths and exec_args template. -fn build_find_exec_commands( - exec_args: &[String], - matched_paths: &[String], - batch: bool, -) -> Vec { - if exec_args.is_empty() || matched_paths.is_empty() { - return Vec::new(); - } - - if batch { - // Batch mode: -exec cmd {} + - // Replace {} with all paths at once - let cmd_args: Vec = exec_args - .iter() - .flat_map(|arg| { - if arg == "{}" { - matched_paths.to_vec() - } else { - vec![arg.clone()] - } - }) - .collect(); - - if cmd_args.is_empty() { - return Vec::new(); - } - - vec![SubCommand { - name: cmd_args[0].clone(), - args: cmd_args[1..].to_vec(), - stdin: None, - }] - } else { - // Per-file mode: -exec cmd {} \; - matched_paths - .iter() - .map(|found_path| { - let cmd_args: Vec = exec_args - .iter() - .map(|arg| arg.replace("{}", found_path)) - .collect(); - - SubCommand { - name: cmd_args[0].clone(), - args: cmd_args[1..].to_vec(), - stdin: None, - } - }) - .collect() - } -} - -#[async_trait] -impl Builtin for Find { - async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::check_help_version( - ctx.args, - "Usage: find [PATH...] [EXPRESSION]\nSearch for files in a directory hierarchy.\n\n -name PATTERN\tmatch filename against PATTERN (supports * and ?)\n -path PATTERN\tmatch full path against PATTERN\n -type TYPE\tmatch file type: f (file), d (directory), l (link)\n -maxdepth N\tdescend at most N levels\n -mindepth N\tdo not apply tests at levels less than N\n -print\t\tprint matching paths (default)\n -printf FMT\tprint using format string (%f %p %P %s %m %M %y %d %T@)\n -exec CMD {} \\;\texecute CMD for each match ({} = path)\n -exec CMD {} +\texecute CMD once with all matches\n -not, !\t\tnegate the next predicate\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", - Some("find (bashkit) 0.1"), - ) { - return Ok(r); - } - - let (search_paths, opts) = match parse_find_args(ctx.args) { - Ok(v) => v, - Err(e) => return Ok(e), - }; - - let mut output = String::new(); - let mut errors = String::new(); - let mut had_error = false; - - for path_str in &search_paths { - let path = resolve_path(ctx.cwd, path_str); - if !ctx.fs.exists(&path).await.unwrap_or(false) { - errors.push_str(&format!( - "find: '{}': No such file or directory\n", - path_str - )); - had_error = true; - continue; - } - - if let Err(e) = find_recursive(&ctx, &path, path_str, &opts, 0, &mut output).await { - errors.push_str(&format!("find: '{}': {}\n", path_str, e)); - had_error = true; - } - } - - Ok(ExecResult { - stdout: output, - stderr: errors, - exit_code: if had_error { 1 } else { 0 }, - control_flow: ControlFlow::None, - ..Default::default() - }) - } - - async fn execution_plan(&self, ctx: &Context<'_>) -> Result> { - let (search_paths, opts) = match parse_find_args(ctx.args) { - Ok(v) => v, - Err(_) => return Ok(None), // Let execute() handle errors - }; - - // Only return a plan when -exec is present - if opts.exec_args.is_empty() { - return Ok(None); - } - - // Collect matched paths plus any path/traversal errors. - let plan_data = collect_find_plan_data(ctx, &search_paths, &opts).await?; - if plan_data.matched_paths.is_empty() && !plan_data.had_error { - return Ok(None); - } - - let commands = - build_find_exec_commands(&opts.exec_args, &plan_data.matched_paths, opts.exec_batch); - if commands.is_empty() && !plan_data.had_error { - return Ok(None); - } - - if plan_data.had_error { - return Ok(Some(ExecutionPlan::BatchWithStatus { - commands, - stderr_prefix: plan_data.errors, - force_error_exit: true, - })); - } - - Ok(Some(ExecutionPlan::Batch { commands })) - } -} - -fn find_recursive<'a>( - ctx: &'a Context<'_>, - path: &'a Path, - display_path: &'a str, - opts: &'a FindOptions, - current_depth: usize, - output: &'a mut String, -) -> std::pin::Pin> + Send + 'a>> { - Box::pin(async move { - // Check if this entry matches - let metadata = ctx.fs.stat(path).await?; - let entry_name = Path::new(display_path) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| display_path.to_string()); - - // Check type filter - let type_matches = match opts.type_filter { - Some('f') => metadata.file_type.is_file(), - Some('d') => metadata.file_type.is_dir(), - Some('l') => metadata.file_type.is_symlink(), - _ => true, - }; - - // Check name pattern - let name_matches = match &opts.name_pattern { - Some(pattern) => { - let m = glob_match(&entry_name, pattern); - if opts.negate_name { !m } else { m } - } - None => true, - }; - - // Check path pattern - let path_matches = match &opts.path_pattern { - Some(pattern) => { - let m = glob_match(display_path, pattern); - if opts.negate_path { !m } else { m } - } - None => true, - }; - - // Check min depth before outputting - let above_min_depth = match opts.min_depth { - Some(min) => current_depth >= min, - None => true, - }; - - // Output if matches (or if no filters, show everything) - if type_matches && name_matches && path_matches && above_min_depth { - if let Some(ref fmt) = opts.printf_format { - output.push_str(&find_printf_format(fmt, display_path, &metadata)); - } else { - output.push_str(display_path); - output.push('\n'); - } - } - - // Recurse into directories - if metadata.file_type.is_dir() { - // Check max depth - if let Some(max) = opts.max_depth - && current_depth >= max - { - return Ok(()); - } - - let entries = ctx.fs.read_dir(path).await?; - let mut sorted_entries = entries; - sorted_entries.sort_by(|a, b| a.name.cmp(&b.name)); - - for entry in sorted_entries { - let child_path = path.join(&entry.name); - let child_display = if display_path == "." { - format!("./{}", entry.name) - } else { - format!("{}/{}", display_path, entry.name) - }; - - find_recursive( - ctx, - &child_path, - &child_display, - opts, - current_depth + 1, - output, - ) - .await?; - } - } - - Ok(()) - }) -} - -/// Format a path using find's -printf format string. -fn find_printf_format(fmt: &str, display_path: &str, metadata: &crate::fs::Metadata) -> String { - let mut out = String::new(); - let chars: Vec = fmt.chars().collect(); - let mut i = 0; - while i < chars.len() { - match chars[i] { - '\\' => { - i += 1; - if i < chars.len() { - match chars[i] { - 'n' => out.push('\n'), - 't' => out.push('\t'), - '0' => out.push('\0'), - '\\' => out.push('\\'), - c => { - out.push('\\'); - out.push(c); - } - } - } - } - '%' => { - i += 1; - if i >= chars.len() { - out.push('%'); - continue; - } - match chars[i] { - 'f' => { - let name = std::path::Path::new(display_path) - .file_name() - .map(|s| s.to_string_lossy().to_string()) - .unwrap_or_else(|| display_path.to_string()); - out.push_str(&name); - } - 'p' => out.push_str(display_path), - 'P' => { - // In builtin context, display_path is already relative - let rel = display_path.strip_prefix("./").unwrap_or(display_path); - out.push_str(rel); - } - 's' => out.push_str(&metadata.size.to_string()), - 'm' => out.push_str(&format!("{:o}", metadata.mode & 0o7777)), - 'M' => { - let type_ch = if metadata.file_type.is_dir() { - 'd' - } else if metadata.file_type.is_symlink() { - 'l' - } else { - '-' - }; - out.push(type_ch); - for shift in [6, 3, 0] { - let bits = (metadata.mode >> shift) & 7; - out.push(if bits & 4 != 0 { 'r' } else { '-' }); - out.push(if bits & 2 != 0 { 'w' } else { '-' }); - out.push(if bits & 1 != 0 { 'x' } else { '-' }); - } - } - 'y' => { - let ch = if metadata.file_type.is_dir() { - 'd' - } else if metadata.file_type.is_symlink() { - 'l' - } else { - 'f' - }; - out.push(ch); - } - 'd' => { - // Approximate depth from display_path - let base = display_path.strip_prefix("./").unwrap_or(display_path); - let depth = if base == "." || base.is_empty() { - 0 - } else { - base.matches('/').count() + 1 - }; - out.push_str(&depth.to_string()); - } - 'T' => { - i += 1; - if i < chars.len() && chars[i] == '@' { - let secs = metadata - .modified - .duration_since(std::time::UNIX_EPOCH) - .ok() - .map(|d| d.as_secs()) - .unwrap_or(0); - out.push_str(&secs.to_string()); - } else { - out.push_str("%T"); - continue; - } - } - '%' => out.push('%'), - c => { - out.push('%'); - out.push(c); - } - } - } - c => out.push(c), - } - i += 1; - } - out -} - -/// Simple glob pattern matching for find -name -pub(crate) fn glob_match(value: &str, pattern: &str) -> bool { - let mut value_chars = value.chars().peekable(); - let mut pattern_chars = pattern.chars().peekable(); - - loop { - match (pattern_chars.peek(), value_chars.peek()) { - (None, None) => return true, - (None, Some(_)) => return false, - (Some('*'), _) => { - pattern_chars.next(); - if pattern_chars.peek().is_none() { - return true; - } - while value_chars.peek().is_some() { - let remaining_value: String = value_chars.clone().collect(); - let remaining_pattern: String = pattern_chars.clone().collect(); - if glob_match(&remaining_value, &remaining_pattern) { - return true; - } - value_chars.next(); - } - let remaining_pattern: String = pattern_chars.collect(); - return glob_match("", &remaining_pattern); - } - (Some('?'), Some(_)) => { - pattern_chars.next(); - value_chars.next(); - } - (Some('?'), None) => return false, - (Some(p), Some(v)) => { - if *p == *v { - pattern_chars.next(); - value_chars.next(); - } else { - return false; - } - } - (Some(_), None) => return false, - } - } -} - -/// The rmdir builtin - remove empty directories. -/// -/// Usage: rmdir [-p] DIRECTORY... -/// -/// Options: -/// -p Remove parent directories as well if they become empty -pub struct Rmdir; - -#[async_trait] -impl Builtin for Rmdir { - async fn execute(&self, ctx: Context<'_>) -> Result { - if let Some(r) = super::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", - 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(); - - 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() { - 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 { - break; - } - } else { - break; - } - - current = parent.parent(); - } - } - } - - Ok(ExecResult::ok(String::new())) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - use std::path::PathBuf; - use std::sync::Arc; - - use crate::fs::{FileSystem, InMemoryFs}; - - async fn create_test_ctx() -> (Arc, PathBuf, HashMap) { - let fs = Arc::new(InMemoryFs::new()); - let cwd = PathBuf::from("/home/user"); - let variables = HashMap::new(); - - fs.mkdir(&cwd, true).await.unwrap(); - - (fs, cwd, variables) - } - - // ==================== ls tests ==================== - - #[tokio::test] - async fn test_ls_empty_dir() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert_eq!(result.stdout, ""); - } - - #[tokio::test] - async fn test_ls_with_files() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create some files - fs.write_file(&cwd.join("file1.txt"), b"content1") - .await - .unwrap(); - fs.write_file(&cwd.join("file2.txt"), b"content2") - .await - .unwrap(); - - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("file1.txt")); - assert!(result.stdout.contains("file2.txt")); - } - - #[tokio::test] - async fn test_ls_hidden_files() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join(".hidden"), b"hidden") - .await - .unwrap(); - fs.write_file(&cwd.join("visible"), b"visible") - .await - .unwrap(); - - // Without -a - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(!result.stdout.contains(".hidden")); - assert!(result.stdout.contains("visible")); - - // With -a - let args = vec!["-a".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains(".hidden")); - assert!(result.stdout.contains("visible")); - } - - #[tokio::test] - async fn test_ls_long_format() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join("test.txt"), b"content") - .await - .unwrap(); - - let args = vec!["-l".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - // Long format should include permissions - assert!(result.stdout.contains("rw")); - assert!(result.stdout.contains("test.txt")); - } - - #[tokio::test] - async fn test_ls_nonexistent() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["nonexistent".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 2); - assert!(result.stderr.contains("No such file or directory")); - } - - #[tokio::test] - async fn test_ls_invalid_option() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["-z".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 2); - // clap-rendered diagnostic differs from GNU coreutils' wording - // ("invalid option") but both flag the unknown short flag. - // `### bash_diff` documents the GNU divergence in spec tests. - let combined = format!("{}{}", result.stdout, result.stderr); - assert!( - combined.contains("unexpected argument") || combined.contains("invalid option"), - "expected an unknown-flag diagnostic, got: {}", - combined - ); - } - - #[tokio::test] - async fn test_ls_sort_by_time() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create files with different modification times - fs.write_file(&cwd.join("older.txt"), b"older") - .await - .unwrap(); - fs.write_file(&cwd.join("newer.txt"), b"newer") - .await - .unwrap(); - - let args = vec!["-t".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - // -t should be accepted (not cause an error) - assert!(result.stdout.contains("older.txt")); - assert!(result.stdout.contains("newer.txt")); - } - - #[tokio::test] - async fn test_ls_file() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join("test.txt"), b"content") - .await - .unwrap(); - - let args = vec!["test.txt".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("test.txt")); - } - - #[tokio::test] - async fn test_ls_recursive() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); - fs.write_file(&cwd.join("file.txt"), b"content") - .await - .unwrap(); - fs.write_file(&cwd.join("subdir/nested.txt"), b"nested") - .await - .unwrap(); - - let args = vec!["-R".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("file.txt")); - assert!(result.stdout.contains("subdir")); - assert!(result.stdout.contains("nested.txt")); - } - - /// TM-INF-024 regression: a host-set `TIME_STYLE` (or any other env var - /// uutils' `uu_app()` attaches to an Arg via `.env(...)`) MUST NOT - /// reach the clap parser. uutils' upstream wires - /// `Arg::new(TIME_STYLE).env("TIME_STYLE")` so the option defaults - /// from the host process — bashkit strips that at codegen time, drops - /// the `env` clap feature workspace-wide as defence-in-depth, and - /// re-implements env-default precedence against `ctx.env` only via - /// `apply_env_defaults`. Without those guards a plain `ls` in a - /// container that exports `TIME_STYLE=long-iso` would trip the - /// unsupported-option gate (since bashkit hasn't implemented - /// `--time-style` yet) and break `ls` for every script running on - /// that host. - #[tokio::test] - #[serial_test::serial] - async fn ls_ignores_host_time_style_and_tabsize() { - // SAFETY: serial_test::serial serializes against other tests that - // touch the process env. Setting + unsetting around a single - // bashkit `Ls.execute()` is the only way to exercise the - // sandbox-leak regression: we're asserting bashkit does NOT - // observe these even when they're present on the host process. - unsafe { - std::env::set_var("TIME_STYLE", "long-iso"); - std::env::set_var("TABSIZE", "16"); - } - - let (fs, mut cwd, mut variables) = create_test_ctx().await; - fs.write_file(&cwd.join("a.txt"), b"a").await.unwrap(); - let env = HashMap::new(); - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - - unsafe { - std::env::remove_var("TIME_STYLE"); - std::env::remove_var("TABSIZE"); - } - - assert_eq!( - result.exit_code, 0, - "host TIME_STYLE/TABSIZE leaked into bashkit ls parser \ - (TM-INF-024): stderr={}", - result.stderr - ); - assert!(result.stdout.contains("a.txt")); - assert!( - !result.stderr.contains("not yet implemented"), - "host env tunneled through clap as a value source: stderr={}", - result.stderr - ); - } - - /// Counterpart to `ls_ignores_host_time_style_and_tabsize`: when - /// `TIME_STYLE` lives in bashkit's *virtual* env (`ctx.env`), the - /// codegen-emitted `LS_ENV_DEFAULTS` table feeds it through - /// `apply_env_defaults` and clap honours it just as upstream - /// uutils would honour `std::env::var("TIME_STYLE")`. We assert - /// that path by observing the clap value-source: ls today rejects - /// `--time-style` with a "not yet implemented" message, but only - /// because clap saw the option at all. If the env→argv shim were - /// silently broken, ls would succeed. - #[tokio::test] - async fn ls_honors_virtual_env_time_style() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - fs.write_file(&cwd.join("a.txt"), b"a").await.unwrap(); - let mut env = HashMap::new(); - env.insert("TIME_STYLE".to_string(), "long-iso".to_string()); - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - - assert_eq!( - result.exit_code, 2, - "virtual TIME_STYLE should reach clap's parser; got stdout={} \ - stderr={}", - result.stdout, result.stderr - ); - assert!( - result.stderr.contains("not yet implemented") && result.stderr.contains("time-style"), - "expected unsupported-option error mentioning time-style; got stderr={}", - result.stderr - ); - } - - /// Argv-set `--time-style` must take precedence over a `ctx.env` - /// value, matching clap's documented "argv > env > default" - /// precedence and `apply_env_defaults`'s contract. Easiest way to - /// observe it: pass an explicit `--time-style=iso`, set a - /// *different* value in `ctx.env`, and assert the error message - /// surfaces the explicit one (or at least that env did not - /// double-inject — the unsupported-option path collapses both - /// into the same diagnostic, so we just confirm exit==2 and that - /// argv wasn't mangled). - #[tokio::test] - async fn ls_argv_time_style_overrides_virtual_env() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let mut env = HashMap::new(); - env.insert("TIME_STYLE".to_string(), "long-iso".to_string()); - let args: Vec = vec!["--time-style=iso".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - let result = Ls.execute(ctx).await.unwrap(); - // Same unsupported-option gate as the virtual-env-only test; - // the precedence-correctness signal is that we don't see - // *two* sources or a clap parse failure complaining about - // duplicate `--time-style`. - assert_eq!(result.exit_code, 2); - assert!( - !result.stderr.contains("supplied more than once"), - "shim double-injected --time-style: stderr={}", - result.stderr - ); - } - - // ==================== find tests ==================== - - #[tokio::test] - async fn test_find_current_dir() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join("file.txt"), b"content") - .await - .unwrap(); - - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains(".")); - assert!(result.stdout.contains("file.txt")); - } - - #[tokio::test] - async fn test_find_name_pattern() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join("file.txt"), b"content") - .await - .unwrap(); - fs.write_file(&cwd.join("other.md"), b"content") - .await - .unwrap(); - - let args = vec!["-name".to_string(), "*.txt".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("file.txt")); - assert!(!result.stdout.contains("other.md")); - } - - #[tokio::test] - async fn test_find_type_file() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join("file.txt"), b"content") - .await - .unwrap(); - fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); - - let args = vec!["-type".to_string(), "f".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("file.txt")); - assert!(!result.stdout.contains("subdir")); - } - - #[tokio::test] - async fn test_find_type_directory() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join("file.txt"), b"content") - .await - .unwrap(); - fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); - - let args = vec!["-type".to_string(), "d".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(!result.stdout.contains("file.txt")); - // Should contain the directory - let lines: Vec<&str> = result.stdout.lines().collect(); - assert!(lines.iter().any(|l| l.contains("subdir") || *l == ".")); - } - - #[tokio::test] - async fn test_find_maxdepth() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("a"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); - fs.write_file(&cwd.join("a/b/deep.txt"), b"deep") - .await - .unwrap(); - - let args = vec!["-maxdepth".to_string(), "1".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(result.stdout.contains("./a")); - assert!(!result.stdout.contains("deep.txt")); - } - - #[tokio::test] - async fn test_find_nonexistent() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["nonexistent".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("No such file or directory")); - } - - #[tokio::test] - async fn test_find_missing_name_arg() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["-name".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("missing argument")); - } - - #[tokio::test] - async fn test_find_unknown_type() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["-type".to_string(), "x".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("unknown type")); - } - - #[tokio::test] - async fn test_find_deep_recursion() { - // Test that find without maxdepth descends into all subdirectory levels - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create a deep directory structure: a/b/c/d/deep.txt - fs.mkdir(&cwd.join("a"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b/c"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b/c/d"), false).await.unwrap(); - fs.write_file(&cwd.join("a/b/c/d/deep.txt"), b"deep content") - .await - .unwrap(); - - // Also add files at each level - fs.write_file(&cwd.join("a/file1.txt"), b"level1") - .await - .unwrap(); - fs.write_file(&cwd.join("a/b/file2.txt"), b"level2") - .await - .unwrap(); - fs.write_file(&cwd.join("a/b/c/file3.txt"), b"level3") - .await - .unwrap(); - - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - // Should find the root - assert!(result.stdout.contains("."), "Should contain current dir"); - - // Should find all directories at all levels - assert!(result.stdout.contains("./a"), "Should contain ./a"); - assert!(result.stdout.contains("./a/b"), "Should contain ./a/b"); - assert!(result.stdout.contains("./a/b/c"), "Should contain ./a/b/c"); - assert!( - result.stdout.contains("./a/b/c/d"), - "Should contain ./a/b/c/d" - ); - - // Should find all files at all levels - assert!( - result.stdout.contains("file1.txt"), - "Should contain file1.txt at level 1" - ); - assert!( - result.stdout.contains("file2.txt"), - "Should contain file2.txt at level 2" - ); - assert!( - result.stdout.contains("file3.txt"), - "Should contain file3.txt at level 3" - ); - assert!( - result.stdout.contains("deep.txt"), - "Should contain deep.txt at level 4" - ); - } - - #[tokio::test] - async fn test_ls_recursive_deep() { - // Test that ls -R descends into all subdirectory levels - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create a deep directory structure: a/b/c/deep.txt - fs.mkdir(&cwd.join("a"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b/c"), false).await.unwrap(); - fs.write_file(&cwd.join("a/b/c/deep.txt"), b"deep content") - .await - .unwrap(); - - let args = vec!["-R".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - // Should list all directories and their contents - assert!(result.stdout.contains("a"), "Should list dir a"); - assert!(result.stdout.contains("b"), "Should list dir b under a"); - assert!(result.stdout.contains("c"), "Should list dir c under a/b"); - assert!( - result.stdout.contains("deep.txt"), - "Should list deep.txt under a/b/c" - ); - } - - #[tokio::test] - async fn test_find_very_deep_nesting() { - // Test 10 levels of nesting to ensure no recursion limits - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create 10 levels deep - let mut path = cwd.clone(); - for i in 0..10 { - path = path.join(format!("level{}", i)); - fs.mkdir(&path, false).await.unwrap(); - fs.write_file( - &path.join(format!("file{}.txt", i)), - format!("content{}", i).as_bytes(), - ) - .await - .unwrap(); - } - - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - // Verify all 10 levels are found - for i in 0..10 { - assert!( - result.stdout.contains(&format!("level{}", i)), - "Should find level{} directory", - i - ); - assert!( - result.stdout.contains(&format!("file{}.txt", i)), - "Should find file{}.txt", - i - ); - } - } - - #[tokio::test] - async fn test_find_and_ls_consistency() { - // Ensure find and ls -R find the same nested structure - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create test structure - fs.mkdir(&cwd.join("top"), false).await.unwrap(); - fs.mkdir(&cwd.join("top/middle"), false).await.unwrap(); - fs.mkdir(&cwd.join("top/middle/bottom"), false) - .await - .unwrap(); - fs.write_file(&cwd.join("top/a.txt"), b"a").await.unwrap(); - fs.write_file(&cwd.join("top/middle/b.txt"), b"b") - .await - .unwrap(); - fs.write_file(&cwd.join("top/middle/bottom/c.txt"), b"c") - .await - .unwrap(); - - // Run find - let args_find: Vec = vec![]; - let ctx_find = Context { - args: &args_find, - env: &env, - variables: &mut variables.clone(), - cwd: &mut cwd.clone(), - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let find_result = Find.execute(ctx_find).await.unwrap(); - - // Run ls -R - let args_ls = vec!["-R".to_string()]; - let ctx_ls = Context { - args: &args_ls, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let ls_result = Ls.execute(ctx_ls).await.unwrap(); - - // Both should find all the nested content - assert!(find_result.stdout.contains("top")); - assert!(find_result.stdout.contains("middle")); - assert!(find_result.stdout.contains("bottom")); - assert!(find_result.stdout.contains("a.txt")); - assert!(find_result.stdout.contains("b.txt")); - assert!(find_result.stdout.contains("c.txt")); - - assert!(ls_result.stdout.contains("top")); - assert!(ls_result.stdout.contains("middle")); - assert!(ls_result.stdout.contains("bottom")); - assert!(ls_result.stdout.contains("a.txt")); - assert!(ls_result.stdout.contains("b.txt")); - assert!(ls_result.stdout.contains("c.txt")); - } - - #[tokio::test] - async fn test_find_with_empty_subdirs() { - // Ensure empty subdirectories are still traversed - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create structure with some empty dirs in the path - fs.mkdir(&cwd.join("a"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/empty1"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/empty2"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/empty1/deep"), false).await.unwrap(); - fs.write_file(&cwd.join("a/empty1/deep/file.txt"), b"found") - .await - .unwrap(); - - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - // Should find the file through the empty directories - assert!(result.stdout.contains("file.txt")); - assert!(result.stdout.contains("empty1")); - assert!(result.stdout.contains("empty2")); - assert!(result.stdout.contains("deep")); - } - - #[tokio::test] - async fn test_find_from_specific_path() { - // Test finding from a specific starting path (not cwd) - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create nested structure - fs.mkdir(&cwd.join("start"), false).await.unwrap(); - fs.mkdir(&cwd.join("start/sub1"), false).await.unwrap(); - fs.mkdir(&cwd.join("start/sub1/sub2"), false).await.unwrap(); - fs.write_file(&cwd.join("start/sub1/sub2/target.txt"), b"target") - .await - .unwrap(); - - // Find from a specific starting path - let args = vec!["start".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - assert!( - result.stdout.contains("start"), - "Should contain starting path" - ); - assert!(result.stdout.contains("sub1"), "Should descend into sub1"); - assert!(result.stdout.contains("sub2"), "Should descend into sub2"); - assert!( - result.stdout.contains("target.txt"), - "Should find target.txt" - ); - } - - #[tokio::test] - async fn test_find_mindepth() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("a"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); - fs.write_file(&cwd.join("a/file1.txt"), b"f1") - .await - .unwrap(); - fs.write_file(&cwd.join("a/b/file2.txt"), b"f2") - .await - .unwrap(); - - // mindepth 1 should exclude the starting directory "." - let args = vec!["-mindepth".to_string(), "1".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - // Should NOT contain "." as the starting point (depth 0) - let lines: Vec<&str> = result.stdout.lines().collect(); - assert!(!lines.contains(&"."), "mindepth 1 should exclude '.'"); - // Should contain everything at depth >= 1 - assert!(result.stdout.contains("./a")); - assert!(result.stdout.contains("file1.txt")); - assert!(result.stdout.contains("file2.txt")); - } - - #[tokio::test] - async fn test_find_mindepth_with_type() { - // Reproduces the reported issue: find . -mindepth 1 -type f | wc -l - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("a"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); - fs.write_file(&cwd.join("a/file1.txt"), b"f1") - .await - .unwrap(); - fs.write_file(&cwd.join("a/b/file2.txt"), b"f2") - .await - .unwrap(); - - // mindepth 1 + type f - let args = vec![ - "-mindepth".to_string(), - "1".to_string(), - "-type".to_string(), - "f".to_string(), - ]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - let lines: Vec<&str> = result.stdout.lines().filter(|l| !l.is_empty()).collect(); - assert_eq!(lines.len(), 2, "Should find 2 files: {:?}", lines); // debug-ok: assert-failure message - - // mindepth 1 + type d - let args2 = vec![ - "-mindepth".to_string(), - "1".to_string(), - "-type".to_string(), - "d".to_string(), - ]; - let ctx2 = Context { - args: &args2, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result2 = Find.execute(ctx2).await.unwrap(); - assert_eq!(result2.exit_code, 0); - let lines2: Vec<&str> = result2.stdout.lines().filter(|l| !l.is_empty()).collect(); - assert_eq!(lines2.len(), 2, "Should find 2 dirs: {:?}", lines2); // debug-ok: assert-failure message - } - - #[tokio::test] - async fn test_find_mindepth_2() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("a"), false).await.unwrap(); - fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); - fs.write_file(&cwd.join("top.txt"), b"top").await.unwrap(); - fs.write_file(&cwd.join("a/mid.txt"), b"mid").await.unwrap(); - fs.write_file(&cwd.join("a/b/deep.txt"), b"deep") - .await - .unwrap(); - - // mindepth 2 should exclude depth 0 and depth 1 - let args = vec!["-mindepth".to_string(), "2".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - let lines: Vec<&str> = result.stdout.lines().collect(); - // depth 0: "." - excluded - assert!(!lines.contains(&".")); - // depth 1: "./a", "./top.txt" - excluded - assert!(!lines.contains(&"./a")); - assert!(!lines.contains(&"./top.txt")); - // depth 2: "./a/b", "./a/mid.txt" - included - assert!(lines.contains(&"./a/b")); - assert!(lines.contains(&"./a/mid.txt")); - // depth 3: "./a/b/deep.txt" - included - assert!(lines.contains(&"./a/b/deep.txt")); - } - - #[tokio::test] - async fn test_find_mindepth_missing_arg() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["-mindepth".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("missing argument")); - } - - #[tokio::test] - async fn test_find_mindepth_invalid_value() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["-mindepth".to_string(), "abc".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("invalid mindepth")); - } - - // ==================== rmdir tests ==================== - - #[tokio::test] - async fn test_rmdir_empty() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("emptydir"), false).await.unwrap(); - - let args = vec!["emptydir".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Rmdir.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(!fs.exists(&cwd.join("emptydir")).await.unwrap()); - } - - #[tokio::test] - async fn test_rmdir_not_empty() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("notempty"), false).await.unwrap(); - fs.write_file(&cwd.join("notempty/file.txt"), b"content") - .await - .unwrap(); - - let args = vec!["notempty".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Rmdir.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("not empty")); - } - - #[tokio::test] - async fn test_rmdir_nonexistent() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["nonexistent".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Rmdir.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("No such file or directory")); - } - - #[tokio::test] - async fn test_rmdir_not_directory() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join("file.txt"), b"content") - .await - .unwrap(); - - let args = vec!["file.txt".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Rmdir.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("Not a directory")); - } - - #[tokio::test] - async fn test_rmdir_parents() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("a/b/c"), true).await.unwrap(); - - let args = vec!["-p".to_string(), "a/b/c".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Rmdir.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - assert!(!fs.exists(&cwd.join("a/b/c")).await.unwrap()); - assert!(!fs.exists(&cwd.join("a/b")).await.unwrap()); - assert!(!fs.exists(&cwd.join("a")).await.unwrap()); - } - - #[tokio::test] - async fn test_rmdir_missing_operand() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Rmdir.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 1); - assert!(result.stderr.contains("missing operand")); - } - - // ==================== Custom filesystem tests ==================== - - #[tokio::test] - async fn test_find_with_overlay_fs() { - use crate::fs::OverlayFs; - - // Create base filesystem with nested structure - let base = Arc::new(InMemoryFs::new()); - base.mkdir(Path::new("/home/user"), true).await.unwrap(); - base.mkdir(Path::new("/home/user/base"), false) - .await - .unwrap(); - base.mkdir(Path::new("/home/user/base/sub1"), false) - .await - .unwrap(); - base.mkdir(Path::new("/home/user/base/sub1/sub2"), false) - .await - .unwrap(); - base.write_file(Path::new("/home/user/base/file1.txt"), b"base1") - .await - .unwrap(); - base.write_file(Path::new("/home/user/base/sub1/file2.txt"), b"base2") - .await - .unwrap(); - base.write_file(Path::new("/home/user/base/sub1/sub2/file3.txt"), b"base3") - .await - .unwrap(); - - // Create overlay - let overlay: Arc = Arc::new(OverlayFs::new(base)); - - // Add a file in the overlay layer (use recursive to ensure parent exists in upper) - overlay - .mkdir(Path::new("/home/user/base/overlay_dir"), true) - .await - .unwrap(); - overlay - .write_file( - Path::new("/home/user/base/overlay_dir/overlay_file.txt"), - b"overlay", - ) - .await - .unwrap(); - - let mut cwd = PathBuf::from("/home/user"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // Run find on the overlay filesystem - let args = vec!["base".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: overlay.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - // Should find files from base layer - assert!( - result.stdout.contains("file1.txt"), - "Should find file1.txt from base" - ); - assert!( - result.stdout.contains("file2.txt"), - "Should find file2.txt from base/sub1" - ); - assert!( - result.stdout.contains("file3.txt"), - "Should find file3.txt from base/sub1/sub2" - ); - - // Should find files from overlay layer - assert!( - result.stdout.contains("overlay_dir"), - "Should find overlay_dir" - ); - assert!( - result.stdout.contains("overlay_file.txt"), - "Should find overlay_file.txt" - ); - - // Should descend into all subdirectories - assert!(result.stdout.contains("sub1"), "Should find sub1"); - assert!(result.stdout.contains("sub2"), "Should find sub2"); - } - - #[tokio::test] - async fn test_find_with_mountable_fs() { - use crate::fs::MountableFs; - - // Create root filesystem - let root = Arc::new(InMemoryFs::new()); - root.mkdir(Path::new("/home/user"), true).await.unwrap(); - root.write_file(Path::new("/home/user/root_file.txt"), b"root") - .await - .unwrap(); - - // Create a nested filesystem to mount - let nested = Arc::new(InMemoryFs::new()); - nested.mkdir(Path::new("/level1"), false).await.unwrap(); - nested - .mkdir(Path::new("/level1/level2"), false) - .await - .unwrap(); - nested - .mkdir(Path::new("/level1/level2/level3"), false) - .await - .unwrap(); - nested - .write_file(Path::new("/level1/nested1.txt"), b"n1") - .await - .unwrap(); - nested - .write_file(Path::new("/level1/level2/nested2.txt"), b"n2") - .await - .unwrap(); - nested - .write_file(Path::new("/level1/level2/level3/nested3.txt"), b"n3") - .await - .unwrap(); - - // Create mountable filesystem and mount nested at /home/user/mounted - let mountable = MountableFs::new(root.clone()); - mountable - .mount("/home/user/mounted", nested.clone()) - .unwrap(); - - let fs: Arc = Arc::new(mountable); - let mut cwd = PathBuf::from("/home/user"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // Run find from cwd - should find both root files and mounted files - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Find.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - // Should find root file - assert!( - result.stdout.contains("root_file.txt"), - "Should find root_file.txt" - ); - - // Should find mount point - assert!( - result.stdout.contains("mounted"), - "Should find mounted directory" - ); - - // Should descend into mounted filesystem - assert!( - result.stdout.contains("level1"), - "Should find level1 in mounted fs" - ); - assert!( - result.stdout.contains("level2"), - "Should find level2 in mounted fs" - ); - assert!( - result.stdout.contains("level3"), - "Should find level3 in mounted fs" - ); - - // Should find files deep in mounted filesystem - assert!( - result.stdout.contains("nested1.txt"), - "Should find nested1.txt" - ); - assert!( - result.stdout.contains("nested2.txt"), - "Should find nested2.txt" - ); - assert!( - result.stdout.contains("nested3.txt"), - "Should find nested3.txt" - ); - } - - #[tokio::test] - async fn test_ls_recursive_with_overlay_fs() { - use crate::fs::OverlayFs; - - // Create base filesystem with nested structure - let base = Arc::new(InMemoryFs::new()); - base.mkdir(Path::new("/home/user"), true).await.unwrap(); - base.mkdir(Path::new("/home/user/dir"), false) - .await - .unwrap(); - base.mkdir(Path::new("/home/user/dir/subdir"), false) - .await - .unwrap(); - base.write_file(Path::new("/home/user/dir/base.txt"), b"base") - .await - .unwrap(); - base.write_file(Path::new("/home/user/dir/subdir/deep.txt"), b"deep") - .await - .unwrap(); - - let overlay: Arc = Arc::new(OverlayFs::new(base)); - - let mut cwd = PathBuf::from("/home/user"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - let args = vec!["-R".to_string(), "dir".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: overlay, - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - assert!(result.stdout.contains("base.txt"), "Should list base.txt"); - assert!(result.stdout.contains("subdir"), "Should list subdir"); - assert!(result.stdout.contains("deep.txt"), "Should list deep.txt"); - } - - #[tokio::test] - async fn test_ls_recursive_with_mountable_fs() { - use crate::fs::MountableFs; - - let root = Arc::new(InMemoryFs::new()); - root.mkdir(Path::new("/home/user"), true).await.unwrap(); - - let mounted = Arc::new(InMemoryFs::new()); - mounted.mkdir(Path::new("/a"), false).await.unwrap(); - mounted.mkdir(Path::new("/a/b"), false).await.unwrap(); - mounted - .write_file(Path::new("/a/file_a.txt"), b"a") - .await - .unwrap(); - mounted - .write_file(Path::new("/a/b/file_b.txt"), b"b") - .await - .unwrap(); - - let mountable = MountableFs::new(root); - mountable.mount("/home/user/mnt", mounted.clone()).unwrap(); - - let fs: Arc = Arc::new(mountable); - let mut cwd = PathBuf::from("/home/user"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - let args = vec!["-R".to_string(), "mnt".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - assert!(result.stdout.contains("a"), "Should list directory a"); - assert!(result.stdout.contains("b"), "Should list directory b"); - assert!( - result.stdout.contains("file_a.txt"), - "Should list file_a.txt" - ); - assert!( - result.stdout.contains("file_b.txt"), - "Should list file_b.txt" - ); - } - - // ==================== root directory tests ==================== - - #[tokio::test] - async fn test_ls_root_directory() { - // Test listing the root directory directly - let fs = Arc::new(InMemoryFs::new()); - let mut cwd = PathBuf::from("/home/user"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // ls / should work - let args = vec!["/".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!( - result.exit_code, 0, - "ls / should succeed: {}", - result.stderr - ); - // Root should contain at least tmp, home, dev - assert!(result.stdout.contains("tmp"), "Root should contain tmp"); - assert!(result.stdout.contains("home"), "Root should contain home"); - assert!(result.stdout.contains("dev"), "Root should contain dev"); - } - - #[tokio::test] - async fn test_ls_dot_from_root() { - // Test: when cwd is /, ls . should list root contents - let fs = Arc::new(InMemoryFs::new()); - let mut cwd = PathBuf::from("/"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // ls . with cwd=/ should work - let args = vec![".".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!( - result.exit_code, 0, - "ls . from / should succeed: {}", - result.stderr - ); - assert!(result.stdout.contains("tmp"), "Root should contain tmp"); - assert!(result.stdout.contains("home"), "Root should contain home"); - } - - #[tokio::test] - async fn test_ls_default_from_root() { - // Test: when cwd is /, ls (no args) should list root contents - let fs = Arc::new(InMemoryFs::new()); - let mut cwd = PathBuf::from("/"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // ls with no args and cwd=/ should work - let args: Vec = vec![]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!( - result.exit_code, 0, - "ls from / should succeed: {}", - result.stderr - ); - assert!(result.stdout.contains("tmp"), "Root should contain tmp"); - assert!(result.stdout.contains("home"), "Root should contain home"); - } - - #[tokio::test] - async fn test_ls_root_with_overlay_fs() { - // Test: ls / with OverlayFs should work - use crate::fs::OverlayFs; - - let base = Arc::new(InMemoryFs::new()); - let overlay: Arc = Arc::new(OverlayFs::new(base)); - - let mut cwd = PathBuf::from("/"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // ls / should work with overlay - let args = vec!["/".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: overlay.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!( - result.exit_code, 0, - "ls / with overlay should succeed: {}", - result.stderr - ); - assert!(result.stdout.contains("tmp"), "Root should contain tmp"); - assert!(result.stdout.contains("home"), "Root should contain home"); - } - - #[tokio::test] - async fn test_ls_dot_from_root_with_overlay_fs() { - // Test: cd / && ls . with OverlayFs should work - use crate::fs::OverlayFs; - - let base = Arc::new(InMemoryFs::new()); - let overlay: Arc = Arc::new(OverlayFs::new(base)); - - let mut cwd = PathBuf::from("/"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // ls . from / with overlay - let args = vec![".".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: overlay.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!( - result.exit_code, 0, - "ls . from / with overlay should succeed: {}", - result.stderr - ); - assert!(result.stdout.contains("tmp"), "Root should contain tmp"); - assert!(result.stdout.contains("home"), "Root should contain home"); - } - - #[tokio::test] - async fn test_resolve_path_slash_dot_normalized() { - // Verify that "/." path (from /join(".")) resolves correctly to root - let fs = Arc::new(InMemoryFs::new()); - - // The path "/." should normalize to "/" and exist - assert!(fs.exists(Path::new("/.")).await.unwrap(), "/. should exist"); - assert!(fs.exists(Path::new("/")).await.unwrap(), "/ should exist"); - - // Both should return the same stat info - let stat_root = fs.stat(Path::new("/")).await.unwrap(); - let stat_dot = fs.stat(Path::new("/.")).await.unwrap(); - assert!(stat_root.file_type.is_dir()); - assert!(stat_dot.file_type.is_dir()); - } - - // ==================== negative tests ==================== - - #[tokio::test] - async fn test_ls_nonexistent_path() { - // Negative test: ls on path that doesn't exist should fail - let fs = Arc::new(InMemoryFs::new()); - let mut cwd = PathBuf::from("/home/user"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - let args = vec!["/nonexistent/path".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 2, "ls on nonexistent path should fail"); - assert!( - result.stderr.contains("No such file or directory"), - "Should report file not found" - ); - } - - #[tokio::test] - async fn test_ls_path_traversal_normalized() { - // Positive test: path traversal with .. should be normalized and work - let fs = Arc::new(InMemoryFs::new()); - let mut cwd = PathBuf::from("/home/user"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // /home/user/../user should normalize to /home/user - let args = vec!["../user".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - // /home/user is an empty directory by default, so it should succeed with empty output - assert_eq!( - result.exit_code, 0, - "ls with .. should succeed after normalization: {}", - result.stderr - ); - } - - #[tokio::test] - async fn test_ls_excessive_dotdot_stays_at_root() { - // Positive test: excessive .. should stay at root - let fs = Arc::new(InMemoryFs::new()); - let mut cwd = PathBuf::from("/home/user"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // ../../../../.. from /home/user should normalize to / - let args = vec!["../../../../..".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!( - result.exit_code, 0, - "Excessive .. should normalize to root: {}", - result.stderr - ); - // Should list root contents - assert!(result.stdout.contains("tmp"), "Should list root (tmp)"); - assert!(result.stdout.contains("home"), "Should list root (home)"); - } - - #[tokio::test] - async fn test_ls_dot_in_middle_of_path() { - // Positive test: . in middle of path should be normalized - let fs = Arc::new(InMemoryFs::new()); - let mut cwd = PathBuf::from("/"); - let mut variables = HashMap::new(); - let env = HashMap::new(); - - // /./home/./user/. should normalize to /home/user - let args = vec!["./home/./user/.".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!( - result.exit_code, 0, - "Path with . components should work: {}", - result.stderr - ); - } - - // ==================== glob_match tests ==================== - - // ==================== file size reporting tests ==================== - - #[tokio::test] - async fn test_ls_long_format_shows_correct_file_size() { - // Positive test: file with known content shows correct size - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create file with exactly 13 bytes: "hello world\n" (11 chars + newline from echo) - let content = b"hello world\n"; - fs.write_file(&cwd.join("test.txt"), content).await.unwrap(); - - let args = vec!["-l".to_string(), "test.txt".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - // File size should be 12 bytes (content.len()) - assert!( - result.stdout.contains("12"), - "Expected size 12 in output, got: {}", - result.stdout - ); - } - - #[tokio::test] - async fn test_ls_long_format_empty_file_shows_zero_size() { - // Negative test: empty file shows size 0 - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&cwd.join("empty.txt"), b"").await.unwrap(); - - let args = vec!["-l".to_string(), "empty.txt".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - // Empty file should show size 0 - // Format is: -rw-r--r-- 0 YYYY-MM-DD HH:MM empty.txt - assert!( - result.stdout.contains(" 0"), - "Expected size 0 in output, got: {}", - result.stdout - ); - } - - #[tokio::test] - async fn test_ls_long_format_directory_shows_zero_size() { - // Negative test: directory shows size 0 - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); - - let args = vec!["-l".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - // Directory line should contain size 0 - // Format is: drwxr-xr-x 0 YYYY-MM-DD HH:MM subdir - let lines: Vec<&str> = result.stdout.lines().collect(); - let subdir_line = lines.iter().find(|l| l.contains("subdir")).unwrap(); - assert!( - subdir_line.contains(" 0"), - "Expected directory size 0, got: {}", - subdir_line - ); - } - - #[tokio::test] - async fn test_ls_long_format_multiple_files_correct_sizes() { - // Positive test: multiple files show their respective sizes - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create files with different sizes - fs.write_file(&cwd.join("small.txt"), b"hi").await.unwrap(); // 2 bytes - fs.write_file(&cwd.join("medium.txt"), b"hello world") - .await - .unwrap(); // 11 bytes - fs.write_file( - &cwd.join("large.txt"), - b"this is a longer content string for testing", - ) - .await - .unwrap(); // 43 bytes - - let args = vec!["-l".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let result = Ls.execute(ctx).await.unwrap(); - assert_eq!(result.exit_code, 0); - - let lines: Vec<&str> = result.stdout.lines().collect(); - - // Check small.txt has size 2 - let small_line = lines.iter().find(|l| l.contains("small.txt")).unwrap(); - assert!( - small_line.contains(" 2"), - "Expected small.txt size 2, got: {}", - small_line - ); - - // Check medium.txt has size 11 - let medium_line = lines.iter().find(|l| l.contains("medium.txt")).unwrap(); - assert!( - medium_line.contains(" 11"), - "Expected medium.txt size 11, got: {}", - medium_line - ); - - // Check large.txt has size 43 - let large_line = lines.iter().find(|l| l.contains("large.txt")).unwrap(); - assert!( - large_line.contains(" 43"), - "Expected large.txt size 43, got: {}", - large_line - ); - } - - #[test] - fn test_glob_match_star() { - assert!(glob_match("file.txt", "*.txt")); - assert!(glob_match("test.txt", "*.txt")); - assert!(!glob_match("file.md", "*.txt")); - } - - #[test] - fn test_glob_match_question() { - assert!(glob_match("ab", "a?")); - assert!(glob_match("ac", "a?")); - assert!(!glob_match("abc", "a?")); - } - - #[test] - fn test_glob_match_exact() { - assert!(glob_match("test", "test")); - assert!(!glob_match("test", "other")); - } - - #[test] - fn test_glob_match_star_middle() { - assert!(glob_match("test.backup.txt", "test*.txt")); - assert!(glob_match("test.txt", "test*.txt")); - } - - // ==================== parse_find_args tests ==================== - - #[test] - fn test_parse_find_args_defaults_to_dot() { - let args: Vec = vec![]; - let (paths, opts) = parse_find_args(&args).unwrap(); - assert_eq!(paths, vec!["."]); - assert!(opts.exec_args.is_empty()); - assert!(!opts.exec_batch); - } - - #[test] - fn test_parse_find_args_exec_per_file() { - let args: Vec = vec![ - ".".into(), - "-name".into(), - "*.txt".into(), - "-exec".into(), - "cat".into(), - "{}".into(), - ";".into(), - ]; - let (paths, opts) = parse_find_args(&args).unwrap(); - assert_eq!(paths, vec!["."]); - assert_eq!(opts.name_pattern.as_deref(), Some("*.txt")); - assert_eq!(opts.exec_args, vec!["cat", "{}"]); - assert!(!opts.exec_batch); - } - - #[test] - fn test_parse_find_args_exec_batch() { - let args: Vec = vec!["-exec".into(), "rm".into(), "{}".into(), "+".into()]; - let (_paths, opts) = parse_find_args(&args).unwrap(); - assert_eq!(opts.exec_args, vec!["rm", "{}"]); - assert!(opts.exec_batch); - } - - #[test] - fn test_parse_find_args_error_missing_name() { - let args: Vec = vec!["-name".into()]; - assert!(parse_find_args(&args).is_err()); - } - - #[test] - fn test_parse_find_args_error_unknown_predicate() { - let args: Vec = vec!["-bogus".into()]; - assert!(parse_find_args(&args).is_err()); - } - - // ==================== build_find_exec_commands tests ==================== - - #[test] - fn test_build_find_exec_commands_per_file() { - let exec_args = vec!["echo".to_string(), "{}".to_string()]; - let paths = vec!["a.txt".to_string(), "b.txt".to_string()]; - let cmds = build_find_exec_commands(&exec_args, &paths, false); - assert_eq!(cmds.len(), 2); - assert_eq!(cmds[0].name, "echo"); - assert_eq!(cmds[0].args, vec!["a.txt"]); - assert_eq!(cmds[1].name, "echo"); - assert_eq!(cmds[1].args, vec!["b.txt"]); - } - - #[test] - fn test_build_find_exec_commands_batch() { - let exec_args = vec!["rm".to_string(), "{}".to_string()]; - let paths = vec!["a.txt".to_string(), "b.txt".to_string()]; - let cmds = build_find_exec_commands(&exec_args, &paths, true); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].name, "rm"); - assert_eq!(cmds[0].args, vec!["a.txt", "b.txt"]); - } - - #[test] - fn test_build_find_exec_commands_empty_paths() { - let exec_args = vec!["echo".to_string(), "{}".to_string()]; - let cmds = build_find_exec_commands(&exec_args, &[], false); - assert!(cmds.is_empty()); - } - - #[test] - fn test_build_find_exec_commands_empty_exec() { - let paths = vec!["a.txt".to_string()]; - let cmds = build_find_exec_commands(&[], &paths, false); - assert!(cmds.is_empty()); - } - - #[test] - fn test_build_find_exec_commands_multiple_placeholders() { - let exec_args = vec!["cp".to_string(), "{}".to_string(), "{}.bak".to_string()]; - let paths = vec!["a.txt".to_string()]; - let cmds = build_find_exec_commands(&exec_args, &paths, false); - assert_eq!(cmds.len(), 1); - assert_eq!(cmds[0].name, "cp"); - assert_eq!(cmds[0].args, vec!["a.txt", "a.txt.bak"]); - } - - // ==================== find execution_plan tests ==================== - - #[tokio::test] - async fn test_find_plan_no_exec_returns_none() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec!["-name".to_string(), "*.txt".to_string()]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let plan = Find.execution_plan(&ctx).await.unwrap(); - assert!(plan.is_none()); - } - - #[tokio::test] - async fn test_find_plan_exec_with_matches() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - // Create test files - fs.write_file(&PathBuf::from("/home/user/a.txt"), b"hello") - .await - .unwrap(); - fs.write_file(&PathBuf::from("/home/user/b.txt"), b"world") - .await - .unwrap(); - - let args = vec![ - ".".to_string(), - "-name".to_string(), - "*.txt".to_string(), - "-exec".to_string(), - "cat".to_string(), - "{}".to_string(), - ";".to_string(), - ]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let plan = Find.execution_plan(&ctx).await.unwrap(); - match plan { - Some(ExecutionPlan::Batch { commands }) => { - assert_eq!(commands.len(), 2); - assert_eq!(commands[0].name, "cat"); - // Each command should have a single arg (the found path) - assert_eq!(commands[0].args.len(), 1); - assert_eq!(commands[1].args.len(), 1); - } - _ => panic!("expected Batch plan"), - } - } - - #[tokio::test] - async fn test_find_plan_exec_no_matches_returns_none() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - let args = vec![ - ".".to_string(), - "-name".to_string(), - "*.xyz".to_string(), - "-exec".to_string(), - "echo".to_string(), - "{}".to_string(), - ";".to_string(), - ]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let plan = Find.execution_plan(&ctx).await.unwrap(); - assert!(plan.is_none()); - } - - #[tokio::test] - async fn test_find_plan_exec_with_missing_path_returns_status_plan() { - let (fs, mut cwd, mut variables) = create_test_ctx().await; - let env = HashMap::new(); - - fs.write_file(&PathBuf::from("/home/user/a.txt"), b"hello") - .await - .unwrap(); - - let args = vec![ - "/home/user".to_string(), - "/home/missing".to_string(), - "-name".to_string(), - "*.txt".to_string(), - "-exec".to_string(), - "echo".to_string(), - "{}".to_string(), - ";".to_string(), - ]; - let ctx = Context { - args: &args, - env: &env, - variables: &mut variables, - cwd: &mut cwd, - fs: fs.clone(), - stdin: None, - #[cfg(feature = "http_client")] - http_client: None, - #[cfg(feature = "git")] - git_client: None, - #[cfg(feature = "ssh")] - ssh_client: None, - shell: None, - }; - - let plan = Find.execution_plan(&ctx).await.unwrap(); - match plan { - Some(ExecutionPlan::BatchWithStatus { - commands, - stderr_prefix, - force_error_exit, - }) => { - assert_eq!(commands.len(), 1); - assert!(stderr_prefix.contains("No such file or directory")); - assert!(force_error_exit); - } - _ => panic!("expected BatchWithStatus plan"), - } - } -} diff --git a/crates/bashkit/src/builtins/ls/find.rs b/crates/bashkit/src/builtins/ls/find.rs new file mode 100644 index 000000000..d3efcf5d5 --- /dev/null +++ b/crates/bashkit/src/builtins/ls/find.rs @@ -0,0 +1,596 @@ +//! find builtin - search for files. + +#![allow(clippy::unwrap_used)] + +use async_trait::async_trait; +use std::path::Path; + +use super::glob_match; +use crate::builtins::{Builtin, Context, ExecutionPlan, SubCommand, resolve_path}; +use crate::error::Result; +use crate::interpreter::{ControlFlow, ExecResult}; + +/// Options for find command +pub(super) struct FindOptions { + pub(super) name_pattern: Option, + /// -path pattern: match against the full display path + pub(super) path_pattern: Option, + pub(super) type_filter: Option, + pub(super) max_depth: Option, + pub(super) min_depth: Option, + pub(super) printf_format: Option, + /// -exec/-execdir command template (args before \; or +) + pub(super) exec_args: Vec, + /// true if -exec uses + (batch mode), false for \; (per-file mode) + pub(super) exec_batch: bool, + /// Negate the -name predicate + pub(super) negate_name: bool, + /// Negate the -path predicate + pub(super) negate_path: bool, +} + +/// The find builtin - search for files. +/// +/// Usage: find [PATH...] [-name PATTERN] [-type TYPE] [-maxdepth N] [-mindepth N] [-printf FMT] [-exec CMD {} \;] +/// +/// Options: +/// -name PATTERN Match filename against PATTERN (supports * and ?) +/// -type TYPE Match file type: f (file), d (directory), l (link) +/// -maxdepth N Descend at most N levels +/// -mindepth N Do not apply tests at levels less than N +/// -print Print matching paths (default) +/// -printf FMT Print using format string (%f %p %P %s %m %M %y %d %T@) +/// -exec CMD {} \; Execute CMD for each match ({} = path) +/// -exec CMD {} + Execute CMD once with all matches +pub struct Find; + +/// Parse find arguments into search paths and options. +/// Returns (paths, opts) or an error ExecResult. +#[allow(clippy::result_large_err)] +pub(super) fn parse_find_args( + args: &[String], +) -> std::result::Result<(Vec, FindOptions), ExecResult> { + let mut paths: Vec = Vec::new(); + let mut opts = FindOptions { + name_pattern: None, + path_pattern: None, + type_filter: None, + max_depth: None, + min_depth: None, + printf_format: None, + exec_args: Vec::new(), + exec_batch: false, + negate_name: false, + negate_path: false, + }; + let mut negate_next = false; + + let mut i = 0; + while i < args.len() { + let arg = &args[i]; + match arg.as_str() { + "-name" => { + i += 1; + if i >= args.len() { + return Err(ExecResult::err( + "find: missing argument to '-name'\n".to_string(), + 1, + )); + } + opts.name_pattern = Some(args[i].clone()); + if negate_next { + opts.negate_name = true; + negate_next = false; + } + } + "-path" => { + i += 1; + if i >= args.len() { + return Err(ExecResult::err( + "find: missing argument to '-path'\n".to_string(), + 1, + )); + } + opts.path_pattern = Some(args[i].clone()); + if negate_next { + opts.negate_path = true; + negate_next = false; + } + } + "-type" => { + i += 1; + if i >= args.len() { + return Err(ExecResult::err( + "find: missing argument to '-type'\n".to_string(), + 1, + )); + } + let t = &args[i]; + match t.as_str() { + "f" | "d" | "l" => opts.type_filter = Some(t.chars().next().unwrap()), + _ => { + return Err(ExecResult::err(format!("find: unknown type '{}'\n", t), 1)); + } + } + } + "-maxdepth" => { + i += 1; + if i >= args.len() { + return Err(ExecResult::err( + "find: missing argument to '-maxdepth'\n".to_string(), + 1, + )); + } + match args[i].parse::() { + Ok(n) => opts.max_depth = Some(n), + Err(_) => { + return Err(ExecResult::err( + format!("find: invalid maxdepth value '{}'\n", args[i]), + 1, + )); + } + } + } + "-mindepth" => { + i += 1; + if i >= args.len() { + return Err(ExecResult::err( + "find: missing argument to '-mindepth'\n".to_string(), + 1, + )); + } + match args[i].parse::() { + Ok(n) => opts.min_depth = Some(n), + Err(_) => { + return Err(ExecResult::err( + format!("find: invalid mindepth value '{}'\n", args[i]), + 1, + )); + } + } + } + "-print" | "-print0" => { + // Default action, ignore + } + "-printf" => { + i += 1; + if i >= args.len() { + return Err(ExecResult::err( + "find: missing argument to '-printf'\n".to_string(), + 1, + )); + } + opts.printf_format = Some(args[i].clone()); + } + "-exec" | "-execdir" => { + i += 1; + while i < args.len() { + let a = &args[i]; + if a == ";" || a == "\\;" { + break; + } + if a == "+" { + opts.exec_batch = true; + break; + } + opts.exec_args.push(a.clone()); + i += 1; + } + } + "-not" | "!" => { + negate_next = true; + } + s if s.starts_with('-') => { + return Err(ExecResult::err( + format!("find: unknown predicate '{}'\n", s), + 1, + )); + } + _ => { + paths.push(arg.clone()); + } + } + i += 1; + } + + if paths.is_empty() { + paths.push(".".to_string()); + } + + Ok((paths, opts)) +} + +pub(super) struct FindPlanData { + pub(super) matched_paths: Vec, + pub(super) errors: String, + pub(super) had_error: bool, +} + +/// Collect matched paths and path/traversal errors for find execution planning. +async fn collect_find_plan_data( + ctx: &Context<'_>, + search_paths: &[String], + opts: &FindOptions, +) -> Result { + let mut matched: Vec = Vec::new(); + let mut errors = String::new(); + let mut had_error = false; + // Reuse find_recursive but with a temporary output buffer + let temp_opts = FindOptions { + name_pattern: opts.name_pattern.clone(), + path_pattern: opts.path_pattern.clone(), + type_filter: opts.type_filter, + max_depth: opts.max_depth, + min_depth: opts.min_depth, + printf_format: None, // Don't format, just collect paths + exec_args: Vec::new(), + exec_batch: false, + negate_name: opts.negate_name, + negate_path: opts.negate_path, + }; + let mut output = String::new(); + for path_str in search_paths { + let path = resolve_path(ctx.cwd, path_str); + if !ctx.fs.exists(&path).await.unwrap_or(false) { + errors.push_str(&format!( + "find: '{}': No such file or directory\n", + path_str + )); + had_error = true; + continue; + } + if let Err(e) = find_recursive(ctx, &path, path_str, &temp_opts, 0, &mut output).await { + errors.push_str(&format!("find: '{}': {}\n", path_str, e)); + had_error = true; + } + } + // Parse the output back into paths (each line is a path) + for line in output.lines() { + if !line.is_empty() { + matched.push(line.to_string()); + } + } + Ok(FindPlanData { + matched_paths: matched, + errors, + had_error, + }) +} + +/// Build exec sub-commands from matched paths and exec_args template. +pub(super) fn build_find_exec_commands( + exec_args: &[String], + matched_paths: &[String], + batch: bool, +) -> Vec { + if exec_args.is_empty() || matched_paths.is_empty() { + return Vec::new(); + } + + if batch { + // Batch mode: -exec cmd {} + + // Replace {} with all paths at once + let cmd_args: Vec = exec_args + .iter() + .flat_map(|arg| { + if arg == "{}" { + matched_paths.to_vec() + } else { + vec![arg.clone()] + } + }) + .collect(); + + if cmd_args.is_empty() { + return Vec::new(); + } + + vec![SubCommand { + name: cmd_args[0].clone(), + args: cmd_args[1..].to_vec(), + stdin: None, + }] + } else { + // Per-file mode: -exec cmd {} \; + matched_paths + .iter() + .map(|found_path| { + let cmd_args: Vec = exec_args + .iter() + .map(|arg| arg.replace("{}", found_path)) + .collect(); + + SubCommand { + name: cmd_args[0].clone(), + args: cmd_args[1..].to_vec(), + stdin: None, + } + }) + .collect() + } +} + +#[async_trait] +impl Builtin for Find { + async fn execute(&self, ctx: Context<'_>) -> Result { + if let Some(r) = crate::builtins::check_help_version( + ctx.args, + "Usage: find [PATH...] [EXPRESSION]\nSearch for files in a directory hierarchy.\n\n -name PATTERN\tmatch filename against PATTERN (supports * and ?)\n -path PATTERN\tmatch full path against PATTERN\n -type TYPE\tmatch file type: f (file), d (directory), l (link)\n -maxdepth N\tdescend at most N levels\n -mindepth N\tdo not apply tests at levels less than N\n -print\t\tprint matching paths (default)\n -printf FMT\tprint using format string (%f %p %P %s %m %M %y %d %T@)\n -exec CMD {} \\;\texecute CMD for each match ({} = path)\n -exec CMD {} +\texecute CMD once with all matches\n -not, !\t\tnegate the next predicate\n --help\tdisplay this help and exit\n --version\toutput version information and exit\n", + Some("find (bashkit) 0.1"), + ) { + return Ok(r); + } + + let (search_paths, opts) = match parse_find_args(ctx.args) { + Ok(v) => v, + Err(e) => return Ok(e), + }; + + let mut output = String::new(); + let mut errors = String::new(); + let mut had_error = false; + + for path_str in &search_paths { + let path = resolve_path(ctx.cwd, path_str); + if !ctx.fs.exists(&path).await.unwrap_or(false) { + errors.push_str(&format!( + "find: '{}': No such file or directory\n", + path_str + )); + had_error = true; + continue; + } + + if let Err(e) = find_recursive(&ctx, &path, path_str, &opts, 0, &mut output).await { + errors.push_str(&format!("find: '{}': {}\n", path_str, e)); + had_error = true; + } + } + + Ok(ExecResult { + stdout: output, + stderr: errors, + exit_code: if had_error { 1 } else { 0 }, + control_flow: ControlFlow::None, + ..Default::default() + }) + } + + async fn execution_plan(&self, ctx: &Context<'_>) -> Result> { + let (search_paths, opts) = match parse_find_args(ctx.args) { + Ok(v) => v, + Err(_) => return Ok(None), // Let execute() handle errors + }; + + // Only return a plan when -exec is present + if opts.exec_args.is_empty() { + return Ok(None); + } + + // Collect matched paths plus any path/traversal errors. + let plan_data = collect_find_plan_data(ctx, &search_paths, &opts).await?; + if plan_data.matched_paths.is_empty() && !plan_data.had_error { + return Ok(None); + } + + let commands = + build_find_exec_commands(&opts.exec_args, &plan_data.matched_paths, opts.exec_batch); + if commands.is_empty() && !plan_data.had_error { + return Ok(None); + } + + if plan_data.had_error { + return Ok(Some(ExecutionPlan::BatchWithStatus { + commands, + stderr_prefix: plan_data.errors, + force_error_exit: true, + })); + } + + Ok(Some(ExecutionPlan::Batch { commands })) + } +} + +fn find_recursive<'a>( + ctx: &'a Context<'_>, + path: &'a Path, + display_path: &'a str, + opts: &'a FindOptions, + current_depth: usize, + output: &'a mut String, +) -> std::pin::Pin> + Send + 'a>> { + Box::pin(async move { + // Check if this entry matches + let metadata = ctx.fs.stat(path).await?; + let entry_name = Path::new(display_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| display_path.to_string()); + + // Check type filter + let type_matches = match opts.type_filter { + Some('f') => metadata.file_type.is_file(), + Some('d') => metadata.file_type.is_dir(), + Some('l') => metadata.file_type.is_symlink(), + _ => true, + }; + + // Check name pattern + let name_matches = match &opts.name_pattern { + Some(pattern) => { + let m = glob_match(&entry_name, pattern); + if opts.negate_name { !m } else { m } + } + None => true, + }; + + // Check path pattern + let path_matches = match &opts.path_pattern { + Some(pattern) => { + let m = glob_match(display_path, pattern); + if opts.negate_path { !m } else { m } + } + None => true, + }; + + // Check min depth before outputting + let above_min_depth = match opts.min_depth { + Some(min) => current_depth >= min, + None => true, + }; + + // Output if matches (or if no filters, show everything) + if type_matches && name_matches && path_matches && above_min_depth { + if let Some(ref fmt) = opts.printf_format { + output.push_str(&find_printf_format(fmt, display_path, &metadata)); + } else { + output.push_str(display_path); + output.push('\n'); + } + } + + // Recurse into directories + if metadata.file_type.is_dir() { + // Check max depth + if let Some(max) = opts.max_depth + && current_depth >= max + { + return Ok(()); + } + + let entries = ctx.fs.read_dir(path).await?; + let mut sorted_entries = entries; + sorted_entries.sort_by(|a, b| a.name.cmp(&b.name)); + + for entry in sorted_entries { + let child_path = path.join(&entry.name); + let child_display = if display_path == "." { + format!("./{}", entry.name) + } else { + format!("{}/{}", display_path, entry.name) + }; + + find_recursive( + ctx, + &child_path, + &child_display, + opts, + current_depth + 1, + output, + ) + .await?; + } + } + + Ok(()) + }) +} + +/// Format a path using find's -printf format string. +fn find_printf_format(fmt: &str, display_path: &str, metadata: &crate::fs::Metadata) -> String { + let mut out = String::new(); + let chars: Vec = fmt.chars().collect(); + let mut i = 0; + while i < chars.len() { + match chars[i] { + '\\' => { + i += 1; + if i < chars.len() { + match chars[i] { + 'n' => out.push('\n'), + 't' => out.push('\t'), + '0' => out.push('\0'), + '\\' => out.push('\\'), + c => { + out.push('\\'); + out.push(c); + } + } + } + } + '%' => { + i += 1; + if i >= chars.len() { + out.push('%'); + continue; + } + match chars[i] { + 'f' => { + let name = std::path::Path::new(display_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()) + .unwrap_or_else(|| display_path.to_string()); + out.push_str(&name); + } + 'p' => out.push_str(display_path), + 'P' => { + // In builtin context, display_path is already relative + let rel = display_path.strip_prefix("./").unwrap_or(display_path); + out.push_str(rel); + } + 's' => out.push_str(&metadata.size.to_string()), + 'm' => out.push_str(&format!("{:o}", metadata.mode & 0o7777)), + 'M' => { + let type_ch = if metadata.file_type.is_dir() { + 'd' + } else if metadata.file_type.is_symlink() { + 'l' + } else { + '-' + }; + out.push(type_ch); + for shift in [6, 3, 0] { + let bits = (metadata.mode >> shift) & 7; + out.push(if bits & 4 != 0 { 'r' } else { '-' }); + out.push(if bits & 2 != 0 { 'w' } else { '-' }); + out.push(if bits & 1 != 0 { 'x' } else { '-' }); + } + } + 'y' => { + let ch = if metadata.file_type.is_dir() { + 'd' + } else if metadata.file_type.is_symlink() { + 'l' + } else { + 'f' + }; + out.push(ch); + } + 'd' => { + // Approximate depth from display_path + let base = display_path.strip_prefix("./").unwrap_or(display_path); + let depth = if base == "." || base.is_empty() { + 0 + } else { + base.matches('/').count() + 1 + }; + out.push_str(&depth.to_string()); + } + 'T' => { + i += 1; + if i < chars.len() && chars[i] == '@' { + let secs = metadata + .modified + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|d| d.as_secs()) + .unwrap_or(0); + out.push_str(&secs.to_string()); + } else { + out.push_str("%T"); + continue; + } + } + '%' => out.push('%'), + c => { + out.push('%'); + out.push(c); + } + } + } + c => out.push(c), + } + i += 1; + } + out +} diff --git a/crates/bashkit/src/builtins/ls/glob.rs b/crates/bashkit/src/builtins/ls/glob.rs new file mode 100644 index 000000000..cd5f882fb --- /dev/null +++ b/crates/bashkit/src/builtins/ls/glob.rs @@ -0,0 +1,44 @@ +//! Glob pattern matching for `find -name` and `ls`. + +/// Simple glob pattern matching for find -name. +pub(crate) fn glob_match(value: &str, pattern: &str) -> bool { + let mut value_chars = value.chars().peekable(); + let mut pattern_chars = pattern.chars().peekable(); + + loop { + match (pattern_chars.peek(), value_chars.peek()) { + (None, None) => return true, + (None, Some(_)) => return false, + (Some('*'), _) => { + pattern_chars.next(); + if pattern_chars.peek().is_none() { + return true; + } + while value_chars.peek().is_some() { + let remaining_value: String = value_chars.clone().collect(); + let remaining_pattern: String = pattern_chars.clone().collect(); + if glob_match(&remaining_value, &remaining_pattern) { + return true; + } + value_chars.next(); + } + let remaining_pattern: String = pattern_chars.collect(); + return glob_match("", &remaining_pattern); + } + (Some('?'), Some(_)) => { + pattern_chars.next(); + value_chars.next(); + } + (Some('?'), None) => return false, + (Some(p), Some(v)) => { + if *p == *v { + pattern_chars.next(); + value_chars.next(); + } else { + return false; + } + } + (Some(_), None) => return false, + } + } +} diff --git a/crates/bashkit/src/builtins/ls/list.rs b/crates/bashkit/src/builtins/ls/list.rs new file mode 100644 index 000000000..f06804b21 --- /dev/null +++ b/crates/bashkit/src/builtins/ls/list.rs @@ -0,0 +1,480 @@ +//! ls builtin - list directory contents. + +// Uses unwrap() for validated single-char strings. +#![allow(clippy::unwrap_used)] + +use async_trait::async_trait; +use std::ffi::OsString; +use std::path::Path; + +use crate::builtins::clap_env::apply_env_defaults; +use crate::builtins::generated::ls_args::{LS_ENV_DEFAULTS, ls_command}; +use crate::builtins::{Builtin, Context, resolve_path}; +use crate::error::Result; +use crate::fs::FileType; +use crate::interpreter::ExecResult; + +/// Argument IDs from the generated `ls_command()` that bashkit currently +/// implements. Anything else clap accepts is reported as "not yet +/// implemented" so scripts get a deterministic error instead of silently +/// missing behavior. See spec `coreutils-args-port.md`. +const LS_SUPPORTED_IDS: &[&str] = &[ + // The 8 short flags the original bashkit ls accepted. + "long", // -l + "all", // -a + "human-readable", // -h + "1", // -1 + "recursive", // -R + "t", // -t + "classify", // -F / --classify + "C", // -C + // Non-flag positional + always-supported infrastructure. + "paths", + "help", +]; + +/// Options for ls command +pub(super) struct LsOptions { + pub(super) long: bool, + pub(super) all: bool, + pub(super) human: bool, + pub(super) one_per_line: bool, + pub(super) recursive: bool, + pub(super) sort_by_time: bool, + pub(super) classify: bool, + pub(super) columns: bool, +} + +/// The ls builtin - list directory contents. +/// +/// Usage: ls [-l] [-a] [-h] [-1] [-R] [-t] [-F] [-C] [PATH...] +/// +/// Options: +/// -l Use long listing format +/// -a Show hidden files (starting with .) +/// -h Human-readable sizes (with -l) +/// -1 One entry per line +/// -R List subdirectories recursively +/// -t Sort by modification time, newest first +/// -F Append indicator (/ for dirs, * for executables, @ for symlinks, | for FIFOs) +/// -C List entries in columns (multi-column output) +pub struct Ls; + +#[async_trait] +impl Builtin for Ls { + async fn execute(&self, ctx: Context<'_>) -> Result { + // clap expects argv[0] = program name; bashkit's ctx.args excludes it. + let argv: Vec = std::iter::once(OsString::from("ls")) + .chain(ctx.args.iter().map(OsString::from)) + .collect(); + // Layer bashkit's virtual env over argv before clap parses it. + // Mirrors uutils' `Arg::env(...)` precedence (argv > env > default) + // but sources values from `ctx.env` instead of the host process — + // see TM-INF-024 and `builtins/clap_env.rs`. + let argv = apply_env_defaults(argv, LS_ENV_DEFAULTS, ctx.env); + + // GNU coreutils' help layout opens with the usage line; clap's + // default template leads with the `about`. uutils handles this via + // uucore's `localized_help_template`, which we drop during codegen + // because it pulls in Fluent. Re-apply a GNU-equivalent template. + let cmd = ls_command().help_template("Usage: {usage}\n{about}\n\n{all-args}\n"); + let matches = match cmd.try_get_matches_from(argv) { + Ok(m) => m, + Err(e) => { + let kind = e.kind(); + let rendered = e.render().to_string(); + if matches!( + kind, + clap::error::ErrorKind::DisplayHelp | clap::error::ErrorKind::DisplayVersion + ) { + return Ok(ExecResult::ok(rendered)); + } + return Ok(ExecResult::err(rendered, 2)); + } + }; + + // Reject any uu_ls flag bashkit hasn't implemented yet. Reporting + // them up front beats silently parsing them and producing GNU- + // incompatible output. Same pattern as `tac -b/-r/-s`. + let unsupported: Vec = matches + .ids() + .filter(|id| { + let name = id.as_str(); + !LS_SUPPORTED_IDS.contains(&name) + && matches.value_source(name) != Some(clap::parser::ValueSource::DefaultValue) + }) + .map(|id| id.as_str().to_string()) + .collect(); + if !unsupported.is_empty() { + return Ok(ExecResult::err( + format!( + "ls: option(s) not yet implemented in bashkit: {}\n", + unsupported.join(", ") + ), + 2, + )); + } + + // -F/--classify takes an optional value (`always`/`auto`/`never`). + // The bashkit implementation only knows "classify or not"; treat + // an explicit `never` as off and anything else (including the + // default "always") as on. + let classify = matches.contains_id("classify") + && matches + .get_one::("classify") + .map(|v| v != "never") + .unwrap_or(true); + + let opts = LsOptions { + long: matches.get_flag("long"), + all: matches.get_flag("all"), + human: matches.get_flag("human-readable"), + one_per_line: matches.get_flag("1"), + recursive: matches.get_flag("recursive"), + sort_by_time: matches.get_flag("t"), + classify, + columns: matches.get_flag("C"), + }; + + // PATHS holds OsString values; convert to owned strings for the + // existing rendering loop. + let paths_owned: Vec = matches + .get_many::("paths") + .map(|vs| vs.map(|v| v.to_string_lossy().into_owned()).collect()) + .unwrap_or_default(); + let mut paths: Vec<&str> = paths_owned.iter().map(String::as_str).collect(); + + // Default to current directory + if paths.is_empty() { + paths.push("."); + } + + let mut output = String::new(); + let multiple_paths = paths.len() > 1 || opts.recursive; + + // Separate file and directory arguments (like real ls) + let mut file_args: Vec<(&str, crate::fs::Metadata)> = Vec::new(); + let mut dir_args: Vec<(usize, &str, std::path::PathBuf)> = Vec::new(); + + for (i, path_str) in paths.iter().enumerate() { + let path = resolve_path(ctx.cwd, path_str); + + // Check if path exists + if !ctx.fs.exists(&path).await.unwrap_or(false) { + return Ok(ExecResult::err( + format!( + "ls: cannot access '{}': No such file or directory\n", + path_str + ), + 2, + )); + } + + let metadata = ctx.fs.stat(&path).await?; + + if metadata.file_type.is_file() { + file_args.push((path_str, metadata)); + } else { + dir_args.push((i, path_str, path)); + } + } + + // Sort file arguments by time if -t, preserving original paths + if opts.sort_by_time { + file_args.sort_by_key(|entry| std::cmp::Reverse(entry.1.modified)); + } + + // Output file arguments first (preserving path as given by user) + if opts.long { + for (path_str, metadata) in &file_args { + let mut entry = format_long_entry(path_str, metadata, opts.human); + if opts.classify { + // Insert suffix before the trailing newline + let suffix = classify_suffix(metadata); + if !suffix.is_empty() { + entry.insert_str(entry.len() - 1, suffix); + } + } + output.push_str(&entry); + } + } else if !file_args.is_empty() { + let names: Vec = file_args + .iter() + .map(|(path_str, metadata)| { + let mut name = (*path_str).to_string(); + if opts.classify { + name.push_str(classify_suffix(metadata)); + } + name + }) + .collect(); + if opts.columns && !opts.one_per_line { + output.push_str(&format_columns(&names, 80)); + } else { + for name in &names { + output.push_str(name); + output.push('\n'); + } + } + } + + // Then output directory listings + for (i, path_str, path) in &dir_args { + if let Err(e) = list_directory( + &ctx, + path, + path_str, + &mut output, + &opts, + multiple_paths, + *i > 0 || !file_args.is_empty(), + ) + .await + { + return Ok(ExecResult::err(format!("ls: {}\n", e), 2)); + } + } + + Ok(ExecResult::ok(output)) + } +} + +async fn list_directory( + ctx: &Context<'_>, + path: &Path, + display_path: &str, + output: &mut String, + opts: &LsOptions, + show_header: bool, + add_newline: bool, +) -> std::result::Result<(), String> { + if add_newline { + output.push('\n'); + } + + if show_header { + output.push_str(&format!("{}:\n", display_path)); + } + + let entries = ctx + .fs + .read_dir(path) + .await + .map_err(|e| format!("cannot open directory '{}': {}", display_path, e))?; + + // Sort entries + let mut sorted_entries = entries; + if opts.sort_by_time { + // Sort by modification time, newest first + sorted_entries.sort_by_key(|entry| std::cmp::Reverse(entry.metadata.modified)); + } else { + // Sort alphabetically + sorted_entries.sort_by(|a, b| a.name.cmp(&b.name)); + } + + // Filter hidden files unless -a + let filtered: Vec<_> = sorted_entries + .iter() + .filter(|e| opts.all || !e.name.starts_with('.')) + .collect(); + + // Collect subdirectories for recursive listing + let mut subdirs: Vec<(std::path::PathBuf, String)> = Vec::new(); + + if opts.long { + for entry in &filtered { + let mut line = format_long_entry(&entry.name, &entry.metadata, opts.human); + if opts.classify { + let suffix = classify_suffix(&entry.metadata); + if !suffix.is_empty() { + line.insert_str(line.len() - 1, suffix); + } + } + output.push_str(&line); + if opts.recursive && entry.metadata.file_type.is_dir() { + subdirs.push(( + path.join(&entry.name), + format!("{}/{}", display_path, entry.name), + )); + } + } + } else { + // Collect entry names for potential column formatting + let mut names: Vec = Vec::new(); + for entry in &filtered { + let mut name = entry.name.clone(); + if opts.classify { + name.push_str(classify_suffix(&entry.metadata)); + } + names.push(name); + if opts.recursive && entry.metadata.file_type.is_dir() { + subdirs.push(( + path.join(&entry.name), + format!("{}/{}", display_path, entry.name), + )); + } + } + + // Precedence: -l > -1 > -C > default (one-per-line) + if opts.columns && !opts.one_per_line { + output.push_str(&format_columns(&names, 80)); + } else { + for name in &names { + output.push_str(name); + output.push('\n'); + } + } + } + + // Recursive listing + if opts.recursive { + for (subpath, display) in subdirs { + // Box the future to avoid infinite recursion type size + Box::pin(list_directory( + ctx, &subpath, &display, output, opts, true, true, + )) + .await?; + } + } + + Ok(()) +} + +/// Return the classify indicator character for a file type. +/// `/` for directories, `*` for executables, `@` for symlinks, `|` for FIFOs. +pub(super) fn classify_suffix(metadata: &crate::fs::Metadata) -> &'static str { + match metadata.file_type { + FileType::Directory => "/", + FileType::Symlink => "@", + FileType::Fifo => "|", + FileType::File => { + // Executable if any execute bit is set + if metadata.mode & 0o111 != 0 { "*" } else { "" } + } + } +} + +/// Format entries in column-major order, like `ls -C`. +/// Uses a fixed terminal width (80) since VFS has no real terminal. +/// Per-column widths match GNU coreutils behavior. +pub(super) fn format_columns(entries: &[String], terminal_width: usize) -> String { + if entries.is_empty() { + return String::new(); + } + + // Try fitting as many columns as possible, starting from the maximum + let max_width = entries.iter().map(|e| e.len()).max().unwrap_or(0); + let max_possible_cols = (terminal_width / (max_width.min(1) + 2)).max(1); + + let mut num_cols = 1; + let mut col_widths: Vec = vec![0]; + let mut num_rows = entries.len(); + + // Try increasing column counts to find the best fit + for try_cols in 2..=max_possible_cols.min(entries.len()) { + let try_rows = entries.len().div_ceil(try_cols); + // Calculate per-column widths (max entry width in each column) + let mut widths = vec![0usize; try_cols]; + for (i, entry) in entries.iter().enumerate() { + let col = i / try_rows; + if col < try_cols { + widths[col] = widths[col].max(entry.len()); + } + } + // Total width: each column except last gets 2-space padding + let total: usize = widths.iter().sum::() + (try_cols - 1) * 2; + if total <= terminal_width { + num_cols = try_cols; + col_widths = widths; + num_rows = try_rows; + } + } + + let mut output = String::new(); + for row in 0..num_rows { + for (col, col_w) in col_widths.iter().enumerate() { + // Column-major order: fill columns top-to-bottom, left-to-right + let idx = col * num_rows + row; + if idx < entries.len() { + let is_last = col == num_cols - 1 || idx + num_rows >= entries.len(); + if is_last { + output.push_str(&entries[idx]); + } else { + let width = col_w + 2; // entry width + 2 spaces + output.push_str(&format!("{: String { + let file_type = match metadata.file_type { + FileType::Directory => 'd', + FileType::Symlink => 'l', + FileType::Fifo => 'p', + FileType::File => '-', + }; + + let mode = metadata.mode; + let perms = format!( + "{}{}{}{}{}{}{}{}{}", + if mode & 0o400 != 0 { 'r' } else { '-' }, + if mode & 0o200 != 0 { 'w' } else { '-' }, + if mode & 0o100 != 0 { 'x' } else { '-' }, + if mode & 0o040 != 0 { 'r' } else { '-' }, + if mode & 0o020 != 0 { 'w' } else { '-' }, + if mode & 0o010 != 0 { 'x' } else { '-' }, + if mode & 0o004 != 0 { 'r' } else { '-' }, + if mode & 0o002 != 0 { 'w' } else { '-' }, + if mode & 0o001 != 0 { 'x' } else { '-' }, + ); + + let size = if human { + human_readable_size(metadata.size) + } else { + format!("{:>8}", metadata.size) + }; + + // Format modified time + let modified = metadata + .modified + .duration_since(std::time::UNIX_EPOCH) + .map(|d| { + let secs = d.as_secs(); + // Simple date formatting: YYYY-MM-DD HH:MM + let days = secs / 86400; + let hours = (secs % 86400) / 3600; + let mins = (secs % 3600) / 60; + // Approximate date calculation + let years = 1970 + (days / 365); + let remaining_days = days % 365; + let month = remaining_days / 30 + 1; + let day = remaining_days % 30 + 1; + format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + years, month, day, hours, mins + ) + }) + .unwrap_or_else(|_| "????-??-?? ??:??".to_string()); + + format!("{}{} {} {} {}\n", file_type, perms, size, modified, name) +} + +fn human_readable_size(size: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = 1024 * KB; + const GB: u64 = 1024 * MB; + + if size >= GB { + format!("{:>5.1}G", size as f64 / GB as f64) + } else if size >= MB { + format!("{:>5.1}M", size as f64 / MB as f64) + } else if size >= KB { + format!("{:>5.1}K", size as f64 / KB as f64) + } else { + format!("{:>6}", size) + } +} diff --git a/crates/bashkit/src/builtins/ls/mod.rs b/crates/bashkit/src/builtins/ls/mod.rs new file mode 100644 index 000000000..1ff0a19e6 --- /dev/null +++ b/crates/bashkit/src/builtins/ls/mod.rs @@ -0,0 +1,14 @@ +//! Directory listing builtins - ls, find, rmdir. + +mod find; +mod glob; +mod list; +mod rmdir; + +pub use find::Find; +pub(crate) use glob::glob_match; +pub use list::Ls; +pub use rmdir::Rmdir; + +#[cfg(test)] +mod tests; diff --git a/crates/bashkit/src/builtins/ls/rmdir.rs b/crates/bashkit/src/builtins/ls/rmdir.rs new file mode 100644 index 000000000..3bce5f508 --- /dev/null +++ b/crates/bashkit/src/builtins/ls/rmdir.rs @@ -0,0 +1,108 @@ +//! rmdir builtin - remove empty directories. + +use async_trait::async_trait; + +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... +/// +/// Options: +/// -p Remove parent directories as well if they become empty +pub struct Rmdir; + +#[async_trait] +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", + 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(); + + 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() { + 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 { + break; + } + } else { + break; + } + + current = parent.parent(); + } + } + } + + Ok(ExecResult::ok(String::new())) + } +} diff --git a/crates/bashkit/src/builtins/ls/tests.rs b/crates/bashkit/src/builtins/ls/tests.rs new file mode 100644 index 000000000..cfd3cac4e --- /dev/null +++ b/crates/bashkit/src/builtins/ls/tests.rs @@ -0,0 +1,2620 @@ +//! Tests for ls, find, rmdir, and glob_match. + +use super::find::{Find, build_find_exec_commands, parse_find_args}; +use super::glob_match; +use super::list::Ls; +use super::rmdir::Rmdir; +use crate::builtins::{Builtin, Context, ExecutionPlan}; + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use crate::fs::{FileSystem, InMemoryFs}; + +async fn create_test_ctx() -> (Arc, PathBuf, HashMap) { + let fs = Arc::new(InMemoryFs::new()); + let cwd = PathBuf::from("/home/user"); + let variables = HashMap::new(); + + fs.mkdir(&cwd, true).await.unwrap(); + + (fs, cwd, variables) +} + +// ==================== ls tests ==================== + +#[tokio::test] +async fn test_ls_empty_dir() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert_eq!(result.stdout, ""); +} + +#[tokio::test] +async fn test_ls_with_files() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create some files + fs.write_file(&cwd.join("file1.txt"), b"content1") + .await + .unwrap(); + fs.write_file(&cwd.join("file2.txt"), b"content2") + .await + .unwrap(); + + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("file1.txt")); + assert!(result.stdout.contains("file2.txt")); +} + +#[tokio::test] +async fn test_ls_hidden_files() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join(".hidden"), b"hidden") + .await + .unwrap(); + fs.write_file(&cwd.join("visible"), b"visible") + .await + .unwrap(); + + // Without -a + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(!result.stdout.contains(".hidden")); + assert!(result.stdout.contains("visible")); + + // With -a + let args = vec!["-a".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains(".hidden")); + assert!(result.stdout.contains("visible")); +} + +#[tokio::test] +async fn test_ls_long_format() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join("test.txt"), b"content") + .await + .unwrap(); + + let args = vec!["-l".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + // Long format should include permissions + assert!(result.stdout.contains("rw")); + assert!(result.stdout.contains("test.txt")); +} + +#[tokio::test] +async fn test_ls_nonexistent() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["nonexistent".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 2); + assert!(result.stderr.contains("No such file or directory")); +} + +#[tokio::test] +async fn test_ls_invalid_option() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["-z".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 2); + // clap-rendered diagnostic differs from GNU coreutils' wording + // ("invalid option") but both flag the unknown short flag. + // `### bash_diff` documents the GNU divergence in spec tests. + let combined = format!("{}{}", result.stdout, result.stderr); + assert!( + combined.contains("unexpected argument") || combined.contains("invalid option"), + "expected an unknown-flag diagnostic, got: {}", + combined + ); +} + +#[tokio::test] +async fn test_ls_sort_by_time() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create files with different modification times + fs.write_file(&cwd.join("older.txt"), b"older") + .await + .unwrap(); + fs.write_file(&cwd.join("newer.txt"), b"newer") + .await + .unwrap(); + + let args = vec!["-t".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + // -t should be accepted (not cause an error) + assert!(result.stdout.contains("older.txt")); + assert!(result.stdout.contains("newer.txt")); +} + +#[tokio::test] +async fn test_ls_file() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join("test.txt"), b"content") + .await + .unwrap(); + + let args = vec!["test.txt".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("test.txt")); +} + +#[tokio::test] +async fn test_ls_recursive() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); + fs.write_file(&cwd.join("file.txt"), b"content") + .await + .unwrap(); + fs.write_file(&cwd.join("subdir/nested.txt"), b"nested") + .await + .unwrap(); + + let args = vec!["-R".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("file.txt")); + assert!(result.stdout.contains("subdir")); + assert!(result.stdout.contains("nested.txt")); +} + +/// TM-INF-024 regression: a host-set `TIME_STYLE` (or any other env var +/// uutils' `uu_app()` attaches to an Arg via `.env(...)`) MUST NOT +/// reach the clap parser. uutils' upstream wires +/// `Arg::new(TIME_STYLE).env("TIME_STYLE")` so the option defaults +/// from the host process — bashkit strips that at codegen time, drops +/// the `env` clap feature workspace-wide as defence-in-depth, and +/// re-implements env-default precedence against `ctx.env` only via +/// `apply_env_defaults`. Without those guards a plain `ls` in a +/// container that exports `TIME_STYLE=long-iso` would trip the +/// unsupported-option gate (since bashkit hasn't implemented +/// `--time-style` yet) and break `ls` for every script running on +/// that host. +#[tokio::test] +#[serial_test::serial] +async fn ls_ignores_host_time_style_and_tabsize() { + // SAFETY: serial_test::serial serializes against other tests that + // touch the process env. Setting + unsetting around a single + // bashkit `Ls.execute()` is the only way to exercise the + // sandbox-leak regression: we're asserting bashkit does NOT + // observe these even when they're present on the host process. + unsafe { + std::env::set_var("TIME_STYLE", "long-iso"); + std::env::set_var("TABSIZE", "16"); + } + + let (fs, mut cwd, mut variables) = create_test_ctx().await; + fs.write_file(&cwd.join("a.txt"), b"a").await.unwrap(); + let env = HashMap::new(); + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + + unsafe { + std::env::remove_var("TIME_STYLE"); + std::env::remove_var("TABSIZE"); + } + + assert_eq!( + result.exit_code, 0, + "host TIME_STYLE/TABSIZE leaked into bashkit ls parser \ + (TM-INF-024): stderr={}", + result.stderr + ); + assert!(result.stdout.contains("a.txt")); + assert!( + !result.stderr.contains("not yet implemented"), + "host env tunneled through clap as a value source: stderr={}", + result.stderr + ); +} + +/// Counterpart to `ls_ignores_host_time_style_and_tabsize`: when +/// `TIME_STYLE` lives in bashkit's *virtual* env (`ctx.env`), the +/// codegen-emitted `LS_ENV_DEFAULTS` table feeds it through +/// `apply_env_defaults` and clap honours it just as upstream +/// uutils would honour `std::env::var("TIME_STYLE")`. We assert +/// that path by observing the clap value-source: ls today rejects +/// `--time-style` with a "not yet implemented" message, but only +/// because clap saw the option at all. If the env→argv shim were +/// silently broken, ls would succeed. +#[tokio::test] +async fn ls_honors_virtual_env_time_style() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + fs.write_file(&cwd.join("a.txt"), b"a").await.unwrap(); + let mut env = HashMap::new(); + env.insert("TIME_STYLE".to_string(), "long-iso".to_string()); + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + + assert_eq!( + result.exit_code, 2, + "virtual TIME_STYLE should reach clap's parser; got stdout={} \ + stderr={}", + result.stdout, result.stderr + ); + assert!( + result.stderr.contains("not yet implemented") && result.stderr.contains("time-style"), + "expected unsupported-option error mentioning time-style; got stderr={}", + result.stderr + ); +} + +/// Argv-set `--time-style` must take precedence over a `ctx.env` +/// value, matching clap's documented "argv > env > default" +/// precedence and `apply_env_defaults`'s contract. Easiest way to +/// observe it: pass an explicit `--time-style=iso`, set a +/// *different* value in `ctx.env`, and assert the error message +/// surfaces the explicit one (or at least that env did not +/// double-inject — the unsupported-option path collapses both +/// into the same diagnostic, so we just confirm exit==2 and that +/// argv wasn't mangled). +#[tokio::test] +async fn ls_argv_time_style_overrides_virtual_env() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let mut env = HashMap::new(); + env.insert("TIME_STYLE".to_string(), "long-iso".to_string()); + let args: Vec = vec!["--time-style=iso".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + let result = Ls.execute(ctx).await.unwrap(); + // Same unsupported-option gate as the virtual-env-only test; + // the precedence-correctness signal is that we don't see + // *two* sources or a clap parse failure complaining about + // duplicate `--time-style`. + assert_eq!(result.exit_code, 2); + assert!( + !result.stderr.contains("supplied more than once"), + "shim double-injected --time-style: stderr={}", + result.stderr + ); +} + +// ==================== find tests ==================== + +#[tokio::test] +async fn test_find_current_dir() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join("file.txt"), b"content") + .await + .unwrap(); + + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains(".")); + assert!(result.stdout.contains("file.txt")); +} + +#[tokio::test] +async fn test_find_name_pattern() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join("file.txt"), b"content") + .await + .unwrap(); + fs.write_file(&cwd.join("other.md"), b"content") + .await + .unwrap(); + + let args = vec!["-name".to_string(), "*.txt".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("file.txt")); + assert!(!result.stdout.contains("other.md")); +} + +#[tokio::test] +async fn test_find_type_file() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join("file.txt"), b"content") + .await + .unwrap(); + fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); + + let args = vec!["-type".to_string(), "f".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("file.txt")); + assert!(!result.stdout.contains("subdir")); +} + +#[tokio::test] +async fn test_find_type_directory() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join("file.txt"), b"content") + .await + .unwrap(); + fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); + + let args = vec!["-type".to_string(), "d".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(!result.stdout.contains("file.txt")); + // Should contain the directory + let lines: Vec<&str> = result.stdout.lines().collect(); + assert!(lines.iter().any(|l| l.contains("subdir") || *l == ".")); +} + +#[tokio::test] +async fn test_find_maxdepth() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.write_file(&cwd.join("a/b/deep.txt"), b"deep") + .await + .unwrap(); + + let args = vec!["-maxdepth".to_string(), "1".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.stdout.contains("./a")); + assert!(!result.stdout.contains("deep.txt")); +} + +#[tokio::test] +async fn test_find_nonexistent() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["nonexistent".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("No such file or directory")); +} + +#[tokio::test] +async fn test_find_missing_name_arg() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["-name".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("missing argument")); +} + +#[tokio::test] +async fn test_find_unknown_type() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["-type".to_string(), "x".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("unknown type")); +} + +#[tokio::test] +async fn test_find_deep_recursion() { + // Test that find without maxdepth descends into all subdirectory levels + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create a deep directory structure: a/b/c/d/deep.txt + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b/c"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b/c/d"), false).await.unwrap(); + fs.write_file(&cwd.join("a/b/c/d/deep.txt"), b"deep content") + .await + .unwrap(); + + // Also add files at each level + fs.write_file(&cwd.join("a/file1.txt"), b"level1") + .await + .unwrap(); + fs.write_file(&cwd.join("a/b/file2.txt"), b"level2") + .await + .unwrap(); + fs.write_file(&cwd.join("a/b/c/file3.txt"), b"level3") + .await + .unwrap(); + + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + // Should find the root + assert!(result.stdout.contains("."), "Should contain current dir"); + + // Should find all directories at all levels + assert!(result.stdout.contains("./a"), "Should contain ./a"); + assert!(result.stdout.contains("./a/b"), "Should contain ./a/b"); + assert!(result.stdout.contains("./a/b/c"), "Should contain ./a/b/c"); + assert!( + result.stdout.contains("./a/b/c/d"), + "Should contain ./a/b/c/d" + ); + + // Should find all files at all levels + assert!( + result.stdout.contains("file1.txt"), + "Should contain file1.txt at level 1" + ); + assert!( + result.stdout.contains("file2.txt"), + "Should contain file2.txt at level 2" + ); + assert!( + result.stdout.contains("file3.txt"), + "Should contain file3.txt at level 3" + ); + assert!( + result.stdout.contains("deep.txt"), + "Should contain deep.txt at level 4" + ); +} + +#[tokio::test] +async fn test_ls_recursive_deep() { + // Test that ls -R descends into all subdirectory levels + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create a deep directory structure: a/b/c/deep.txt + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b/c"), false).await.unwrap(); + fs.write_file(&cwd.join("a/b/c/deep.txt"), b"deep content") + .await + .unwrap(); + + let args = vec!["-R".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + // Should list all directories and their contents + assert!(result.stdout.contains("a"), "Should list dir a"); + assert!(result.stdout.contains("b"), "Should list dir b under a"); + assert!(result.stdout.contains("c"), "Should list dir c under a/b"); + assert!( + result.stdout.contains("deep.txt"), + "Should list deep.txt under a/b/c" + ); +} + +#[tokio::test] +async fn test_find_very_deep_nesting() { + // Test 10 levels of nesting to ensure no recursion limits + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create 10 levels deep + let mut path = cwd.clone(); + for i in 0..10 { + path = path.join(format!("level{}", i)); + fs.mkdir(&path, false).await.unwrap(); + fs.write_file( + &path.join(format!("file{}.txt", i)), + format!("content{}", i).as_bytes(), + ) + .await + .unwrap(); + } + + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + // Verify all 10 levels are found + for i in 0..10 { + assert!( + result.stdout.contains(&format!("level{}", i)), + "Should find level{} directory", + i + ); + assert!( + result.stdout.contains(&format!("file{}.txt", i)), + "Should find file{}.txt", + i + ); + } +} + +#[tokio::test] +async fn test_find_and_ls_consistency() { + // Ensure find and ls -R find the same nested structure + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create test structure + fs.mkdir(&cwd.join("top"), false).await.unwrap(); + fs.mkdir(&cwd.join("top/middle"), false).await.unwrap(); + fs.mkdir(&cwd.join("top/middle/bottom"), false) + .await + .unwrap(); + fs.write_file(&cwd.join("top/a.txt"), b"a").await.unwrap(); + fs.write_file(&cwd.join("top/middle/b.txt"), b"b") + .await + .unwrap(); + fs.write_file(&cwd.join("top/middle/bottom/c.txt"), b"c") + .await + .unwrap(); + + // Run find + let args_find: Vec = vec![]; + let ctx_find = Context { + args: &args_find, + env: &env, + variables: &mut variables.clone(), + cwd: &mut cwd.clone(), + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let find_result = Find.execute(ctx_find).await.unwrap(); + + // Run ls -R + let args_ls = vec!["-R".to_string()]; + let ctx_ls = Context { + args: &args_ls, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let ls_result = Ls.execute(ctx_ls).await.unwrap(); + + // Both should find all the nested content + assert!(find_result.stdout.contains("top")); + assert!(find_result.stdout.contains("middle")); + assert!(find_result.stdout.contains("bottom")); + assert!(find_result.stdout.contains("a.txt")); + assert!(find_result.stdout.contains("b.txt")); + assert!(find_result.stdout.contains("c.txt")); + + assert!(ls_result.stdout.contains("top")); + assert!(ls_result.stdout.contains("middle")); + assert!(ls_result.stdout.contains("bottom")); + assert!(ls_result.stdout.contains("a.txt")); + assert!(ls_result.stdout.contains("b.txt")); + assert!(ls_result.stdout.contains("c.txt")); +} + +#[tokio::test] +async fn test_find_with_empty_subdirs() { + // Ensure empty subdirectories are still traversed + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create structure with some empty dirs in the path + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/empty1"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/empty2"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/empty1/deep"), false).await.unwrap(); + fs.write_file(&cwd.join("a/empty1/deep/file.txt"), b"found") + .await + .unwrap(); + + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + // Should find the file through the empty directories + assert!(result.stdout.contains("file.txt")); + assert!(result.stdout.contains("empty1")); + assert!(result.stdout.contains("empty2")); + assert!(result.stdout.contains("deep")); +} + +#[tokio::test] +async fn test_find_from_specific_path() { + // Test finding from a specific starting path (not cwd) + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create nested structure + fs.mkdir(&cwd.join("start"), false).await.unwrap(); + fs.mkdir(&cwd.join("start/sub1"), false).await.unwrap(); + fs.mkdir(&cwd.join("start/sub1/sub2"), false).await.unwrap(); + fs.write_file(&cwd.join("start/sub1/sub2/target.txt"), b"target") + .await + .unwrap(); + + // Find from a specific starting path + let args = vec!["start".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + assert!( + result.stdout.contains("start"), + "Should contain starting path" + ); + assert!(result.stdout.contains("sub1"), "Should descend into sub1"); + assert!(result.stdout.contains("sub2"), "Should descend into sub2"); + assert!( + result.stdout.contains("target.txt"), + "Should find target.txt" + ); +} + +#[tokio::test] +async fn test_find_mindepth() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.write_file(&cwd.join("a/file1.txt"), b"f1") + .await + .unwrap(); + fs.write_file(&cwd.join("a/b/file2.txt"), b"f2") + .await + .unwrap(); + + // mindepth 1 should exclude the starting directory "." + let args = vec!["-mindepth".to_string(), "1".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + // Should NOT contain "." as the starting point (depth 0) + let lines: Vec<&str> = result.stdout.lines().collect(); + assert!(!lines.contains(&"."), "mindepth 1 should exclude '.'"); + // Should contain everything at depth >= 1 + assert!(result.stdout.contains("./a")); + assert!(result.stdout.contains("file1.txt")); + assert!(result.stdout.contains("file2.txt")); +} + +#[tokio::test] +async fn test_find_mindepth_with_type() { + // Reproduces the reported issue: find . -mindepth 1 -type f | wc -l + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.write_file(&cwd.join("a/file1.txt"), b"f1") + .await + .unwrap(); + fs.write_file(&cwd.join("a/b/file2.txt"), b"f2") + .await + .unwrap(); + + // mindepth 1 + type f + let args = vec![ + "-mindepth".to_string(), + "1".to_string(), + "-type".to_string(), + "f".to_string(), + ]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + let lines: Vec<&str> = result.stdout.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines.len(), 2, "Should find 2 files: {:?}", lines); // debug-ok: assert-failure message + + // mindepth 1 + type d + let args2 = vec![ + "-mindepth".to_string(), + "1".to_string(), + "-type".to_string(), + "d".to_string(), + ]; + let ctx2 = Context { + args: &args2, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result2 = Find.execute(ctx2).await.unwrap(); + assert_eq!(result2.exit_code, 0); + let lines2: Vec<&str> = result2.stdout.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!(lines2.len(), 2, "Should find 2 dirs: {:?}", lines2); // debug-ok: assert-failure message +} + +#[tokio::test] +async fn test_find_mindepth_2() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("a"), false).await.unwrap(); + fs.mkdir(&cwd.join("a/b"), false).await.unwrap(); + fs.write_file(&cwd.join("top.txt"), b"top").await.unwrap(); + fs.write_file(&cwd.join("a/mid.txt"), b"mid").await.unwrap(); + fs.write_file(&cwd.join("a/b/deep.txt"), b"deep") + .await + .unwrap(); + + // mindepth 2 should exclude depth 0 and depth 1 + let args = vec!["-mindepth".to_string(), "2".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + let lines: Vec<&str> = result.stdout.lines().collect(); + // depth 0: "." - excluded + assert!(!lines.contains(&".")); + // depth 1: "./a", "./top.txt" - excluded + assert!(!lines.contains(&"./a")); + assert!(!lines.contains(&"./top.txt")); + // depth 2: "./a/b", "./a/mid.txt" - included + assert!(lines.contains(&"./a/b")); + assert!(lines.contains(&"./a/mid.txt")); + // depth 3: "./a/b/deep.txt" - included + assert!(lines.contains(&"./a/b/deep.txt")); +} + +#[tokio::test] +async fn test_find_mindepth_missing_arg() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["-mindepth".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("missing argument")); +} + +#[tokio::test] +async fn test_find_mindepth_invalid_value() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["-mindepth".to_string(), "abc".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("invalid mindepth")); +} + +// ==================== rmdir tests ==================== + +#[tokio::test] +async fn test_rmdir_empty() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("emptydir"), false).await.unwrap(); + + let args = vec!["emptydir".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Rmdir.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(!fs.exists(&cwd.join("emptydir")).await.unwrap()); +} + +#[tokio::test] +async fn test_rmdir_not_empty() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("notempty"), false).await.unwrap(); + fs.write_file(&cwd.join("notempty/file.txt"), b"content") + .await + .unwrap(); + + let args = vec!["notempty".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Rmdir.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("not empty")); +} + +#[tokio::test] +async fn test_rmdir_nonexistent() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["nonexistent".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Rmdir.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("No such file or directory")); +} + +#[tokio::test] +async fn test_rmdir_not_directory() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join("file.txt"), b"content") + .await + .unwrap(); + + let args = vec!["file.txt".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Rmdir.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("Not a directory")); +} + +#[tokio::test] +async fn test_rmdir_parents() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("a/b/c"), true).await.unwrap(); + + let args = vec!["-p".to_string(), "a/b/c".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Rmdir.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + assert!(!fs.exists(&cwd.join("a/b/c")).await.unwrap()); + assert!(!fs.exists(&cwd.join("a/b")).await.unwrap()); + assert!(!fs.exists(&cwd.join("a")).await.unwrap()); +} + +#[tokio::test] +async fn test_rmdir_missing_operand() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Rmdir.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 1); + assert!(result.stderr.contains("missing operand")); +} + +// ==================== Custom filesystem tests ==================== + +#[tokio::test] +async fn test_find_with_overlay_fs() { + use crate::fs::OverlayFs; + + // Create base filesystem with nested structure + let base = Arc::new(InMemoryFs::new()); + base.mkdir(Path::new("/home/user"), true).await.unwrap(); + base.mkdir(Path::new("/home/user/base"), false) + .await + .unwrap(); + base.mkdir(Path::new("/home/user/base/sub1"), false) + .await + .unwrap(); + base.mkdir(Path::new("/home/user/base/sub1/sub2"), false) + .await + .unwrap(); + base.write_file(Path::new("/home/user/base/file1.txt"), b"base1") + .await + .unwrap(); + base.write_file(Path::new("/home/user/base/sub1/file2.txt"), b"base2") + .await + .unwrap(); + base.write_file(Path::new("/home/user/base/sub1/sub2/file3.txt"), b"base3") + .await + .unwrap(); + + // Create overlay + let overlay: Arc = Arc::new(OverlayFs::new(base)); + + // Add a file in the overlay layer (use recursive to ensure parent exists in upper) + overlay + .mkdir(Path::new("/home/user/base/overlay_dir"), true) + .await + .unwrap(); + overlay + .write_file( + Path::new("/home/user/base/overlay_dir/overlay_file.txt"), + b"overlay", + ) + .await + .unwrap(); + + let mut cwd = PathBuf::from("/home/user"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // Run find on the overlay filesystem + let args = vec!["base".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: overlay.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + // Should find files from base layer + assert!( + result.stdout.contains("file1.txt"), + "Should find file1.txt from base" + ); + assert!( + result.stdout.contains("file2.txt"), + "Should find file2.txt from base/sub1" + ); + assert!( + result.stdout.contains("file3.txt"), + "Should find file3.txt from base/sub1/sub2" + ); + + // Should find files from overlay layer + assert!( + result.stdout.contains("overlay_dir"), + "Should find overlay_dir" + ); + assert!( + result.stdout.contains("overlay_file.txt"), + "Should find overlay_file.txt" + ); + + // Should descend into all subdirectories + assert!(result.stdout.contains("sub1"), "Should find sub1"); + assert!(result.stdout.contains("sub2"), "Should find sub2"); +} + +#[tokio::test] +async fn test_find_with_mountable_fs() { + use crate::fs::MountableFs; + + // Create root filesystem + let root = Arc::new(InMemoryFs::new()); + root.mkdir(Path::new("/home/user"), true).await.unwrap(); + root.write_file(Path::new("/home/user/root_file.txt"), b"root") + .await + .unwrap(); + + // Create a nested filesystem to mount + let nested = Arc::new(InMemoryFs::new()); + nested.mkdir(Path::new("/level1"), false).await.unwrap(); + nested + .mkdir(Path::new("/level1/level2"), false) + .await + .unwrap(); + nested + .mkdir(Path::new("/level1/level2/level3"), false) + .await + .unwrap(); + nested + .write_file(Path::new("/level1/nested1.txt"), b"n1") + .await + .unwrap(); + nested + .write_file(Path::new("/level1/level2/nested2.txt"), b"n2") + .await + .unwrap(); + nested + .write_file(Path::new("/level1/level2/level3/nested3.txt"), b"n3") + .await + .unwrap(); + + // Create mountable filesystem and mount nested at /home/user/mounted + let mountable = MountableFs::new(root.clone()); + mountable + .mount("/home/user/mounted", nested.clone()) + .unwrap(); + + let fs: Arc = Arc::new(mountable); + let mut cwd = PathBuf::from("/home/user"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // Run find from cwd - should find both root files and mounted files + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Find.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + // Should find root file + assert!( + result.stdout.contains("root_file.txt"), + "Should find root_file.txt" + ); + + // Should find mount point + assert!( + result.stdout.contains("mounted"), + "Should find mounted directory" + ); + + // Should descend into mounted filesystem + assert!( + result.stdout.contains("level1"), + "Should find level1 in mounted fs" + ); + assert!( + result.stdout.contains("level2"), + "Should find level2 in mounted fs" + ); + assert!( + result.stdout.contains("level3"), + "Should find level3 in mounted fs" + ); + + // Should find files deep in mounted filesystem + assert!( + result.stdout.contains("nested1.txt"), + "Should find nested1.txt" + ); + assert!( + result.stdout.contains("nested2.txt"), + "Should find nested2.txt" + ); + assert!( + result.stdout.contains("nested3.txt"), + "Should find nested3.txt" + ); +} + +#[tokio::test] +async fn test_ls_recursive_with_overlay_fs() { + use crate::fs::OverlayFs; + + // Create base filesystem with nested structure + let base = Arc::new(InMemoryFs::new()); + base.mkdir(Path::new("/home/user"), true).await.unwrap(); + base.mkdir(Path::new("/home/user/dir"), false) + .await + .unwrap(); + base.mkdir(Path::new("/home/user/dir/subdir"), false) + .await + .unwrap(); + base.write_file(Path::new("/home/user/dir/base.txt"), b"base") + .await + .unwrap(); + base.write_file(Path::new("/home/user/dir/subdir/deep.txt"), b"deep") + .await + .unwrap(); + + let overlay: Arc = Arc::new(OverlayFs::new(base)); + + let mut cwd = PathBuf::from("/home/user"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + let args = vec!["-R".to_string(), "dir".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: overlay, + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + assert!(result.stdout.contains("base.txt"), "Should list base.txt"); + assert!(result.stdout.contains("subdir"), "Should list subdir"); + assert!(result.stdout.contains("deep.txt"), "Should list deep.txt"); +} + +#[tokio::test] +async fn test_ls_recursive_with_mountable_fs() { + use crate::fs::MountableFs; + + let root = Arc::new(InMemoryFs::new()); + root.mkdir(Path::new("/home/user"), true).await.unwrap(); + + let mounted = Arc::new(InMemoryFs::new()); + mounted.mkdir(Path::new("/a"), false).await.unwrap(); + mounted.mkdir(Path::new("/a/b"), false).await.unwrap(); + mounted + .write_file(Path::new("/a/file_a.txt"), b"a") + .await + .unwrap(); + mounted + .write_file(Path::new("/a/b/file_b.txt"), b"b") + .await + .unwrap(); + + let mountable = MountableFs::new(root); + mountable.mount("/home/user/mnt", mounted.clone()).unwrap(); + + let fs: Arc = Arc::new(mountable); + let mut cwd = PathBuf::from("/home/user"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + let args = vec!["-R".to_string(), "mnt".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + assert!(result.stdout.contains("a"), "Should list directory a"); + assert!(result.stdout.contains("b"), "Should list directory b"); + assert!( + result.stdout.contains("file_a.txt"), + "Should list file_a.txt" + ); + assert!( + result.stdout.contains("file_b.txt"), + "Should list file_b.txt" + ); +} + +// ==================== root directory tests ==================== + +#[tokio::test] +async fn test_ls_root_directory() { + // Test listing the root directory directly + let fs = Arc::new(InMemoryFs::new()); + let mut cwd = PathBuf::from("/home/user"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // ls / should work + let args = vec!["/".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!( + result.exit_code, 0, + "ls / should succeed: {}", + result.stderr + ); + // Root should contain at least tmp, home, dev + assert!(result.stdout.contains("tmp"), "Root should contain tmp"); + assert!(result.stdout.contains("home"), "Root should contain home"); + assert!(result.stdout.contains("dev"), "Root should contain dev"); +} + +#[tokio::test] +async fn test_ls_dot_from_root() { + // Test: when cwd is /, ls . should list root contents + let fs = Arc::new(InMemoryFs::new()); + let mut cwd = PathBuf::from("/"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // ls . with cwd=/ should work + let args = vec![".".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!( + result.exit_code, 0, + "ls . from / should succeed: {}", + result.stderr + ); + assert!(result.stdout.contains("tmp"), "Root should contain tmp"); + assert!(result.stdout.contains("home"), "Root should contain home"); +} + +#[tokio::test] +async fn test_ls_default_from_root() { + // Test: when cwd is /, ls (no args) should list root contents + let fs = Arc::new(InMemoryFs::new()); + let mut cwd = PathBuf::from("/"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // ls with no args and cwd=/ should work + let args: Vec = vec![]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!( + result.exit_code, 0, + "ls from / should succeed: {}", + result.stderr + ); + assert!(result.stdout.contains("tmp"), "Root should contain tmp"); + assert!(result.stdout.contains("home"), "Root should contain home"); +} + +#[tokio::test] +async fn test_ls_root_with_overlay_fs() { + // Test: ls / with OverlayFs should work + use crate::fs::OverlayFs; + + let base = Arc::new(InMemoryFs::new()); + let overlay: Arc = Arc::new(OverlayFs::new(base)); + + let mut cwd = PathBuf::from("/"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // ls / should work with overlay + let args = vec!["/".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: overlay.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!( + result.exit_code, 0, + "ls / with overlay should succeed: {}", + result.stderr + ); + assert!(result.stdout.contains("tmp"), "Root should contain tmp"); + assert!(result.stdout.contains("home"), "Root should contain home"); +} + +#[tokio::test] +async fn test_ls_dot_from_root_with_overlay_fs() { + // Test: cd / && ls . with OverlayFs should work + use crate::fs::OverlayFs; + + let base = Arc::new(InMemoryFs::new()); + let overlay: Arc = Arc::new(OverlayFs::new(base)); + + let mut cwd = PathBuf::from("/"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // ls . from / with overlay + let args = vec![".".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: overlay.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!( + result.exit_code, 0, + "ls . from / with overlay should succeed: {}", + result.stderr + ); + assert!(result.stdout.contains("tmp"), "Root should contain tmp"); + assert!(result.stdout.contains("home"), "Root should contain home"); +} + +#[tokio::test] +async fn test_resolve_path_slash_dot_normalized() { + // Verify that "/." path (from /join(".")) resolves correctly to root + let fs = Arc::new(InMemoryFs::new()); + + // The path "/." should normalize to "/" and exist + assert!(fs.exists(Path::new("/.")).await.unwrap(), "/. should exist"); + assert!(fs.exists(Path::new("/")).await.unwrap(), "/ should exist"); + + // Both should return the same stat info + let stat_root = fs.stat(Path::new("/")).await.unwrap(); + let stat_dot = fs.stat(Path::new("/.")).await.unwrap(); + assert!(stat_root.file_type.is_dir()); + assert!(stat_dot.file_type.is_dir()); +} + +// ==================== negative tests ==================== + +#[tokio::test] +async fn test_ls_nonexistent_path() { + // Negative test: ls on path that doesn't exist should fail + let fs = Arc::new(InMemoryFs::new()); + let mut cwd = PathBuf::from("/home/user"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + let args = vec!["/nonexistent/path".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 2, "ls on nonexistent path should fail"); + assert!( + result.stderr.contains("No such file or directory"), + "Should report file not found" + ); +} + +#[tokio::test] +async fn test_ls_path_traversal_normalized() { + // Positive test: path traversal with .. should be normalized and work + let fs = Arc::new(InMemoryFs::new()); + let mut cwd = PathBuf::from("/home/user"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // /home/user/../user should normalize to /home/user + let args = vec!["../user".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + // /home/user is an empty directory by default, so it should succeed with empty output + assert_eq!( + result.exit_code, 0, + "ls with .. should succeed after normalization: {}", + result.stderr + ); +} + +#[tokio::test] +async fn test_ls_excessive_dotdot_stays_at_root() { + // Positive test: excessive .. should stay at root + let fs = Arc::new(InMemoryFs::new()); + let mut cwd = PathBuf::from("/home/user"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // ../../../../.. from /home/user should normalize to / + let args = vec!["../../../../..".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!( + result.exit_code, 0, + "Excessive .. should normalize to root: {}", + result.stderr + ); + // Should list root contents + assert!(result.stdout.contains("tmp"), "Should list root (tmp)"); + assert!(result.stdout.contains("home"), "Should list root (home)"); +} + +#[tokio::test] +async fn test_ls_dot_in_middle_of_path() { + // Positive test: . in middle of path should be normalized + let fs = Arc::new(InMemoryFs::new()); + let mut cwd = PathBuf::from("/"); + let mut variables = HashMap::new(); + let env = HashMap::new(); + + // /./home/./user/. should normalize to /home/user + let args = vec!["./home/./user/.".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!( + result.exit_code, 0, + "Path with . components should work: {}", + result.stderr + ); +} + +// ==================== glob_match tests ==================== + +// ==================== file size reporting tests ==================== + +#[tokio::test] +async fn test_ls_long_format_shows_correct_file_size() { + // Positive test: file with known content shows correct size + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create file with exactly 13 bytes: "hello world\n" (11 chars + newline from echo) + let content = b"hello world\n"; + fs.write_file(&cwd.join("test.txt"), content).await.unwrap(); + + let args = vec!["-l".to_string(), "test.txt".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + // File size should be 12 bytes (content.len()) + assert!( + result.stdout.contains("12"), + "Expected size 12 in output, got: {}", + result.stdout + ); +} + +#[tokio::test] +async fn test_ls_long_format_empty_file_shows_zero_size() { + // Negative test: empty file shows size 0 + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&cwd.join("empty.txt"), b"").await.unwrap(); + + let args = vec!["-l".to_string(), "empty.txt".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + // Empty file should show size 0 + // Format is: -rw-r--r-- 0 YYYY-MM-DD HH:MM empty.txt + assert!( + result.stdout.contains(" 0"), + "Expected size 0 in output, got: {}", + result.stdout + ); +} + +#[tokio::test] +async fn test_ls_long_format_directory_shows_zero_size() { + // Negative test: directory shows size 0 + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.mkdir(&cwd.join("subdir"), false).await.unwrap(); + + let args = vec!["-l".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + // Directory line should contain size 0 + // Format is: drwxr-xr-x 0 YYYY-MM-DD HH:MM subdir + let lines: Vec<&str> = result.stdout.lines().collect(); + let subdir_line = lines.iter().find(|l| l.contains("subdir")).unwrap(); + assert!( + subdir_line.contains(" 0"), + "Expected directory size 0, got: {}", + subdir_line + ); +} + +#[tokio::test] +async fn test_ls_long_format_multiple_files_correct_sizes() { + // Positive test: multiple files show their respective sizes + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create files with different sizes + fs.write_file(&cwd.join("small.txt"), b"hi").await.unwrap(); // 2 bytes + fs.write_file(&cwd.join("medium.txt"), b"hello world") + .await + .unwrap(); // 11 bytes + fs.write_file( + &cwd.join("large.txt"), + b"this is a longer content string for testing", + ) + .await + .unwrap(); // 43 bytes + + let args = vec!["-l".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let result = Ls.execute(ctx).await.unwrap(); + assert_eq!(result.exit_code, 0); + + let lines: Vec<&str> = result.stdout.lines().collect(); + + // Check small.txt has size 2 + let small_line = lines.iter().find(|l| l.contains("small.txt")).unwrap(); + assert!( + small_line.contains(" 2"), + "Expected small.txt size 2, got: {}", + small_line + ); + + // Check medium.txt has size 11 + let medium_line = lines.iter().find(|l| l.contains("medium.txt")).unwrap(); + assert!( + medium_line.contains(" 11"), + "Expected medium.txt size 11, got: {}", + medium_line + ); + + // Check large.txt has size 43 + let large_line = lines.iter().find(|l| l.contains("large.txt")).unwrap(); + assert!( + large_line.contains(" 43"), + "Expected large.txt size 43, got: {}", + large_line + ); +} + +#[test] +fn test_glob_match_star() { + assert!(glob_match("file.txt", "*.txt")); + assert!(glob_match("test.txt", "*.txt")); + assert!(!glob_match("file.md", "*.txt")); +} + +#[test] +fn test_glob_match_question() { + assert!(glob_match("ab", "a?")); + assert!(glob_match("ac", "a?")); + assert!(!glob_match("abc", "a?")); +} + +#[test] +fn test_glob_match_exact() { + assert!(glob_match("test", "test")); + assert!(!glob_match("test", "other")); +} + +#[test] +fn test_glob_match_star_middle() { + assert!(glob_match("test.backup.txt", "test*.txt")); + assert!(glob_match("test.txt", "test*.txt")); +} + +// ==================== parse_find_args tests ==================== + +#[test] +fn test_parse_find_args_defaults_to_dot() { + let args: Vec = vec![]; + let (paths, opts) = parse_find_args(&args).unwrap(); + assert_eq!(paths, vec!["."]); + assert!(opts.exec_args.is_empty()); + assert!(!opts.exec_batch); +} + +#[test] +fn test_parse_find_args_exec_per_file() { + let args: Vec = vec![ + ".".into(), + "-name".into(), + "*.txt".into(), + "-exec".into(), + "cat".into(), + "{}".into(), + ";".into(), + ]; + let (paths, opts) = parse_find_args(&args).unwrap(); + assert_eq!(paths, vec!["."]); + assert_eq!(opts.name_pattern.as_deref(), Some("*.txt")); + assert_eq!(opts.exec_args, vec!["cat", "{}"]); + assert!(!opts.exec_batch); +} + +#[test] +fn test_parse_find_args_exec_batch() { + let args: Vec = vec!["-exec".into(), "rm".into(), "{}".into(), "+".into()]; + let (_paths, opts) = parse_find_args(&args).unwrap(); + assert_eq!(opts.exec_args, vec!["rm", "{}"]); + assert!(opts.exec_batch); +} + +#[test] +fn test_parse_find_args_error_missing_name() { + let args: Vec = vec!["-name".into()]; + assert!(parse_find_args(&args).is_err()); +} + +#[test] +fn test_parse_find_args_error_unknown_predicate() { + let args: Vec = vec!["-bogus".into()]; + assert!(parse_find_args(&args).is_err()); +} + +// ==================== build_find_exec_commands tests ==================== + +#[test] +fn test_build_find_exec_commands_per_file() { + let exec_args = vec!["echo".to_string(), "{}".to_string()]; + let paths = vec!["a.txt".to_string(), "b.txt".to_string()]; + let cmds = build_find_exec_commands(&exec_args, &paths, false); + assert_eq!(cmds.len(), 2); + assert_eq!(cmds[0].name, "echo"); + assert_eq!(cmds[0].args, vec!["a.txt"]); + assert_eq!(cmds[1].name, "echo"); + assert_eq!(cmds[1].args, vec!["b.txt"]); +} + +#[test] +fn test_build_find_exec_commands_batch() { + let exec_args = vec!["rm".to_string(), "{}".to_string()]; + let paths = vec!["a.txt".to_string(), "b.txt".to_string()]; + let cmds = build_find_exec_commands(&exec_args, &paths, true); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name, "rm"); + assert_eq!(cmds[0].args, vec!["a.txt", "b.txt"]); +} + +#[test] +fn test_build_find_exec_commands_empty_paths() { + let exec_args = vec!["echo".to_string(), "{}".to_string()]; + let cmds = build_find_exec_commands(&exec_args, &[], false); + assert!(cmds.is_empty()); +} + +#[test] +fn test_build_find_exec_commands_empty_exec() { + let paths = vec!["a.txt".to_string()]; + let cmds = build_find_exec_commands(&[], &paths, false); + assert!(cmds.is_empty()); +} + +#[test] +fn test_build_find_exec_commands_multiple_placeholders() { + let exec_args = vec!["cp".to_string(), "{}".to_string(), "{}.bak".to_string()]; + let paths = vec!["a.txt".to_string()]; + let cmds = build_find_exec_commands(&exec_args, &paths, false); + assert_eq!(cmds.len(), 1); + assert_eq!(cmds[0].name, "cp"); + assert_eq!(cmds[0].args, vec!["a.txt", "a.txt.bak"]); +} + +// ==================== find execution_plan tests ==================== + +#[tokio::test] +async fn test_find_plan_no_exec_returns_none() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec!["-name".to_string(), "*.txt".to_string()]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let plan = Find.execution_plan(&ctx).await.unwrap(); + assert!(plan.is_none()); +} + +#[tokio::test] +async fn test_find_plan_exec_with_matches() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + // Create test files + fs.write_file(&PathBuf::from("/home/user/a.txt"), b"hello") + .await + .unwrap(); + fs.write_file(&PathBuf::from("/home/user/b.txt"), b"world") + .await + .unwrap(); + + let args = vec![ + ".".to_string(), + "-name".to_string(), + "*.txt".to_string(), + "-exec".to_string(), + "cat".to_string(), + "{}".to_string(), + ";".to_string(), + ]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let plan = Find.execution_plan(&ctx).await.unwrap(); + match plan { + Some(ExecutionPlan::Batch { commands }) => { + assert_eq!(commands.len(), 2); + assert_eq!(commands[0].name, "cat"); + // Each command should have a single arg (the found path) + assert_eq!(commands[0].args.len(), 1); + assert_eq!(commands[1].args.len(), 1); + } + _ => panic!("expected Batch plan"), + } +} + +#[tokio::test] +async fn test_find_plan_exec_no_matches_returns_none() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + let args = vec![ + ".".to_string(), + "-name".to_string(), + "*.xyz".to_string(), + "-exec".to_string(), + "echo".to_string(), + "{}".to_string(), + ";".to_string(), + ]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let plan = Find.execution_plan(&ctx).await.unwrap(); + assert!(plan.is_none()); +} + +#[tokio::test] +async fn test_find_plan_exec_with_missing_path_returns_status_plan() { + let (fs, mut cwd, mut variables) = create_test_ctx().await; + let env = HashMap::new(); + + fs.write_file(&PathBuf::from("/home/user/a.txt"), b"hello") + .await + .unwrap(); + + let args = vec![ + "/home/user".to_string(), + "/home/missing".to_string(), + "-name".to_string(), + "*.txt".to_string(), + "-exec".to_string(), + "echo".to_string(), + "{}".to_string(), + ";".to_string(), + ]; + let ctx = Context { + args: &args, + env: &env, + variables: &mut variables, + cwd: &mut cwd, + fs: fs.clone(), + stdin: None, + #[cfg(feature = "http_client")] + http_client: None, + #[cfg(feature = "git")] + git_client: None, + #[cfg(feature = "ssh")] + ssh_client: None, + shell: None, + }; + + let plan = Find.execution_plan(&ctx).await.unwrap(); + match plan { + Some(ExecutionPlan::BatchWithStatus { + commands, + stderr_prefix, + force_error_exit, + }) => { + assert_eq!(commands.len(), 1); + assert!(stderr_prefix.contains("No such file or directory")); + assert!(force_error_exit); + } + _ => panic!("expected BatchWithStatus plan"), + } +}