Skip to main content

trg/
search.rs

1//! Search configuration and the `-m` sink. Deliberately thin: the matching
2//! engine is ripgrep's, unmodified.
3
4use std::io;
5use std::path::Path;
6
7use grep_searcher::{
8    BinaryDetection, Searcher, SearcherBuilder, Sink, SinkContext, SinkFinish, SinkMatch,
9};
10
11pub struct SearchOpts {
12    pub case_insensitive: bool,
13    pub smart_case: bool,
14    pub word: bool,
15    pub fixed: bool,
16    pub invert: bool,
17    pub before: usize,
18    pub after: usize,
19    pub line_numbers: bool,
20    /// `-a`: search members that look binary.
21    pub text: bool,
22    pub max_count: Option<u64>,
23    pub heap_limit: usize,
24    /// `--crlf`: treat CRLF as the line terminator. Off by default — see
25    /// `agrees_with_rg_on_anchored_patterns` in `tests/differential.rs` for
26    /// why this must never be hard-coded on.
27    pub crlf: bool,
28}
29
30impl Default for SearchOpts {
31    fn default() -> Self {
32        Self {
33            case_insensitive: false,
34            smart_case: false,
35            word: false,
36            fixed: false,
37            invert: false,
38            before: 0,
39            after: 0,
40            line_numbers: true,
41            text: false,
42            max_count: None,
43            heap_limit: 16 * 1024 * 1024,
44            crlf: false,
45        }
46    }
47}
48
49pub fn build_matcher(
50    pats: &[String],
51    o: &SearchOpts,
52) -> anyhow::Result<grep_regex::RegexMatcher> {
53    // Built as ripgrep builds it: multi-line, so `^` and `$` hold at every line
54    // and not only at the edges of whatever buffer the searcher hands over,
55    // with `\n` as the line terminator so no match can span two lines. Built
56    // single-line, `-o '^[0-9.]+'` matched the first line and printed every
57    // later hit whole.
58    let mut b = grep_regex::RegexMatcherBuilder::new();
59    b.case_insensitive(o.case_insensitive)
60        .case_smart(o.smart_case)
61        .word(o.word)
62        .fixed_strings(o.fixed)
63        .multi_line(true)
64        .line_terminator(Some(b'\n'))
65        .crlf(o.crlf);
66    Ok(b.build_many(pats)?)
67}
68
69pub fn build_searcher(o: &SearchOpts) -> Searcher {
70    let mut b = SearcherBuilder::new();
71    b.line_number(o.line_numbers)
72        .before_context(o.before)
73        .after_context(o.after)
74        .invert_match(o.invert)
75        .heap_limit(Some(o.heap_limit))
76        .binary_detection(if o.text {
77            BinaryDetection::none()
78        } else {
79            BinaryDetection::quit(b'\x00')
80        });
81    if o.crlf {
82        b.line_terminator(grep_matcher::LineTerminator::crlf());
83    }
84    b.build()
85}
86
87/// `-m/--max-count`. `StandardBuilder` has no `max_matches`, so the limit is
88/// enforced by wrapping the printer's sink and returning `Ok(false)` from
89/// `matched`, which stops the search.
90pub struct MaxCount<S> {
91    inner: S,
92    limit: Option<u64>,
93    seen: u64,
94    binary_at: Option<u64>,
95}
96
97impl<S: Sink> MaxCount<S> {
98    pub fn new(inner: S, limit: Option<u64>) -> Self {
99        Self { inner, limit, seen: 0, binary_at: None }
100    }
101
102    /// Whether the searcher reported any match at all for this member.
103    ///
104    /// This, and not the printer's own summary, is the exit code's source of
105    /// truth. See the note on `matched` below for why the difference matters.
106    pub fn saw_match(&self) -> bool {
107        self.seen > 0
108    }
109
110    /// Matched, then ran into binary data — the one combination that makes
111    /// `SummarySink` retract its count and print nothing.
112    ///
113    /// Read from the searcher's own `SinkFinish`, which is the same value
114    /// `SummarySink::finish` consults before squashing `match_count` to zero,
115    /// so the two can never disagree about whether a count was suppressed.
116    /// Only `-c` cares: `-l` prints the path, `--json` emits its records and
117    /// the default printer emits a binary warning of its own.
118    pub fn count_suppressed_by_binary(&self) -> bool {
119        self.seen > 0 && self.binary_at.is_some()
120    }
121}
122
123impl<S: Sink> Sink for MaxCount<S> {
124    type Error = S::Error;
125
126    fn matched(&mut self, s: &Searcher, m: &SinkMatch<'_>) -> Result<bool, S::Error> {
127        // Counted *before* the inner sink is consulted, and this ordering is
128        // load-bearing twice over.
129        //
130        // A printer is allowed to say "stop, I have seen enough" — `-l` and
131        // `-q` both quit on their first match — and a printer is allowed to
132        // revise what it reports afterwards: `SummarySink::finish` zeroes its
133        // own `match_count` whenever binary data was seen under
134        // `BinaryDetection::quit`, which grep-printer's source calls "an
135        // unfortunate inconsistency ... we accept the bug". That is a
136        // reasonable answer to rg's question ("is this file worth showing
137        // you?") and the wrong answer to ours, because exit 1 here is a
138        // positive claim that the logs are clean.
139        //
140        // Counting first makes `seen` the record of what the *searcher*
141        // reported, which no printer can overrule and no early stop can
142        // truncate. The `-m` limit is unaffected: when the inner sink stops the
143        // search, its own count no longer decides anything.
144        self.seen += 1;
145        if !self.inner.matched(s, m)? {
146            return Ok(false);
147        }
148        Ok(match self.limit {
149            Some(n) => self.seen < n,
150            None => true,
151        })
152    }
153
154    fn context(&mut self, s: &Searcher, c: &SinkContext<'_>) -> Result<bool, S::Error> {
155        self.inner.context(s, c)
156    }
157
158    fn context_break(&mut self, s: &Searcher) -> Result<bool, S::Error> {
159        self.inner.context_break(s)
160    }
161
162    fn binary_data(&mut self, s: &Searcher, off: u64) -> Result<bool, S::Error> {
163        self.inner.binary_data(s, off)
164    }
165
166    // `Sink` gives `begin` and `finish` default no-op implementations, so a
167    // wrapper that forgets to forward them silently swallows them. That is not
168    // cosmetic: `SummarySink` writes its whole result — the `-c` count line,
169    // the `-l` path — from `finish`, and resets its per-search match count in
170    // `begin`. Without these two, `-c` and `-l` print nothing at all.
171    fn begin(&mut self, s: &Searcher) -> Result<bool, S::Error> {
172        self.seen = 0;
173        self.binary_at = None;
174        self.inner.begin(s)
175    }
176
177    fn finish(&mut self, s: &Searcher, f: &SinkFinish) -> Result<(), S::Error> {
178        self.binary_at = f.binary_byte_offset();
179        self.inner.finish(s, f)
180    }
181}
182
183/// `archive.tgz:logs/vhost03.access.log`, or just the path for a plain file.
184pub fn display_path(archive: &Path, member: &str, sep: char) -> String {
185    if member.is_empty() {
186        archive.display().to_string()
187    } else {
188        format!("{}{}{}", archive.display(), sep, member)
189    }
190}
191
192/// Everything a search needs that is the same for every archive in a run.
193///
194/// Borrowed and shared across the whole scheduler: it is `Sync`, so one
195/// instance is read by every worker. The per-job values — the path, the
196/// `Searcher`, the printer, and the inflate strategy — stay as arguments,
197/// because each worker must own its own (`Searcher` is not `Sync`) and the
198/// strategy is chosen per file from its size.
199pub struct SearchCtx<'a> {
200    pub matcher: &'a grep_regex::RegexMatcher,
201    pub sep: char,
202    pub globs: Option<&'a crate::source::Globs>,
203    pub max_count: Option<u64>,
204    /// A request, not a decision: `walk_archive` narrows it to
205    /// archives, so a plain live logfile is never evicted.
206    pub drop_cache: bool,
207    /// Whether the run is in `-c` mode. Not used to choose a printer — `cli`
208    /// already did that — only to decide whether a suppressed count is worth a
209    /// note on stderr.
210    pub count_mode: bool,
211}
212
213/// Whether `-g` excludes this input, for the one case `for_each_member` cannot
214/// decide on its own.
215///
216/// `-g` filters at the finest available granularity: tar member names for
217/// archives, which `for_each_member` applies itself, and the file's own path
218/// for plain (non-tar) files, which arrive here as a single member named `""`.
219/// Without this second half, a mixed sweep of live logs plus archives would
220/// ignore `-g` for every live log.
221fn excluded_plain_file(member: &str, path: &Path, globs: Option<&crate::source::Globs>) -> bool {
222    member.is_empty() && globs.is_some_and(|set| !set.is_match(path))
223}
224
225/// What one member's search concluded, beyond the bytes it printed.
226pub struct MemberVerdict {
227    pub matched: bool,
228    /// The member matched but `-c` printed no count line for it.
229    pub count_suppressed: bool,
230}
231
232/// Search one member, returning whether it matched.
233///
234/// "Did this member match" comes from [`MaxCount::saw_match`] — the searcher's
235/// own tally — and from neither of the two tempting alternatives. Not from
236/// whether bytes reached the buffer: `-q` matches and writes nothing, and `-c`
237/// writes a line whose content is a count. And not from the sink's
238/// `has_match()` either, because `SummarySink` retracts that after seeing
239/// binary data, which would make `-c` exit 1 on an archive `-l`, `-q`,
240/// `--json` and the default mode all agree matched.
241fn search_member(
242    printer: &mut crate::print::Printer<'_>,
243    m: &grep_regex::RegexMatcher,
244    s: &mut Searcher,
245    r: &mut dyn io::Read,
246    display: &str,
247    max_count: Option<u64>,
248) -> io::Result<MemberVerdict> {
249    // The three printers have unrelated sink types, so the body is monomorphic
250    // per arm; a macro keeps the one real sequence — build sink, cap it,
251    // search, ask it what it saw — written once.
252    macro_rules! run {
253        ($p:expr) => {{
254            let sink = $p.sink_with_path(m, display);
255            let mut capped = MaxCount::new(sink, max_count);
256            let res = s.search_reader(m, r, &mut capped);
257            // Read the verdict before propagating: a member that matched and
258            // *then* hit a read error still matched, and the error is recorded
259            // separately by the caller.
260            let hit = capped.saw_match();
261            let suppressed = capped.count_suppressed_by_binary();
262            res.map_err(io::Error::other)?;
263            MemberVerdict { matched: hit, count_suppressed: suppressed }
264        }};
265    }
266
267    Ok(match printer {
268        crate::print::Printer::Standard(p) => run!(p),
269        crate::print::Printer::Summary(p) => run!(p),
270        crate::print::Printer::Json(p) => run!(p),
271    })
272}
273
274/// Search one archive into `printer`'s buffer.
275pub fn search_archive(
276    ctx: &SearchCtx<'_>,
277    path: &Path,
278    s: &mut Searcher,
279    printer: &mut crate::print::Printer<'_>,
280) -> crate::archive::Outcome {
281    let (m, sep, globs, max_count) = (ctx.matcher, ctx.sep, ctx.globs, ctx.max_count);
282
283    let mut matched = false;
284    let mut notes: Vec<String> = Vec::new();
285    let mut out = crate::archive::walk_archive(path, ctx.drop_cache, globs, |member, r| {
286        if excluded_plain_file(member, path, globs) {
287            return Ok(());
288        }
289        let display = display_path(path, member, sep);
290        let v = search_member(printer, m, s, r, &display, max_count)?;
291        matched |= v.matched;
292        // `-c` is otherwise the only mode that goes completely silent on a
293        // member that matched and then hit binary data: stdout stays empty and
294        // the exit code stays 0, so `n=$(trg -c PAT a.tgz)` yields '' and a
295        // script testing `((n>0))` concludes the logs are clean. stdout must
296        // not change — it is rg-compatible, and the count really is unknown —
297        // so the correction goes to stderr, where it costs a pipeline nothing
298        // and an operator everything.
299        if ctx.count_mode && v.count_suppressed {
300            notes.push(format!("{display}: binary data; count suppressed"));
301        }
302        Ok(())
303    });
304    // A note, not an error: nothing is known to be missing, and the format
305    // offers no way to find out. But a 1 is someone's "the logs are clean",
306    // and this file cannot back it up.
307    if out.unverified_end && !matched && out.errors.is_empty() {
308        notes.push(format!(
309            "{}: no match, but .Z cannot detect truncation: a cut-off file reads as a shorter one",
310            path.display()
311        ));
312    }
313    out.matched = matched;
314    out.notes = notes;
315    out
316}
317
318/// `-T/--list-members`: print member paths without searching.
319///
320/// Deliberately does **not** short-circuit on the first member: the exit-code
321/// contract applies here too, so the whole archive is walked and any failure
322/// on the way lands in `Outcome.errors`.
323///
324/// Lists every regular member, including ones a *search* would skip as binary.
325/// Listing is a question about the tar header; skipping a binary member is a
326/// question about its content, which `-T` never reads.
327///
328/// Takes the same `ctx` as [`search_archive`], and opens through the same
329/// [`crate::archive::walk_archive`], because `-T` walks exactly as
330/// many bytes of exactly the same archives. Opening through the plain
331/// `open_decoded` instead would make `-T` the one mode that leaves a sweep's
332/// worth of archive pages in the cache —
333/// precisely the pollution the cache-dropping work exists to prevent.
334pub fn list_members(
335    ctx: &SearchCtx<'_>,
336    path: &Path,
337    out: &mut dyn io::Write,
338) -> crate::archive::Outcome {
339    let (sep, globs) = (ctx.sep, ctx.globs);
340
341    let mut listed = false;
342    let mut outcome = crate::archive::walk_archive(path, ctx.drop_cache, globs, |member, _| {
343        if excluded_plain_file(member, path, globs) {
344            return Ok(());
345        }
346        listed = true;
347        writeln!(out, "{}", display_path(path, member, sep))
348    });
349    // `-T` has no notion of a match, so "listed something" is what stands in
350    // for it: `trg -T ... ; echo $?` should say 1 when an archive held nothing
351    // the globs would accept, the same shape of answer as a search.
352    outcome.matched = listed;
353    outcome
354}