1use std::collections::HashMap;
9
10use grep::matcher::Matcher;
11
12use crate::confirm::{SearchOptions, build_matcher};
13use crate::cursor::{self, Cursor, Mode};
14use crate::effective_pattern;
15
16pub const DEFAULT_PAGE_SIZE: usize = 50;
18pub const DEFAULT_MAX_COLS: usize = 200;
21
22pub struct CompactOpts {
23 pub mode: Mode,
24 pub start_after: Option<(String, u64)>,
27 pub page_size: usize,
28 pub max_cols: usize,
29}
30
31impl Default for CompactOpts {
32 fn default() -> Self {
33 Self {
34 mode: Mode::Matches,
35 start_after: None,
36 page_size: DEFAULT_PAGE_SIZE,
37 max_cols: DEFAULT_MAX_COLS,
38 }
39 }
40}
41
42pub struct Page {
45 pub header: String,
46 pub body: String,
47 pub total_matches: usize,
48 pub total_files: usize,
49 pub first_index: usize,
52 pub last_index: usize,
53 pub last_key: Option<(String, u64)>,
55 pub has_more: bool,
56 pub fingerprint: u64,
58}
59
60impl Page {
61 pub fn next_cursor(
65 &self,
66 mode: Mode,
67 pattern: String,
68 opts: SearchOptions,
69 page_size: usize,
70 root_hint: Option<String>,
71 ) -> Option<Cursor> {
72 self.has_more.then(|| Cursor {
73 mode,
74 pattern,
75 opts,
76 page_size,
77 last_path: self.last_key.as_ref().map(|(p, _)| p.clone()),
78 last_lineno: self.last_key.as_ref().map_or(0, |(_, l)| *l),
79 prev_total: self.total_matches,
80 fingerprint: self.fingerprint as u32,
81 root_hint,
82 })
83 }
84
85 pub fn staleness_note(&self, prev: Option<(usize, u32)>) -> Option<String> {
89 match prev {
90 Some((prev_total, prev_fp)) if prev_fp != self.fingerprint as u32 => Some(format!(
91 "result set changed since the previous page ({prev_total} -> {} matches)",
92 self.total_matches
93 )),
94 _ => None,
95 }
96 }
97}
98
99fn plural(n: usize) -> &'static str {
100 if n == 1 { "" } else { "s" }
101}
102
103struct Row<'a> {
104 path: &'a str,
105 lineno: u64,
106 is_match: bool,
107 text: &'a str,
108 block: usize,
110}
111
112pub fn format(raw: &[u8], pattern: &str, opts: SearchOptions, c: CompactOpts) -> Page {
117 let text = String::from_utf8_lossy(raw);
118 let rows = parse_rows(&text);
119
120 let mut match_idx: Vec<usize> = rows
127 .iter()
128 .enumerate()
129 .filter(|(_, r)| r.is_match)
130 .map(|(i, _)| i)
131 .collect();
132 match_idx.sort_by(|&a, &b| (rows[a].path, rows[a].lineno).cmp(&(rows[b].path, rows[b].lineno)));
133 let total_matches = match_idx.len();
134
135 let mut files: Vec<&str> = rows.iter().filter(|r| r.is_match).map(|r| r.path).collect();
136 files.sort_unstable();
137 files.dedup();
138 let total_files = files.len();
139
140 let fingerprint =
141 cursor::fingerprint(match_idx.iter().map(|&i| (rows[i].path, rows[i].lineno)));
142 let page_size = c.page_size.max(1);
143
144 match c.mode {
145 Mode::Matches => render_matches(
146 &rows,
147 &match_idx,
148 total_matches,
149 total_files,
150 pattern,
151 opts,
152 &c,
153 page_size,
154 fingerprint,
155 ),
156 Mode::Files | Mode::Count => render_by_file(
157 &rows,
158 &match_idx,
159 &files,
160 total_matches,
161 total_files,
162 &c,
163 page_size,
164 fingerprint,
165 ),
166 }
167}
168
169#[allow(clippy::too_many_arguments)]
170fn render_matches(
171 rows: &[Row],
172 match_idx: &[usize],
173 total_matches: usize,
174 total_files: usize,
175 pattern: &str,
176 opts: SearchOptions,
177 c: &CompactOpts,
178 page_size: usize,
179 fingerprint: u64,
180) -> Page {
181 let skip = match &c.start_after {
183 Some((p, l)) => match_idx
184 .iter()
185 .filter(|&&i| (rows[i].path, rows[i].lineno) <= (p.as_str(), *l))
186 .count(),
187 None => 0,
188 };
189 let window_matches: Vec<usize> = match_idx
190 .iter()
191 .copied()
192 .skip(skip)
193 .take(page_size)
194 .collect();
195 let rendered = window_matches.len();
196 let window: std::collections::HashSet<usize> = window_matches.iter().copied().collect();
197 let first_index = if rendered == 0 { 0 } else { skip + 1 };
198 let last_index = if rendered == 0 { 0 } else { skip + rendered };
199 let has_more = skip + rendered < total_matches;
200 let last_key = window_matches
201 .last()
202 .map(|&i| (rows[i].path.to_string(), rows[i].lineno));
203
204 let header = if total_matches == 0 {
205 "[no matches]".to_string()
206 } else {
207 format!(
208 "[matches {first_index}-{last_index} of {total_matches} in {total_files} file{}]",
209 plural(total_files)
210 )
211 };
212
213 let nearest = nearest_match_per_row(rows);
217 let mut to_render: Vec<usize> = (0..rows.len())
218 .filter(|&i| {
219 if rows[i].is_match {
220 window.contains(&i)
221 } else {
222 nearest[i].is_some_and(|m| window.contains(&m))
223 }
224 })
225 .collect();
226 to_render.sort_by(|&a, &b| (rows[a].path, rows[a].lineno).cmp(&(rows[b].path, rows[b].lineno)));
227 let matcher = build_matcher(&effective_pattern(pattern, opts), opts).ok();
228 let mut body = String::new();
229 let mut cur_path: Option<&str> = None;
230 for &i in &to_render {
231 let r = &rows[i];
232 if cur_path != Some(r.path) {
233 body.push_str(r.path);
234 body.push('\n');
235 cur_path = Some(r.path);
236 }
237 let center = if r.is_match {
238 matcher
239 .as_ref()
240 .and_then(|m| m.find(r.text.as_bytes()).ok().flatten())
241 .map(|mat| mat.start())
242 } else {
243 None
244 };
245 let sep = if r.is_match { ':' } else { '-' };
246 body.push_str(" ");
247 body.push_str(&r.lineno.to_string());
248 body.push(sep);
249 body.push(' ');
250 body.push_str(&truncate_centered(r.text, c.max_cols, center));
251 body.push('\n');
252 }
253
254 Page {
255 header,
256 body,
257 total_matches,
258 total_files,
259 first_index,
260 last_index,
261 has_more,
262 last_key,
263 fingerprint,
264 }
265}
266
267#[allow(clippy::too_many_arguments)]
268fn render_by_file(
269 rows: &[Row],
270 match_idx: &[usize],
271 files: &[&str],
272 total_matches: usize,
273 total_files: usize,
274 c: &CompactOpts,
275 page_size: usize,
276 fingerprint: u64,
277) -> Page {
278 let skip = match &c.start_after {
279 Some((p, _)) => files.iter().filter(|&&f| f <= p.as_str()).count(),
280 None => 0,
281 };
282 let window: Vec<&str> = files.iter().copied().skip(skip).take(page_size).collect();
283 let rendered = window.len();
284 let first_index = if rendered == 0 { 0 } else { skip + 1 };
285 let last_index = if rendered == 0 { 0 } else { skip + rendered };
286 let has_more = skip + rendered < total_files;
287 let last_key = window.last().map(|&p| (p.to_string(), 0));
288
289 let counts: HashMap<&str, usize> = if matches!(c.mode, Mode::Count) {
290 let mut m = HashMap::new();
291 for &i in match_idx {
292 *m.entry(rows[i].path).or_insert(0) += 1;
293 }
294 m
295 } else {
296 HashMap::new()
297 };
298
299 let body: String = match c.mode {
300 Mode::Count => window
301 .iter()
302 .map(|&p| format!("{p}:{}\n", counts.get(p).copied().unwrap_or(0)))
303 .collect(),
304 _ => window.iter().map(|&p| format!("{p}\n")).collect(),
305 };
306
307 let header = if total_files == 0 {
308 "[no matches]".to_string()
309 } else if matches!(c.mode, Mode::Count) {
310 format!(
311 "[count {first_index}-{last_index} of {total_files} file{} \u{b7} {total_matches} match{}]",
312 plural(total_files),
313 if total_matches == 1 { "" } else { "es" }
314 )
315 } else {
316 format!("[files {first_index}-{last_index} of {total_files}]")
317 };
318
319 Page {
320 header,
321 body,
322 total_matches,
323 total_files,
324 first_index,
325 last_index,
326 has_more,
327 last_key,
328 fingerprint,
329 }
330}
331
332type Cand<'a> = (&'a str, u64, bool, &'a str);
334
335enum Entry<'a> {
336 Break,
338 Row(Option<Cand<'a>>, Option<Cand<'a>>),
340}
341
342fn parse_rows(text: &str) -> Vec<Row<'_>> {
343 let entries: Vec<Entry> = text
344 .lines()
345 .filter_map(|line| {
346 if line == "--" {
347 return Some(Entry::Break);
348 }
349 let m = split_on(line, b':').map(|(p, n, t)| (p, n, true, t));
350 let c = split_on(line, b'-').map(|(p, n, t)| (p, n, false, t));
351 match (m, c) {
352 (None, None) => None, (m, c) => Some(Entry::Row(m, c)),
354 }
355 })
356 .collect();
357
358 let anchors: Vec<Option<&str>> = entries
363 .iter()
364 .map(|e| match e {
365 Entry::Row(Some(m), None) => Some(m.0),
366 Entry::Row(None, Some(c)) => Some(c.0),
367 _ => None,
368 })
369 .collect();
370
371 let mut rows = Vec::new();
372 let mut block = 0usize;
373 let mut prev_path: Option<&str> = None;
374 let mut pending_break = false;
375 for (i, e) in entries.iter().enumerate() {
376 let (path, lineno, is_match, body) = match e {
377 Entry::Break => {
378 pending_break = true;
379 continue;
380 }
381 Entry::Row(Some(m), None) => *m,
382 Entry::Row(None, Some(c)) => *c,
383 Entry::Row(Some(m), Some(c)) => {
384 let near = nearest_anchor(&anchors, i);
387 if near == Some(c.0) && near != Some(m.0) {
388 *c
389 } else {
390 *m
391 }
392 }
393 Entry::Row(None, None) => continue,
394 };
395 if let Some(pp) = prev_path
396 && (pp != path || pending_break)
397 {
398 block += 1;
399 }
400 pending_break = false;
401 prev_path = Some(path);
402 rows.push(Row {
403 path,
404 lineno,
405 is_match,
406 text: body,
407 block,
408 });
409 }
410 rows
411}
412
413fn nearest_anchor<'a>(anchors: &[Option<&'a str>], i: usize) -> Option<&'a str> {
415 (1..anchors.len()).find_map(|d| {
416 i.checked_sub(d)
417 .and_then(|j| anchors[j])
418 .or_else(|| anchors.get(i + d).copied().flatten())
419 })
420}
421
422fn split_on(line: &str, sep: u8) -> Option<(&str, u64, &str)> {
423 let bytes = line.as_bytes();
424 let mut i = 0;
425 while i < bytes.len() {
426 if bytes[i] == sep {
427 let rest = &bytes[i + 1..];
428 let digits = rest.iter().take_while(|b| b.is_ascii_digit()).count();
429 if digits > 0 && rest.get(digits) == Some(&sep) {
430 let lineno: u64 = line[i + 1..i + 1 + digits].parse().ok()?;
431 return Some((&line[..i], lineno, &line[i + 1 + digits + 1..]));
432 }
433 }
434 i += 1;
435 }
436 None
437}
438
439fn nearest_match_per_row(rows: &[Row]) -> Vec<Option<usize>> {
442 let n = rows.len();
443 let mut out = vec![None; n];
444 let mut last: Option<usize> = None;
446 for i in 0..n {
447 if rows[i].is_match {
448 last = Some(i);
449 }
450 out[i] = last.filter(|&m| rows[m].block == rows[i].block);
451 }
452 let mut next: Option<usize> = None;
453 for i in (0..n).rev() {
454 if rows[i].is_match {
455 next = Some(i);
456 }
457 let fwd = next.filter(|&m| rows[m].block == rows[i].block);
458 out[i] = match (out[i], fwd) {
459 (Some(b), Some(f)) => Some(if i - b <= f - i { b } else { f }),
460 (b, f) => b.or(f),
461 };
462 }
463 out
464}
465
466fn truncate_centered(text: &str, max_cols: usize, center: Option<usize>) -> String {
469 let char_count = text.chars().count();
470 if char_count <= max_cols {
471 return text.to_string();
472 }
473 let center_char = match center {
474 Some(byte) => {
475 let mut b = byte.min(text.len());
478 while b > 0 && !text.is_char_boundary(b) {
479 b -= 1;
480 }
481 text[..b].chars().count()
482 }
483 None => 0,
484 };
485 let before = max_cols / 3;
486 let start = center_char
487 .saturating_sub(before)
488 .min(char_count - max_cols);
489 let end = start + max_cols;
490
491 let char_byte = |ci: usize| {
492 text.char_indices()
493 .nth(ci)
494 .map(|(b, _)| b)
495 .unwrap_or(text.len())
496 };
497 let slice = &text[char_byte(start)..char_byte(end)];
498 let mut out = String::new();
499 if start > 0 {
500 out.push('\u{2026}');
501 }
502 out.push_str(slice);
503 if end < char_count {
504 out.push('\u{2026}');
505 }
506 out
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512
513 const RAW: &[u8] = b"src/a.rs:1:fn one() {}\n\
514src/a.rs:2:fn two() {}\n\
515src/b.rs:10:fn three() {}\n";
516
517 fn page(raw: &[u8], pattern: &str, c: CompactOpts) -> Page {
518 format(raw, pattern, SearchOptions::default(), c)
519 }
520
521 #[test]
522 fn groups_by_file_with_counts() {
523 let p = page(RAW, "fn", CompactOpts::default());
524 assert_eq!(p.total_matches, 3);
525 assert_eq!(p.total_files, 2);
526 assert!(!p.has_more);
527 assert_eq!(p.body.matches("src/a.rs\n").count(), 1);
529 assert!(
530 p.body
531 .contains("src/a.rs\n 1: fn one() {}\n 2: fn two() {}\n")
532 );
533 assert!(p.body.contains("src/b.rs\n 10: fn three() {}\n"));
534 assert!(p.header.contains("matches 1-3 of 3 in 2 files"));
535 }
536
537 #[test]
538 fn paginates_without_dropping_matches() {
539 let p1 = page(
540 RAW,
541 "fn",
542 CompactOpts {
543 page_size: 2,
544 ..Default::default()
545 },
546 );
547 assert!(p1.has_more);
548 assert_eq!((p1.first_index, p1.last_index), (1, 2));
549 assert!(p1.body.contains(" 1: fn one"));
550 assert!(p1.body.contains(" 2: fn two"));
551 assert!(!p1.body.contains("three"));
552
553 let p2 = page(
554 RAW,
555 "fn",
556 CompactOpts {
557 page_size: 2,
558 start_after: p1.last_key.clone(),
559 ..Default::default()
560 },
561 );
562 assert!(!p2.has_more);
563 assert_eq!((p2.first_index, p2.last_index), (3, 3));
564 assert!(p2.body.contains("src/b.rs\n 10: fn three"));
565 assert!(!p2.body.contains("fn one"));
566 }
567
568 #[test]
569 fn keyset_survives_unsorted_input_without_dropping_matches() {
570 const UNSORTED: &[u8] = b"src/a/b.rs:1:fn x\n\
574src/a.rs:1:fn y\n\
575src/ab.rs:1:fn z\n";
576 let mut seen = Vec::new();
577 let mut start_after = None;
578 for _ in 0..5 {
579 let p = page(
580 UNSORTED,
581 "fn",
582 CompactOpts {
583 page_size: 1,
584 start_after: start_after.clone(),
585 ..Default::default()
586 },
587 );
588 assert_eq!(p.total_matches, 3);
589 for line in p.body.lines().filter(|l| l.starts_with(" ")) {
590 seen.push(line.to_string());
591 }
592 if !p.has_more {
593 break;
594 }
595 start_after = p.last_key.clone();
596 }
597 assert_eq!(seen, vec![" 1: fn y", " 1: fn x", " 1: fn z"]);
599 }
600
601 #[test]
602 fn keyset_resume_after_last_key() {
603 let p1 = page(
605 RAW,
606 "fn",
607 CompactOpts {
608 page_size: 1,
609 ..Default::default()
610 },
611 );
612 assert_eq!(p1.last_key, Some(("src/a.rs".to_string(), 1)));
613 let p2 = page(
614 RAW,
615 "fn",
616 CompactOpts {
617 page_size: 1,
618 start_after: p1.last_key.clone(),
619 ..Default::default()
620 },
621 );
622 assert!(p2.body.contains(" 2: fn two"));
623 assert!(!p2.body.contains("fn one"));
624 assert_eq!((p2.first_index, p2.last_index), (2, 2));
625 }
626
627 #[test]
628 fn files_mode_lists_paths() {
629 let p = page(
630 RAW,
631 "fn",
632 CompactOpts {
633 mode: Mode::Files,
634 ..Default::default()
635 },
636 );
637 assert_eq!(p.total_files, 2);
638 assert!(p.header.contains("files 1-2 of 2"));
639 assert!(p.body.contains("src/a.rs\n"));
640 assert!(p.body.contains("src/b.rs\n"));
641 assert!(!p.body.contains("fn one")); }
643
644 #[test]
645 fn count_mode_tallies_per_file() {
646 let p = page(
647 RAW,
648 "fn",
649 CompactOpts {
650 mode: Mode::Count,
651 ..Default::default()
652 },
653 );
654 assert!(p.body.contains("src/a.rs:2\n"));
655 assert!(p.body.contains("src/b.rs:1\n"));
656 assert!(p.header.contains("count 1-2 of 2 files"));
657 assert!(p.header.contains("3 matches"));
658 }
659
660 #[test]
661 fn fingerprint_stable_across_calls_and_pages() {
662 let full = page(RAW, "fn", CompactOpts::default());
663 let paged = page(
664 RAW,
665 "fn",
666 CompactOpts {
667 page_size: 1,
668 ..Default::default()
669 },
670 );
671 assert_eq!(full.fingerprint, paged.fingerprint);
672 assert_ne!(full.fingerprint, 0);
673 }
674
675 #[test]
676 fn truncates_long_line_centered_on_match() {
677 let long = format!("src/x.rs:1:{}NEEDLE{}\n", "a".repeat(400), "b".repeat(400));
678 let p = page(
679 long.as_bytes(),
680 "NEEDLE",
681 CompactOpts {
682 max_cols: 60,
683 ..Default::default()
684 },
685 );
686 let line = p.body.lines().find(|l| l.contains("NEEDLE")).unwrap();
687 assert!(line.contains('\u{2026}'), "expected ellipsis: {line}");
688 assert!(line.chars().count() < 100);
689 }
690
691 #[test]
692 fn truncates_long_multibyte_line_without_panicking() {
693 let long = format!(
694 "src/x.rs:1:{}café NEEDLE {}\n",
695 "é".repeat(300),
696 "ü".repeat(300)
697 );
698 let p = page(
699 long.as_bytes(),
700 "NEEDLE",
701 CompactOpts {
702 max_cols: 50,
703 ..Default::default()
704 },
705 );
706 let line = p.body.lines().find(|l| l.contains("NEEDLE")).unwrap();
707 assert!(line.contains('\u{2026}'));
708 assert!(line.chars().count() < 90);
709 }
710
711 #[test]
712 fn empty_input_has_no_body() {
713 let p = page(b"", "fn", CompactOpts::default());
714 assert_eq!(p.total_matches, 0);
715 assert!(!p.has_more);
716 assert!(p.body.is_empty());
717 assert_eq!(p.header, "[no matches]");
718 }
719
720 #[test]
721 fn context_lines_attach_to_their_match_and_dont_count() {
722 let raw = b"f.rs-4-before a\n\
724f.rs:5:MATCH a\n\
725f.rs-6-after a\n\
726--\n\
727f.rs-9-before b\n\
728f.rs:10:MATCH b\n\
729f.rs-11-after b\n";
730 let p = page(
731 raw,
732 "MATCH",
733 CompactOpts {
734 page_size: 1,
735 ..Default::default()
736 },
737 );
738 assert_eq!(p.total_matches, 2);
740 assert_eq!(p.total_files, 1);
741 assert!(p.has_more);
742 assert!(p.body.contains(" 5: MATCH a"));
744 assert!(p.body.contains(" 4- before a"));
745 assert!(p.body.contains(" 6- after a"));
746 assert!(!p.body.contains("MATCH b"));
747 assert!(!p.body.contains("before b"));
748 }
749
750 #[test]
751 fn context_line_with_colon_digits_in_text_is_not_misparsed() {
752 let raw = b"f.txt-2-log at 12:34:56 here\n\
755f.txt:3:TARGET match\n\
756f.txt-4-after line\n";
757 let p = page(raw, "TARGET", CompactOpts::default());
758 assert_eq!(p.total_matches, 1, "{}", p.body);
759 assert_eq!(p.total_files, 1, "{}", p.body);
760 assert!(p.body.contains("f.txt\n 2- log at 12:34:56 here"));
761 assert!(p.body.contains(" 3: TARGET match"));
762 assert!(!p.body.contains("12\n"));
763 }
764
765 #[test]
766 fn colon_separator_wins_over_hyphen_in_path() {
767 let p = page(
769 b"src/a-b-2.rs:42:let x-1 = y-2;\n",
770 "let",
771 CompactOpts::default(),
772 );
773 assert!(
774 p.body.contains("src/a-b-2.rs\n 42: let x-1 = y-2;"),
775 "{}",
776 p.body
777 );
778 assert_eq!(p.total_matches, 1);
779 }
780
781 #[test]
782 fn start_after_past_end_is_empty() {
783 let p = page(
784 RAW,
785 "fn",
786 CompactOpts {
787 start_after: Some(("zzz".to_string(), 0)),
788 ..Default::default()
789 },
790 );
791 assert!(!p.has_more);
792 assert_eq!(p.first_index, 0);
793 assert_eq!(p.last_index, 0);
794 assert!(p.body.is_empty());
795 assert_eq!(p.last_key, None);
796 }
797}