Skip to content

-U --count-matches counts one cross-line match once per covered line and disagrees with --stats #188

Closed

Description

@wuliangtdi

Summary

With -U/--multiline, --count-matches counts the per-line pieces of a match instead of the original regex match. A single match spanning two lines is reported as 2, while ripgrep reports 1.

For a file containing hello\nworld\n, the pattern hello\nworld matches exactly once. tgrep's --stats correctly reports 1 matches (2 matched lines), but the same command prints 2 for --count-matches.

This happens with --no-index, with the on-disk index, and through tgrep serve.

Related to #185: the adjacent-match example from that issue now correctly counts two matches after #187. This report concerns one match spanning multiple lines.

Reproduce

mkdir repro && cd repro
printf 'hello\nworld\n' > a.txt

tgrep --no-index -U --count-matches -- 'hello\nworld' a.txt
rg               -U --count-matches -- 'hello\nworld' a.txt

tgrep --no-index -U --count-matches --stats -- 'hello\nworld' a.txt

# Controls: one single-line match, then two independent matches
tgrep --no-index -U --count-matches -- 'hello' a.txt
tgrep --no-index -U --count-matches -- 'hello|world' a.txt
Query Actual: tgrep Expected: rg 15.1.0
-U --count-matches 'hello\nworld' 2 1
-U --count-matches --stats 'hello\nworld' stdout: 2; stats: 1 matches (2 matched lines) stdout: 1
-U --count-matches 'hello' 1 1
`-U --count-matches 'hello world'` 2

All commands above exit 0.

The indexed query gives the same incorrect count:

tgrep index .
tgrep -U --count-matches -- 'hello\nworld' .

For the server path, start tgrep serve --no-watch . in another terminal and repeat the directory query. Adding --stats confirms (via server) and still reports one match while stdout counts two.

Environment

  • tgrep 1.1.0, built from b614b8c (current main, including Fix search compatibility bugs and Linux RSS test #187)
  • Release build: cargo build --release --locked -p tgrep-cli
  • rustc 1.95.0 (59807616e 2026-04-14)
  • Windows 11 Pro (build 28000); the counting code involved is platform-independent
  • ripgrep 15.1.0, checked with its default engine and --pcre2
  • The incorrect tgrep count was confirmed across scan, local-index, and server paths with both --engine default and --engine pcre2

Cause

In multiline mode, FileMatches::find collects the original spans, then passes them to group_spans_by_line. It already saves the original match count in match_totals:

let spans = limit_to_line_blocks(&index, &spans, opts.max_count);
let hits = group_spans_by_line(&index, &spans);
let totals = (spans.len() as u64, hits.len() as u64);

group_spans_by_line clips each original span onto every line it covers. The single (0, 11) span for hello\nworld becomes one span on the hello line and one on the world line.

However, FileMatches::match_count sums the spans in those per-line hits. The scan and local-index output path uses that method for --count-matches:

tgrep/tgrep-cli/src/search.rs

Lines 2119 to 2125 in b614b8c

if opts.count || opts.count_matches {
let n = if opts.count_matches {
found.match_count()
} else {
found.matched_lines()
};
writer.write_count(rel_path, n)?;

The server client independently reconstructs the count by summing spans from each returned line, producing the same result:

tgrep/tgrep-cli/src/search.rs

Lines 1224 to 1233 in b614b8c

let line = m.get("line").and_then(|l| l.as_u64()).unwrap_or(0) as usize;
lines.entry(file.to_string()).or_default().insert(line);
// A row with no spans is still one match (`-v` reports inverted
// lines with an empty span list).
let n = m
.get("spans")
.and_then(|s| s.as_array())
.map(|s| s.len().max(1))
.unwrap_or(1);
*spans.entry(file.to_string()).or_default() += n;

Server stats instead use the original match_totals, which explains the disagreement:

tgrep/tgrep-cli/src/serve.rs

Lines 2170 to 2179 in b614b8c

let mut result = FileSearchResult {
rows: Vec::new(),
stats: opts.stats.then(|| {
let (matches, matched_lines) = found.match_totals();
crate::search::FileMatchStats {
file: rel_path.to_string(),
matches,
matched_lines,
}
}),

Possible fix

Use the original regex match count, after applying the search limit and before splitting spans for presentation, for non-inverted multiline --count-matches. The local path already retains that count in FileMatches::match_totals. Carry the original per-file count through server responses independently of --stats, rather than reconstructing it from rendered lines. Preserve the existing behavior for inverted matching.

The existing stats_multiline_counts_original_matches_before_rendering test includes --count-matches, but it asserts the stats totals and output parity with/without --stats, not the expected stdout count.

A regression could assert that hello\nworld over hello\nworld\n prints 1 with --count-matches, with and without --stats, across scan, local-index, and server paths. The single-line, independent-match, and adjacent-match controls should retain their existing counts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions