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}