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}