Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/cmds/system/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## Specifics

- `read.rs` uses `core/filter` for language-aware code stripping (FilterLevel: none/minimal/aggressive)
- `search.rs` backs both `rtk grep` and `rtk rg`: it runs the invoked engine (never substituting one for the other) and groups its output, reading `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw.
- `search.rs` backs both `rtk grep` and `rtk rg`: it runs the invoked engine (never substituting one for the other) and groups its output, reading `core/config` for `limits.grep_max_results` and `limits.grep_max_per_file`. Format-altering flags (`-c`, `-l`, `-L`, `-o`, `-Z`) bypass RTK filtering and run raw, except a bare file list (`-l`, `-L`, `--files` with no other shape flag), whose shared directory prefix is folded into a `<prefix> (N files)` header under the `never_worse` guard.
- `ctest_cmd.rs` takes the run total from the first result line (covering `--stop-on-failure`, disabled tests, and forwarded suites) and validates both result lines and the summary against it, deduplicates retries by test number+name, folds wrapped result lines until their terminator, falls back to the raw `FAILED:` trailer for unparsed failures, keeps the error trailer behind an empty run, labels and caps failure details with tee recovery, and attributes diagnostics safely under `-j`; explicit verbose/show-only/help/version flags and dashboard modes bypass filtering, except `-T Test`, which prints ordinary test output and stays filtered.
- `local_llm.rs` (`rtk smart`) uses `core/filter` for heuristic file summarization
- `format_cmd.rs` is a cross-ecosystem dispatcher: auto-detects and routes to `prettier_cmd` or `ruff_cmd` (black is handled inline, not as a separate module)
Expand Down
267 changes: 251 additions & 16 deletions src/cmds/system/search.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
//! compresses its output by grouping matches by file, capping, and teeing overflow.

use crate::core::arg_tokenizer::{self, Dialect, Token, TokenKind, ValueSpec};
use crate::core::guard::never_worse;
use crate::core::stream::{
self, CaptureResult, FilterMode, StdinMode, StreamFilter, exec_capture, exec_capture_stdin,
};
Expand Down Expand Up @@ -549,12 +550,17 @@ fn run_streaming_search(

/// Runs the agent's command verbatim for forms RTK does not group: format/shape
/// flags and pattern-less modes (`--files`, `--type-list`).
///
/// One exception: a bare file list (`-l`/`-L`/`--files`, see [`is_bare_file_list`]) has its
/// shared directory prefix folded into a header when captured. The streaming form reads
/// stdin, where the only "file" is `(standard input)`, so it has nothing to fold.
fn passthrough<T: AsRef<str>>(
timer: &tracking::TimedExecution,
engine: Engine,
args: &[T],
real_cmd: &str,
stream_stdin: bool,
fold_file_list: bool,
) -> Result<i32> {
let mut cmd = resolved_command(engine.bin());
if stream_stdin && !std::io::stdout().is_terminal() {
Expand All @@ -565,21 +571,108 @@ fn passthrough<T: AsRef<str>>(
cmd.child_arg(a.as_ref());
}

let exit_code = if stream_stdin {
stream::run_streaming(&mut cmd, StdinMode::Inherit, FilterMode::Passthrough)
.context("search failed")?
.exit_code
if stream_stdin {
let exit_code =
stream::run_streaming(&mut cmd, StdinMode::Inherit, FilterMode::Passthrough)
.context("search failed")?
.exit_code;
timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd));
return Ok(exit_code);
}

let result = exec_capture_stdin(&mut cmd).context("search failed")?;
let cleaned = strip_ansi(&result.stdout);
let folded = if fold_file_list {
fold_path_prefix(&cleaned).filter(|f| never_worse(&cleaned, f) == f)
} else {
let result = exec_capture_stdin(&mut cmd).context("search failed")?;
print!("{}", strip_ansi(&result.stdout));
if !result.stderr.is_empty() {
eprint!("{}", result.stderr);
}
result.exit_code
None
};
match &folded {
Some(folded) => print!("{}", folded),
None => print!("{}", cleaned),
}
if !result.stderr.is_empty() {
eprint!("{}", result.stderr);
}

timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd));
Ok(exit_code)
match &folded {
// Real sizes, so the fold shows up in `rtk gain`.
Some(folded) => timer.track(
real_cmd,
&format!("rtk {}", engine.label()),
&cleaned,
folded,
),
// 0/0 keeps an unchanged passthrough from diluting the savings statistics.
None => timer.track_passthrough(real_cmd, &format!("rtk {} (passthrough)", real_cmd)),
}
Ok(result.exit_code)
}

/// Folds the directory prefix every line of a file list shares into a one-line header, then
/// emits each path's remaining tail in engine order:
///
/// ```text
/// /home/u/proj/src/ (3 files)
/// a/foo.rs
/// a/bar.rs
/// b/baz.rs
/// ```
///
/// Agents search from absolute roots, so `-l` output restates the same long prefix on every
/// line; that prefix is the whole redundancy of the list. The transform is lossless (each path
/// is `prefix + tail`) and needs no cap or tee. `None` when there is nothing to fold: fewer
/// than two lines, a line that is not a plain path (empty, NUL-joined under `-Z`, carrying a
/// `\r` that `lines()` would drop, or an escape such as an rg `--hyperlink-format` OSC 8 link
/// that `strip_ansi` leaves in place), or no shared directory component. The prefix is cut on whole components, never inside one, so
/// `/a/foobar/x` and `/a/foobaz/y` share `/a/`, not `/a/fooba`.
///
/// KNOWN LIMITATION: only `/` separates components, so Windows-style `\` paths never fold.
fn fold_path_prefix(raw: &str) -> Option<String> {
// Checked on the raw text: `lines()` would already have dropped a `\r` before `\n`.
if raw.contains(['\0', '\r', '\x1b']) {
return None;
}
let paths: Vec<&str> = raw.lines().collect();
if paths.len() < 2 || paths.iter().any(|p| p.is_empty()) {
Comment thread
KuSh marked this conversation as resolved.
Resolved
return None;
}

fn dirs(path: &str) -> Vec<&str> {
match path.rsplit_once('/') {
Some((dir, _)) => dir.split('/').collect(),
None => Vec::new(),
}
}

let first = dirs(paths[0]);
let mut common = first.len();
for path in &paths[1..] {
let shared = dirs(path)
.iter()
.zip(&first)
.take(common)
.take_while(|(a, b)| a == b)
.count();
common = shared;
if common == 0 {
return None;
}
}

// An absolute path splits to a leading empty component, so `/a/b` rejoins with its slash;
// a bare `/` root is the one prefix that saves nothing.
let prefix = format!("{}/", first[..common].join("/"));
if prefix.len() <= 1 {
return None;
}

let mut out = format!("{} ({} files)\n", prefix, paths.len());
for path in &paths {
out.push_str(path.strip_prefix(&prefix).unwrap_or(path));
out.push('\n');
}
Some(out)
}

