From 8b4495564fced0b2a69452b7c555c823736032e5 Mon Sep 17 00:00:00 2001 From: Mykhailo Chalyi Date: Thu, 21 May 2026 23:44:05 -0500 Subject: [PATCH] feat(rg): add max columns modes --- crates/bashkit/docs/compatibility.md | 2 +- crates/bashkit/src/builtins/rg/mod.rs | 161 +++++++++++++++++++++++++- specs/implementation-status.md | 2 +- 3 files changed, 158 insertions(+), 7 deletions(-) diff --git a/crates/bashkit/docs/compatibility.md b/crates/bashkit/docs/compatibility.md index 43b6309f3..cfebeb7dc 100644 --- a/crates/bashkit/docs/compatibility.md +++ b/crates/bashkit/docs/compatibility.md @@ -71,7 +71,7 @@ for sandbox security reasons. See the compliance spec for details. | `readonly` | `VAR[=value]`, `-p` | POSIX mark variable read-only | | `times` | - | POSIX display process times | | `grep` | `-i`, `-v`, `-c`, `-n`, `-E`, `-q` | Pattern matching | -| `rg` | `-i/-S/-s`, `-n/-N`, `--column`, `-b/--byte-offset`, `--vimgrep`, basic `--json`, `--null`, `-c`, `--count-matches`, `--include-zero`, `-l`, `--files-with-matches`, `--files-without-match`, `--files`, `-F`, `-w`, `-x`, `-a/--text`, `--binary`, `-m`, `--max-depth`, `-A`, `-B`, `-C`, separator flags, `--heading/--no-heading`, `--sort/--sortr`, `-g/--glob`, `--ignore-file`, `.ignore`/`.gitignore`, `--no-ignore*`, `-t/--type`, `-T/--type-not`, `--type-add`, `--type-clear`, `--type-list`, `--hidden/--no-hidden`, `-o`, `-q`, `-e`, `-f`, `-r`, `--passthru`, `--trim`, `-H/-I`, `--help`, `--version` | Recursive ripgrep-style search | +| `rg` | `-i/-S/-s`, `-n/-N`, `--column`, `-b/--byte-offset`, `--vimgrep`, basic `--json`, `--null`, `-c`, `--count-matches`, `--include-zero`, `-l`, `--files-with-matches`, `--files-without-match`, `--files`, `-F`, `-w`, `-x`, `-a/--text`, `--binary`, `-m`, `-M/--max-columns`, `--max-columns-preview`, `--max-depth`, `-A`, `-B`, `-C`, separator flags, `--heading/--no-heading`, `--sort/--sortr`, `-g/--glob`, `--ignore-file`, `.ignore`/`.gitignore`, `--no-ignore*`, `-t/--type`, `-T/--type-not`, `--type-add`, `--type-clear`, `--type-list`, `--hidden/--no-hidden`, `-o`, `-q`, `-e`, `-f`, `-r`, `--passthru`, `--trim`, `-H/-I`, `--help`, `--version` | Recursive ripgrep-style search | | `sed` | `s///[g]`, `d`, `p`, `q`, `a`, `i`, `c`, `h/H/g/G/x`, `-E`, `-n`, `!` | Stream editing | | `awk` | `'{print}'`, `-F`, `-v`, loops, arrays, increment, ternary | Text processing | | `jq` | `.field`, `.[n]`, pipes, file args, `-r`, `-c`, `-n`, `-s`, `-S`, `-e`, `-j`, `--tab`, `--arg`, `--argjson`, `-V`, combined flags | JSON processing | diff --git a/crates/bashkit/src/builtins/rg/mod.rs b/crates/bashkit/src/builtins/rg/mod.rs index 7ec12c1ec..c8555216e 100644 --- a/crates/bashkit/src/builtins/rg/mod.rs +++ b/crates/bashkit/src/builtins/rg/mod.rs @@ -50,6 +50,8 @@ struct RgOptions { text: bool, binary: bool, max_count: Option, + max_columns: Option, + max_columns_preview: bool, max_depth: Option, before_context: usize, after_context: usize, @@ -141,6 +143,8 @@ impl RgOptions { text: false, binary: false, max_count: None, + max_columns: None, + max_columns_preview: false, max_depth: None, before_context: 0, after_context: 0, @@ -202,6 +206,15 @@ impl RgOptions { opts.max_count = Some(val.parse().map_err(|_| { Error::Execution(format!("rg: invalid --max-count value: {val}")) })?); + } else if let Some(val) = p.flag_value("-M", "rg").map_err(Error::Execution)? { + opts.max_columns = Some( + val.parse() + .map_err(|_| Error::Execution(format!("rg: invalid -M value: {val}")))?, + ); + } else if let Some(val) = long_value(&mut p, "--max-columns")? { + 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-depth")? { opts.max_depth = Some(val.parse().map_err(|_| { Error::Execution(format!("rg: invalid --max-depth value: {val}")) @@ -305,6 +318,8 @@ impl RgOptions { opts.passthru = true; } else if p.flag("--trim") { opts.trim = true; + } else if p.flag("--max-columns-preview") { + opts.max_columns_preview = true; } else if p.flag("--include-zero") { opts.include_zero = true; } else if p.flag("--heading") { @@ -450,6 +465,25 @@ impl RgOptions { })?); break; } + 'M' => { + let rest: String = chars[j + 1..].iter().collect(); + let num_str = if !rest.is_empty() { + rest + } else { + match p.positional() { + Some(v) => v.to_string(), + None => { + return Err(Error::Execution( + "rg: -M requires an argument".to_string(), + )); + } + } + }; + opts.max_columns = Some(num_str.parse().map_err(|_| { + Error::Execution(format!("rg: invalid -M value: {num_str}")) + })?); + break; + } 'A' => { let rest: String = chars[j + 1..].iter().collect(); opts.after_context = if !rest.is_empty() { @@ -1505,6 +1539,24 @@ fn format_rg_line(line: &str, regex: &Regex, opts: &RgOptions, matched: bool) -> } } +fn format_rg_output_line(line: &str, regex: &Regex, opts: &RgOptions, matched: bool) -> String { + let line = format_rg_line(line, regex, opts, matched); + let Some(max_columns) = opts.max_columns else { + return line; + }; + if max_columns == 0 || line.chars().count() <= max_columns { + return line; + } + if opts.max_columns_preview { + let preview: String = line.chars().take(max_columns).collect(); + format!("{preview} [... omitted end of long line]") + } else if matched { + "[Omitted long matching line]".to_string() + } else { + "[Omitted long context line]".to_string() + } +} + fn write_rg_context( output: &mut String, filename: &str, @@ -1560,7 +1612,12 @@ fn write_rg_context( null_path_separator: opts.null, }, ); - output.push_str(&format_rg_line(lines[line_idx].text, regex, opts, matched)); + output.push_str(&format_rg_output_line( + lines[line_idx].text, + regex, + opts, + matched, + )); output.push('\n'); } } @@ -1662,7 +1719,7 @@ fn write_rg_json_summary( #[async_trait] 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 --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 -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 -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 --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 -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 --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 --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 = "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 --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 -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 -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 -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 --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 --color MODE\tcolor output (no-op)\n -h, --help\tdisplay this help and exit\n -V, --version\toutput version information and exit\n"; if ctx.args.iter().any(|arg| arg == "-h") { return Ok(ExecResult::ok(help_text.to_string())); } @@ -1940,7 +1997,7 @@ impl Builtin for Rg { null_path_separator: opts.null, }, ); - output.push_str(&format_rg_line(line.text, ®ex, &opts, matched)); + output.push_str(&format_rg_output_line(line.text, ®ex, &opts, matched)); output.push('\n'); } } else if opts.vimgrep && !opts.invert_match { @@ -1962,7 +2019,7 @@ impl Builtin for Rg { if opts.only_matching { output.push_str(mat.as_str()); } else { - output.push_str(&format_rg_line( + output.push_str(&format_rg_output_line( lines[line_idx].text, ®ex, &opts, @@ -2041,7 +2098,12 @@ impl Builtin for Rg { null_path_separator: opts.null, }, ); - output.push_str(&format_rg_line(lines[line_idx].text, ®ex, &opts, true)); + output.push_str(&format_rg_output_line( + lines[line_idx].text, + ®ex, + &opts, + true, + )); output.push('\n'); } } @@ -2211,6 +2273,11 @@ mod tests { ("/proj/text.txt", b"needle\n"), ]; + const DIFF_MAX_COLUMNS_FILES: &[(&str, &[u8])] = &[( + "/proj/long.txt", + b"short needle\ncontext line is long\n0123456789 needle\nnomatch\n", + )]; + const RG_DIFF_CASES: &[RgDiffCase] = &[ RgDiffCase { name: "stdin line numbers", @@ -2770,6 +2837,44 @@ mod tests { cwd: "/", output: RgDiffOutput::UnorderedLines, }, + RgDiffCase { + name: "max columns omits long matches", + args: &["--max-columns", "10", "needle", "proj/long.txt"], + stdin: None, + files: DIFF_MAX_COLUMNS_FILES, + cwd: "/", + output: RgDiffOutput::Exact, + }, + RgDiffCase { + name: "max columns preview", + args: &[ + "--max-columns", + "10", + "--max-columns-preview", + "needle", + "proj/long.txt", + ], + stdin: None, + files: DIFF_MAX_COLUMNS_FILES, + cwd: "/", + output: RgDiffOutput::Exact, + }, + RgDiffCase { + name: "max columns context", + args: &["-C1", "--max-columns", "10", "needle", "proj/long.txt"], + stdin: None, + files: DIFF_MAX_COLUMNS_FILES, + cwd: "/", + output: RgDiffOutput::Exact, + }, + RgDiffCase { + name: "max columns passthru", + args: &["--passthru", "-M10", "needle", "proj/long.txt"], + stdin: None, + files: DIFF_MAX_COLUMNS_FILES, + cwd: "/", + output: RgDiffOutput::Exact, + }, ]; fn require_real_rg() { @@ -3106,6 +3211,52 @@ mod tests { ); } + #[tokio::test] + async fn test_rg_max_columns_modes() { + let files: &[(&str, &[u8])] = &[( + "/proj/long.txt", + b"short needle\ncontext line is long\n0123456789 needle\nnomatch\n", + )]; + + let omitted = run_rg( + &["--max-columns", "10", "needle", "/proj/long.txt"], + None, + files, + ) + .await; + assert_eq!(omitted.exit_code, 0); + assert_eq!( + omitted.stdout, + "[Omitted long matching line]\n[Omitted long matching line]\n" + ); + + let preview = run_rg( + &["-M10", "--max-columns-preview", "needle", "/proj/long.txt"], + None, + files, + ) + .await; + assert_eq!(preview.exit_code, 0); + assert_eq!( + preview.stdout, + "short need [... omitted end of long line]\n0123456789 [... omitted end of long line]\n" + ); + + let disabled = run_rg(&["-M", "0", "needle", "/proj/long.txt"], None, files).await; + assert_eq!(disabled.exit_code, 0); + assert_eq!(disabled.stdout, "short needle\n0123456789 needle\n"); + } + + #[tokio::test] + async fn test_rg_max_columns_requires_value() { + let args: Vec = vec!["needle".to_string(), "-M".to_string()]; + let result = RgOptions::parse(&args); + assert!(matches!( + result, + Err(Error::Execution(msg)) if msg == "rg: -M requires an argument" + )); + } + #[tokio::test] async fn test_rg_unknown_option_errors() { let fs = Arc::new(InMemoryFs::new()) as Arc; diff --git a/specs/implementation-status.md b/specs/implementation-status.md index 5e5fe9c06..506bc4a58 100644 --- a/specs/implementation-status.md +++ b/specs/implementation-status.md @@ -108,7 +108,7 @@ Bashkit implements IEEE 1003.1-2024 Shell Command Language. |----------|-------|-------|------|------|-------| | Bash (core) | 1939 | Yes | 1916 | 23 | `bash_spec_tests` in CI | | AWK | 126 | Yes | 126 | 0 | loops, arrays, -v, ternary, field assign, getline, %.6g, delete, dev-stderr | -| Grep | 95 | Yes | 95 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude; rg has context, glob, ignore files, binary/text modes, -e, -f, -r, -o, -q, --files, --max-depth, --column, -b/--byte-offset, --vimgrep, basic --json, --null, --count-matches, --include-zero, --passthru, --trim, --heading/--no-heading, separator flags, smart/line-regexp, filename controls, hidden/type filters, type definition flags, help/version, indexed recursive search, and pinned real-rg differentials | +| Grep | 95 | Yes | 95 | 0 | -z, -r, -a, -b, -H, -h, -f, -P, --include, --exclude; rg has context, glob, ignore files, binary/text modes, max-column modes, -e, -f, -r, -o, -q, --files, --max-depth, --column, -b/--byte-offset, --vimgrep, basic --json, --null, --count-matches, --include-zero, --passthru, --trim, --heading/--no-heading, separator flags, smart/line-regexp, filename controls, hidden/type filters, type definition flags, help/version, indexed recursive search, and pinned real-rg differentials | | Sed | 78 | Yes | 78 | 0 | hold space, change, regex ranges, -E | | JQ | 121 | Yes | 120 | 1 | reduce, walk, regex funcs, --arg/--argjson, combined flags, input/inputs, env | | Python | 60 | Yes | 58 | 2 | embedded Python (Monty 0.0.11); datetime, json, pathlib, os |