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