fix(search): make rtk grep line numbers faithful to plain grep

show_line() emitted a `<lineno>:` prefix on every match by default,
suppressed only by -N. That diverges from grep (which shows line numbers
only with -n), so `cargo test | grep "test result:"` came back as
`266:test result: ...` and broke downstream parses like
`grep X | awk -F: '{print $1}'` -- violating RTK's transparency goal
(issue #1436) and driving agent retries in benchmarking.

Line numbers now appear only when the agent requests them (-n /
--line-number); -N / --no-line-number still force them off. grep -c and
exit codes are untouched (already faithful). Tests rebased onto the
plain-grep baseline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EeBpaGF4TJsNWz12h6Sk3G
This commit is contained in:
Adrien EPPLING
2026-07-23 12:54:20 +02:00
parent 1c5a23c64a
commit 608e5741f3
3 changed files with 43 additions and 38 deletions
+9 -3
View File
@@ -397,7 +397,13 @@ fn show_file(paths: &[String], extra_args: &[String]) -> bool {
}
fn show_line(extra_args: &[String]) -> bool {
!has_short_flag(extra_args, 'N')
// Faithful to grep: line numbers appear only when the agent asks for them
// (`-n`/`--line-number`). Adding them by default corrupts downstream parses
// (e.g. `grep X | awk -F: '{print $1}'`) and breaks RTK's transparency.
// `-N`/`--no-line-number` still force them off when combined with `-n`.
(has_short_flag(extra_args, 'n')
|| extra_args.iter().any(|f| f == "--line-number"))
&& !has_short_flag(extra_args, 'N')
&& !extra_args.iter().any(|f| f == "--no-line-number")
}
@@ -612,8 +618,8 @@ pub fn run(
// -n. We force -nH--null for robust parsing, then drop what the engine itself
// would not have shown.
let show_file = by_file.len() > 1 || show_file(&paths, &extra_args);
// Always surface the line number (the openable position) unless the agent
// explicitly turned it off; the filename is the only conditional part.
// Surface the line number only when the agent requested it (-n), matching
// real grep; the filename is the other conditional part.
let show_line = show_line(&extra_args);
// Faithful baseline: exactly what the real command prints, full content.
+32 -33
View File
@@ -20,19 +20,17 @@ fn rtk_grep(args: &[&str]) -> (String, Option<i32>) {
)
}
fn grep_n(args: &[&str]) -> (String, Option<i32>) {
let mut a = vec!["-n"];
a.extend_from_slice(args);
let out = Command::new("grep").args(&a).output().expect("grep");
fn grep_plain(args: &[&str]) -> (String, Option<i32>) {
let out = Command::new("grep").args(args).output().expect("grep");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
out.status.code(),
)
}
fn assert_eq_grep_n(args: &[&str]) {
fn assert_eq_grep(args: &[&str]) {
let (rtk, rc) = rtk_grep(args);
let (grep, gc) = grep_n(args);
let (grep, gc) = grep_plain(args);
assert_eq!(rtk, grep, "stdout mismatch for {args:?}");
assert_eq!(rc, gc, "exit code mismatch for {args:?}");
}
@@ -48,17 +46,18 @@ fn single_multi_recursive_and_h_match_grep_n() {
let d = tempfile::tempdir().unwrap();
let f1 = write(d.path(), "f1.txt", "apple\nzebra apple\nbanana\n");
let f2 = write(d.path(), "f2.txt", "apricot\n");
assert_eq_grep_n(&["apple", &f1]); // single: position only
assert_eq_grep_n(&["a", &f1, &f2]); // multi: file + position
assert_eq_grep_n(&["-H", "apple", &f1]); // -H forces filename on a single file
assert_eq_grep_n(&["-r", "a", d.path().to_str().unwrap()]); // recursive
assert_eq_grep(&["apple", &f1]); // single: content only, no position
assert_eq_grep(&["a", &f1, &f2]); // multi: filename only, no position
assert_eq_grep(&["-H", "apple", &f1]); // -H forces filename on a single file
assert_eq_grep(&["-n", "apple", &f1]); // -n adds the line number
assert_eq_grep(&["-r", "a", d.path().to_str().unwrap()]); // recursive
}
#[test]
fn no_match_matches_grep_n() {
let d = tempfile::tempdir().unwrap();
let f = write(d.path(), "f.txt", "hello\n");
assert_eq_grep_n(&["zzz_no_match_xyz", &f]); // empty stdout, exit 1
assert_eq_grep(&["zzz_no_match_xyz", &f]); // empty stdout, exit 1
}
#[test]
@@ -69,29 +68,29 @@ fn nasty_content_is_not_misparsed() {
"n.txt",
"12:34 looks like a line number\na::b::c ClassRegistry::init('x')\nport :8080: here\n$(rm -rf /) `whoami` && echo\n中文 テスト مرحبا\n",
);
assert_eq_grep_n(&[":", &f]);
assert_eq_grep_n(&["ClassRegistry", &f]);
assert_eq_grep_n(&["中文", &f]);
assert_eq_grep_n(&["whoami", &f]);
assert_eq_grep(&[":", &f]);
assert_eq_grep(&["ClassRegistry", &f]);
assert_eq_grep(&["中文", &f]);
assert_eq_grep(&["whoami", &f]);
}
#[test]
fn regex_metacharacters_match_grep_n() {
let d = tempfile::tempdir().unwrap();
let f = write(d.path(), "r.txt", "a.b\naxb\nfoo.bar\n[x]\n");
assert_eq_grep_n(&["a.b", &f]); // . is any-char
assert_eq_grep_n(&["-F", "a.b", &f]); // fixed-string
assert_eq_grep_n(&["^foo", &f]); // anchor
assert_eq_grep_n(&["-E", "ax?b", &f]); // ERE
assert_eq_grep(&["a.b", &f]); // . is any-char
assert_eq_grep(&["-F", "a.b", &f]); // fixed-string
assert_eq_grep(&["^foo", &f]); // anchor
assert_eq_grep(&["-E", "ax?b", &f]); // ERE
}
#[test]
fn context_flags_match_grep_n() {
let d = tempfile::tempdir().unwrap();
let f = write(d.path(), "c.txt", "x\nMATCH\ny\nz\n");
assert_eq_grep_n(&["-A1", "MATCH", &f]);
assert_eq_grep_n(&["-B1", "MATCH", &f]);
assert_eq_grep_n(&["-C1", "MATCH", &f]);
assert_eq_grep(&["-A1", "MATCH", &f]);
assert_eq_grep(&["-B1", "MATCH", &f]);
assert_eq_grep(&["-C1", "MATCH", &f]);
}
#[test]
@@ -102,7 +101,7 @@ fn context_group_separator_matches_grep_n() {
"sep.txt",
"match A\nfill1\nfill2\nfill3\nmatch B\n",
);
assert_eq_grep_n(&["-A1", "match", &f]);
assert_eq_grep(&["-A1", "match", &f]);
}
// #1436: a single-file `-n` search with `::` content and a BRE pattern with
@@ -116,11 +115,11 @@ fn issue_1436_colons_and_literal_parens() {
"shell.php",
"use Util\\ClassRegistry;\nclass Foo {\n public function run() {\n $this->m = ClassRegistry::init('Collections.QueueProcess');\n try {\n deleteRow($id);\n $this->delete();\n } catch (\\Exception $e) {\n } finally {}\n }\n private function delete() {}\n}\n",
);
assert_eq_grep_n(&[
assert_eq_grep(&[
"private function delete\\|try\\|catch\\|finally\\|delete()",
&f,
]);
assert_eq_grep_n(&["init", &f]); // ClassRegistry::init must stay one intact line
assert_eq_grep(&["init", &f]); // ClassRegistry::init must stay one intact line
}
// #1436 (comments): a leading `^` anchor must stay scoped to the given file
@@ -135,9 +134,9 @@ fn issue_1436_anchor_scope_and_trailing_colon() {
"t.py",
"x = 1\nif crypto >= MAX_CRYPTO:\nclass Foo:\n",
);
assert_eq_grep_n(&["^pub", &f]); // anchored pattern must not escape the path
assert_eq_grep_n(&["MAX_CRYPTO", &py]); // trailing-colon line kept intact
assert_eq_grep_n(&["class", &py]);
assert_eq_grep(&["^pub", &f]); // anchored pattern must not escape the path
assert_eq_grep(&["MAX_CRYPTO", &py]); // trailing-colon line kept intact
assert_eq_grep(&["class", &py]);
}
#[test]
@@ -145,15 +144,15 @@ fn colon_in_filename_matches_grep_n() {
let d = tempfile::tempdir().unwrap();
let f1 = write(d.path(), "weird:name.txt", "hit\n");
let f2 = write(d.path(), "other.txt", "hit\n");
assert_eq_grep_n(&["hit", &f1, &f2]); // colon in a path must not fool the parser
assert_eq_grep(&["hit", &f1, &f2]); // colon in a path must not fool the parser
}
#[test]
fn case_insensitive_and_invert_match_grep_n() {
let d = tempfile::tempdir().unwrap();
let f = write(d.path(), "i.txt", "Apple\nbanana\nAPPLE\n");
assert_eq_grep_n(&["-i", "apple", &f]);
assert_eq_grep_n(&["-v", "apple", &f]);
assert_eq_grep(&["-i", "apple", &f]);
assert_eq_grep(&["-v", "apple", &f]);
}
#[test]
@@ -170,6 +169,6 @@ fn piped_stdin_matches_grep_n() {
String::from_utf8_lossy(&c.wait_with_output().unwrap().stdout).into_owned()
};
let rtk = feed(Command::new(env!("CARGO_BIN_EXE_rtk")).args(["grep", "apple"]));
let grep = feed(Command::new("grep").args(["-n", "apple"]));
assert_eq!(rtk, grep, "piped stdin must equal grep -n");
let grep = feed(Command::new("grep").args(["apple"]));
assert_eq!(rtk, grep, "piped stdin must equal plain grep");
}
+2 -2
View File
@@ -307,8 +307,8 @@ fn small_grep_not_worse_than_plain() {
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.trim() == "1:foo",
"single-file grep must equal `grep -n` (position, no filename):\n{stdout}"
stdout.trim() == "foo",
"single-file grep must equal plain `grep` (content only, no position/filename):\n{stdout}"
);
assert!(
!stdout.contains("matches in"),