pub fn run(
Expand Down Expand Up @@ -611,7 +704,7 @@ pub fn run(
});
if dangling_value_flag {
let real_cmd = format!("{} {}", engine.bin(), args.join(" "));
return passthrough(&timer, engine, args, &real_cmd, false);
return passthrough(&timer, engine, args, &real_cmd, false, false);
}

if asks_for_help {
Expand All @@ -632,7 +725,9 @@ pub fn run(
extract_pattern_path(args, engine);

if patterns.is_empty() {
return passthrough(&timer, engine, args, &real_cmd, false);
// `rg --files` lists paths without a pattern; fold it like `-l`.
let fold = is_bare_file_list(engine, args);
return passthrough(&timer, engine, args, &real_cmd, false, fold);
}

let pattern_display = if patterns.len() == 1 {
Expand All @@ -652,7 +747,8 @@ pub fn run(

// format/shape flags (-c/-l/-o/...): already-minimal native output, passthrough.
if extra_args_has_format_flag {
return passthrough(&timer, engine, args, &real_cmd, reads_piped_stdin);
let fold = is_bare_file_list(engine, args);
return passthrough(&timer, engine, args, &real_cmd, reads_piped_stdin, fold);
}

if reads_piped_stdin {
Expand All @@ -676,7 +772,7 @@ pub fn run(
// Unparseable shape re-runs verbatim below (with its own stderr), so handle it
// before surfacing this run's stderr (#2333).
if unparsed_signal(&raw_output) > 0 {
return passthrough(&timer, engine, args, &real_cmd, false);
return passthrough(&timer, engine, args, &real_cmd, false, false);
}

if !result.stderr.is_empty() {
Expand Down Expand Up @@ -895,6 +991,32 @@ fn is_format_flag_token(engine: Engine, kind: TokenKind, text: &str) -> bool {
}
}

/// True when the command's only shape flag is a file list -- `-l`/`--files-with-matches`,
/// grep's `-L`/`--files-without-match`, or rg's `--files` -- so every stdout line is one
/// plain path and [`fold_path_prefix`] applies. Any other shape flag changes the line
/// (`-c` appends `:count`, `-Z`/`--null` joins with NUL, `--json` wraps it) or removes it
/// (`-q`), so the list is left verbatim.
pub(crate) fn is_bare_file_list<T: AsRef<str>>(engine: Engine, args: &[T]) -> bool {
let tokens = tokenize_search_args(args, engine);
let mut file_list = false;
for t in &tokens {
let is_list = match t.kind {
TokenKind::Long => matches!(
t.text,
"files" | "files-with-matches" | "files-without-match"
),
TokenKind::Short => t.text == "l" || (t.text == "L" && engine == Engine::Grep),
_ => false,
};
if is_list {
file_list = true;
} else if is_format_flag_token(engine, t.kind, t.text) {
return false;
}
}
file_list
}

/// True for `-H`/`--with-filename`, an explicit request for the filename prefix (same meaning
/// for both engines).
fn is_show_file_token(kind: TokenKind, text: &str) -> bool {
Expand Down Expand Up @@ -1149,6 +1271,119 @@ mod tests {
assert!(!cleaned.is_empty());
}

// --- fold_path_prefix / is_bare_file_list ---

#[test]
fn fold_path_prefix_folds_shared_dir_into_header() {
let raw =
"/home/u/proj/src/a/foo.rs\n/home/u/proj/src/a/bar.rs\n/home/u/proj/src/b/baz.rs\n";
assert_eq!(
fold_path_prefix(raw).as_deref(),
Some("/home/u/proj/src/ (3 files)\na/foo.rs\na/bar.rs\nb/baz.rs\n")
);
}

#[test]
fn fold_path_prefix_keeps_engine_order_and_relative_paths() {
let raw = "src/z.rs\nsrc/a.rs\n";
assert_eq!(
fold_path_prefix(raw).as_deref(),
Some("src/ (2 files)\nz.rs\na.rs\n")
);
}

#[test]
fn fold_path_prefix_cuts_on_whole_components() {
// `/a/foobar` and `/a/foobaz` share `/a/`, never the byte run `/a/fooba`.
let raw = "/a/foobar/x\n/a/foobaz/y\n";
assert_eq!(
fold_path_prefix(raw).as_deref(),
Some("/a/ (2 files)\nfoobar/x\nfoobaz/y\n")
);
}

#[test]
fn fold_path_prefix_never_folds_into_a_filename() {
// The shared prefix is a directory only; a file that sits at the prefix root keeps its
// full name.
let raw = "/a/b/c.rs\n/a/b/d/e.rs\n";
assert_eq!(
fold_path_prefix(raw).as_deref(),
Some("/a/b/ (2 files)\nc.rs\nd/e.rs\n")
);
}

#[test]
fn fold_path_prefix_none_when_nothing_to_fold() {
assert_eq!(fold_path_prefix(""), None, "empty");
assert_eq!(fold_path_prefix("/a/b/c.rs\n"), None, "single line");
assert_eq!(fold_path_prefix("a.rs\nb.rs\n"), None, "bare filenames");
assert_eq!(
fold_path_prefix("/x/a.rs\n/y/b.rs\n"),
None,
"only `/` shared"
);
assert_eq!(
fold_path_prefix("src/a.rs\nlib/b.rs\n"),
None,
"no shared dir"
);
assert_eq!(fold_path_prefix("/a/b.rs\n\n/a/c.rs\n"), None, "blank line");
assert_eq!(
fold_path_prefix("/a/b.rs\0/a/c.rs\0"),
None,
"NUL-joined (-Z)"
);
assert_eq!(fold_path_prefix("/a/x\r\n/a/y\n"), None, "CR in a name");
assert_eq!(
fold_path_prefix("\x1b]8;;file:///a/x\x1b\\/a/x\n\x1b]8;;file:///a/y\x1b\\/a/y\n"),
None,
"OSC 8 hyperlink"
);
}

#[test]
fn fold_path_prefix_is_lossless() {
let raw = "/p/q/a.rs\n/p/q/r/b.rs\n/p/q/c.rs\n";
let folded = fold_path_prefix(raw).expect("folds");
let mut lines = folded.lines();
let header = lines.next().unwrap();
let prefix = header.strip_suffix(" (3 files)").unwrap();
let rebuilt: String = lines.map(|tail| format!("{prefix}{tail}\n")).collect();
assert_eq!(rebuilt, raw);
}

#[test]
fn bare_file_list_detects_list_flags_per_engine() {
assert!(is_bare_file_list(Engine::Grep, &["-rl", "foo", "."]));
assert!(is_bare_file_list(Engine::Grep, &["-L", "foo", "."]));
assert!(is_bare_file_list(
Engine::Grep,
&["--files-with-matches", "foo"]
));
assert!(is_bare_file_list(Engine::Rg, &["-l", "foo"]));
assert!(is_bare_file_list(Engine::Rg, &["--files", "src"]));
// rg's -L is --follow, not a list.
assert!(!is_bare_file_list(Engine::Rg, &["-L", "foo"]));
assert!(!is_bare_file_list(Engine::Grep, &["-rn", "foo", "."]));
}

#[test]
fn bare_file_list_rejects_other_shape_flags() {
// Each of these changes or removes the path line, so the list must stay verbatim.
assert!(!is_bare_file_list(Engine::Grep, &["-lc", "foo", "."]));
assert!(!is_bare_file_list(Engine::Grep, &["-lZ", "foo", "."]));
assert!(!is_bare_file_list(
Engine::Grep,
&["-l", "--null", "foo", "."]
));
assert!(!is_bare_file_list(Engine::Grep, &["-lq", "foo", "."]));
assert!(!is_bare_file_list(Engine::Rg, &["-l", "--json", "foo"]));
assert!(!is_bare_file_list(Engine::Rg, &["--files", "--null"]));
// `-e -l` is a pattern, not the list flag.
assert!(!is_bare_file_list(Engine::Grep, &["-e", "-l", "."]));
}

// --- extract_pattern_path ---
//
// parse_cluster/ClusterResult were replaced by arg_tokenizer::tokenize; the
Expand Down
Loading
Loading