diff --git a/crates/bashkit/src/builtins/rg/mod.rs b/crates/bashkit/src/builtins/rg/mod.rs index f199bd3f4..bcb10c8f5 100644 --- a/crates/bashkit/src/builtins/rg/mod.rs +++ b/crates/bashkit/src/builtins/rg/mod.rs @@ -56,6 +56,7 @@ struct RgOptions { max_count: Option, max_columns: Option, max_columns_preview: bool, + max_filesize: Option, max_depth: Option, before_context: usize, after_context: usize, @@ -179,6 +180,7 @@ impl RgOptions { max_count: None, max_columns: None, max_columns_preview: false, + max_filesize: None, max_depth: None, before_context: 0, after_context: 0, @@ -254,6 +256,8 @@ impl RgOptions { opts.max_columns = Some(val.parse().map_err(|_| { Error::Execution(format!("rg: invalid --max-columns value: {val}")) })?); + } else if let Some(val) = long_value(&mut p, "--max-filesize")? { + opts.max_filesize = Some(parse_max_filesize(&val)?); } else if let Some(val) = long_value(&mut p, "--max-depth")? { opts.max_depth = Some(val.parse().map_err(|_| { Error::Execution(format!("rg: invalid --max-depth value: {val}")) @@ -815,6 +819,10 @@ impl RgOptions { .any(|file_type| file_type.matches(path)) } + fn matches_max_filesize(&self, size: u64) -> bool { + self.max_filesize.is_none_or(|max| size <= max) + } + fn first_positive_glob(&self) -> Option { self.glob_rules .iter() @@ -1211,6 +1219,31 @@ fn parse_context_value(value: &str, flag: &str) -> Result { .map_err(|_| Error::Execution(format!("rg: invalid {} value: {}", flag, value))) } +fn parse_max_filesize(value: &str) -> Result { + let (digits, multiplier) = match value.as_bytes().last().copied() { + Some(b'K') => (&value[..value.len() - 1], 1024_u64), + Some(b'M') => (&value[..value.len() - 1], 1024_u64 * 1024), + Some(b'G') => (&value[..value.len() - 1], 1024_u64 * 1024 * 1024), + _ => (value, 1), + }; + + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) { + return Err(Error::Execution(format!( + "rg: error parsing flag --max-filesize: invalid size: invalid format for size '{value}', which should be a non-empty sequence of digits followed by an optional 'K', 'M' or 'G' suffix" + ))); + } + + digits + .parse::() + .ok() + .and_then(|size| size.checked_mul(multiplier)) + .ok_or_else(|| { + Error::Execution(format!( + "rg: error parsing flag --max-filesize: invalid size: size '{value}' is too large" + )) + }) +} + fn parse_path_separator(value: &str) -> Result { if value.len() == 1 { Ok(value.to_string()) @@ -1558,6 +1591,7 @@ async fn collect_rg_files_recursive( && opts .max_depth .is_none_or(|max_depth| entry_depth <= max_depth) + && opts.matches_max_filesize(entry.metadata.size) && !opts.is_ignored_by_rules(&path, false, &rules) && opts.matches_globs(&path, cwd) && opts.matches_type_filters(&path) @@ -1567,7 +1601,9 @@ async fn collect_rg_files_recursive( } } else if let Ok(meta) = fs.stat(¤t).await && meta.file_type.is_file() + && opts.matches_max_filesize(meta.size) && opts.matches_globs(¤t, cwd) + && opts.matches_type_filters(¤t) { result.push(current); } @@ -1661,6 +1697,7 @@ async fn try_indexed_search( || opts.files_without_matches || opts.uses_ignore_files() || opts.patterns.len() != 1 + || opts.max_filesize.is_some() || !opts.type_includes.is_empty() || !opts.type_excludes.is_empty() { @@ -2171,6 +2208,10 @@ fn append_rg_stats(output: &mut String, stats: &RgSearchStats, bytes_printed: us impl Builtin for Rg { async fn execute(&self, ctx: Context<'_>) -> Result { let help_text = "Usage: rg [OPTIONS] PATTERN [PATH...]\nRecursively search for a pattern.\n\n -i, --ignore-case\tcase insensitive\n -S, --smart-case\tcase insensitive if pattern is lowercase\n -s, --case-sensitive\tcase sensitive\n -n, --line-number\tshow line numbers\n -N, --no-line-number\tsuppress line numbers\n --column\tshow column numbers\n -b, --byte-offset\tshow byte offsets\n --vimgrep\tshow file:line:column:match lines\n --json\tshow JSON Lines events\n --stats\tshow search statistics\n --null\tterminate path fields with NUL\n -c, --count\tcount matching lines\n --count-matches\tcount individual matches\n --include-zero\tinclude zero counts\n -l, --files-with-matches\tfiles with matches\n --files-without-match\tfiles without matches\n --files\tprint files that would be searched\n -v, --invert-match\tinvert match\n -w, --word-regexp\tword boundary\n -x, --line-regexp\tmatch whole lines\n -F, --fixed-strings\tfixed strings (literal)\n -a, --text\tsearch binary files as text\n --binary\tsearch binary files and print binary-match summaries\n --crlf\ttreat CRLF as line terminators for $ anchors\n --no-crlf\tdisable CRLF line terminator mode\n -U, --multiline\tenable matching across line boundaries\n --no-multiline\tdisable multiline matching\n --multiline-dotall\tmake . match line terminators in multiline mode\n --no-multiline-dotall\tdisable multiline dotall mode\n -o, --only-matching\tshow only matching text\n -q, --quiet\tsuppress output; exit status only\n -e, --regexp PATTERN\tuse PATTERN for matching\n -f, --file PATTERNFILE\tread patterns from file\n -E, --encoding ENCODING\tdecode searched files using ENCODING\n -r, --replace REPLACEMENT\treplace matches in output\n --passthru\tprint matching and non-matching lines\n --trim\ttrim whitespace from output lines\n -m, --max-count NUM\tmax count per file\n -M, --max-columns NUM\tomit lines longer than NUM columns\n --max-columns-preview\tshow prefixes of long lines\n --max-depth NUM\tlimit recursive directory depth\n -A, --after-context NUM\tshow trailing context\n -B, --before-context NUM\tshow leading context\n -C, --context NUM\tshow leading and trailing context\n --context-separator SEP\tset context group separator\n --field-match-separator SEP\tset match field separator\n --field-context-separator SEP\tset context field separator\n --heading\tgroup matches by file\n --no-heading\tdisable heading output\n --sort SORTBY\tsort paths (path only)\n --sortr SORTBY\tsort paths in reverse (path only)\n --sort-files\tsort --files output\n --path-separator SEP\tset displayed path separator\n -g, --glob GLOB\tinclude/exclude paths by glob (!GLOB excludes)\n -t, --type TYPE\tinclude files matching TYPE\n -T, --type-not TYPE\texclude files matching TYPE\n --type-add TYPE:GLOB\tadd a file type glob\n --type-clear TYPE\tclear a file type definition\n --type-list\tshow file type definitions\n --ignore-file FILE\tuse additional ignore file\n --no-ignore\tdo not use ignore files\n --no-ignore-dot\tdo not use .ignore files\n --no-ignore-vcs\tdo not use .gitignore files\n --no-require-git\tuse .gitignore outside git repositories\n --require-git\trequire a git repository for .gitignore files\n -u, --unrestricted\treduce filtering (repeatable)\n --messages\tshow file read diagnostics\n --no-messages\tsuppress file read diagnostics\n --hidden\tsearch hidden files and directories\n --no-hidden\tdo not search hidden files and directories\n -H, --with-filename\tshow filename\n -I, --no-filename\tsuppress filename\n --line-buffered\tforce line buffering (no-op)\n --block-buffered\tforce block buffering (no-op)\n --no-config\tdo not read config files (no-op)\n --mmap\tsearch using memory maps when possible (no-op)\n --no-mmap\tdisable memory maps (no-op)\n -P, --pcre2\tuse PCRE2 regex engine for supported patterns (no-op)\n --no-pcre2\tdisable PCRE2 regex engine (no-op)\n --engine ENGINE\tselect regex engine: default, auto, pcre2 (no-op)\n --auto-hybrid-regex\tuse PCRE2 when needed (no-op)\n --no-auto-hybrid-regex\tdisable auto hybrid regex (no-op)\n --color MODE\tcolor output (no-op)\n -h, --help\tdisplay this help and exit\n -V, --version\toutput version information and exit\n"; + let help_text = help_text.replace( + " --max-columns-preview\tshow prefixes of long lines\n", + " --max-columns-preview\tshow prefixes of long lines\n --max-filesize NUM\tignore files larger than NUM bytes, K, M, or G\n", + ); let help_text = help_text.replace( " -g, --glob GLOB\tinclude/exclude paths by glob (!GLOB excludes)\n", " -g, --glob GLOB\tinclude/exclude paths by glob (!GLOB excludes)\n --iglob GLOB\tcase-insensitive include/exclude path glob\n --glob-case-insensitive\tmake -g/--glob rules case-insensitive\n --no-glob-case-insensitive\tdisable case-insensitive -g/--glob rules\n", @@ -3201,6 +3242,11 @@ mod tests { b"short needle\ncontext line is long\n0123456789 needle\nnomatch\n", )]; + const DIFF_MAX_FILESIZE_FILES: &[(&str, &[u8])] = &[ + ("/proj/small.txt", b"needle\n"), + ("/proj/big.txt", b"needle long\n"), + ]; + const RG_DIFF_CASES: &[RgDiffCase] = &[ RgDiffCase { name: "stdin line numbers", @@ -4270,6 +4316,52 @@ mod tests { cwd: "/", output: RgDiffOutput::Exact, }, + RgDiffCase { + name: "max filesize filters recursive search", + args: &["--max-filesize", "10", "needle", "proj"], + stdin: None, + files: DIFF_MAX_FILESIZE_FILES, + cwd: "/", + output: RgDiffOutput::Exact, + }, + RgDiffCase { + name: "max filesize suffix allows larger files", + args: &["--max-filesize", "1K", "needle", "proj"], + stdin: None, + files: DIFF_MAX_FILESIZE_FILES, + cwd: "/", + output: RgDiffOutput::UnorderedLines, + }, + RgDiffCase { + name: "max filesize does not filter explicit file", + args: &[ + "--max-filesize", + "10", + "needle", + "proj/small.txt", + "proj/big.txt", + ], + stdin: None, + files: DIFF_MAX_FILESIZE_FILES, + cwd: "/", + output: RgDiffOutput::Exact, + }, + RgDiffCase { + name: "max filesize filters files listing", + args: &["--files", "--max-filesize", "10", "proj"], + stdin: None, + files: DIFF_MAX_FILESIZE_FILES, + cwd: "/", + output: RgDiffOutput::Exact, + }, + RgDiffCase { + name: "max filesize empty files listing exits one", + args: &["--files", "--max-filesize", "1", "proj"], + stdin: None, + files: DIFF_MAX_FILESIZE_FILES, + cwd: "/", + output: RgDiffOutput::Exact, + }, RgDiffCase { name: "missing file keeps stdout and exits 2", args: &["needle", "proj/a.txt", "proj/missing.txt"],