1use std::collections::{BTreeMap, BTreeSet};
2
3use anyhow::Context as _;
4use postgres::Client;
5use postgres::types::ToSql;
6use regex::Regex;
7use serde::Serialize;
8
9use crate::commands::scope;
10use crate::config::{Context, ProjectIndexScope};
11use crate::db;
12use crate::output::{self, Format};
13use crate::search::fts;
14use crate::visibility;
15
16const GREP_SQL_SAFETY_LIMIT: i64 = 100_000;
17
18pub struct GrepOptions<'a> {
19 pub pattern: &'a str,
20 pub paths: &'a [String],
21 pub globs: &'a [String],
22 pub fixed_strings: bool,
23 pub ignore_case: bool,
24 pub context: Option<usize>,
25 pub before_context: Option<usize>,
26 pub after_context: Option<usize>,
27 pub max_count: Option<usize>,
28 pub format: Format,
29}
30
31#[derive(Debug, Clone)]
32struct IndexedContentChunk {
33 file_path: String,
34 line_start: usize,
35 content: String,
36}
37
38#[derive(Debug)]
39struct LoadedIndexedChunks {
40 chunks: Vec<IndexedContentChunk>,
41 truncated: bool,
42}
43
44#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
45pub(crate) struct GrepSpan {
46 pub start: usize,
47 pub end: usize,
48}
49
50#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
51pub(crate) struct GrepContextLine {
52 pub line: usize,
53 pub text: String,
54}
55
56#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
57pub(crate) struct GrepMatch {
58 pub path: String,
59 pub line: usize,
60 pub text: String,
61 pub spans: Vec<GrepSpan>,
62 pub before: Vec<GrepContextLine>,
63 pub after: Vec<GrepContextLine>,
64}
65
66#[derive(Debug, Serialize)]
67struct GrepResponse {
68 project_id: String,
69 pattern: String,
70 fixed_strings: bool,
71 ignore_case: bool,
72 paths: Vec<String>,
73 globs: Vec<String>,
74 max_count: Option<usize>,
75 matched_lines: usize,
76 truncated: bool,
77 scanned_chunks: usize,
78 matches: Vec<GrepMatch>,
79}
80
81#[derive(Debug)]
82struct GrepResult {
83 scanned_chunks: usize,
84 matched_lines: usize,
85 truncated: bool,
86 matches: Vec<GrepMatch>,
87}
88
89pub fn run(ctx: &Context, options: GrepOptions<'_>) -> anyhow::Result<()> {
90 let mut conn = db::connect_readonly(&ctx.database_url)?;
91 let filters = GrepFilters::new(options.paths, options.globs)?;
92 let loaded = load_indexed_chunks(&mut conn, ctx, &filters)?;
93 let mut result = grep_chunks_with_filters(&loaded.chunks, &options, &filters)?;
94 result.truncated |= loaded.truncated;
95
96 match options.format {
97 Format::Json => output::print_json(&GrepResponse {
98 project_id: ctx.project_id.clone(),
99 pattern: options.pattern.to_string(),
100 fixed_strings: options.fixed_strings,
101 ignore_case: options.ignore_case,
102 paths: options.paths.to_vec(),
103 globs: options.globs.to_vec(),
104 max_count: options.max_count,
105 matched_lines: result.matched_lines,
106 truncated: result.truncated,
107 scanned_chunks: result.scanned_chunks,
108 matches: result.matches,
109 }),
110 Format::Text => {
111 let text = format_text_matches(&result.matches);
112 if text.is_empty() {
113 Ok(())
114 } else {
115 output::print_text(&text)
116 }
117 }
118 }
119}
120
121fn load_indexed_chunks(
122 conn: &mut Client,
123 ctx: &Context,
124 filters: &GrepFilters,
125) -> anyhow::Result<LoadedIndexedChunks> {
126 let mut chunks = Vec::new();
127 let tombstone_language = visibility::TOMBSTONE_LANGUAGE;
128 let rows = match &ctx.index_scope {
129 ProjectIndexScope::Single => {
130 let mut params: Vec<&(dyn ToSql + Sync)> = vec![&ctx.project_id, &tombstone_language];
131 let mut conditions = vec![
132 "c.project_id = $1".to_string(),
133 "cf.language != $2".to_string(),
134 ];
135 push_grep_sql_prefilters(&mut conditions, &mut params, "c", filters);
136 let limit = GREP_SQL_SAFETY_LIMIT + 1;
137 let limit_placeholder = format!("${}", params.len() + 1);
138 params.push(&limit);
139 let sql = format!(
140 "SELECT c.file_path,
141 c.line_start::BIGINT AS line_start,
142 c.content
143 FROM code_content_chunks c
144 JOIN code_indexed_files cf
145 ON cf.project_id = c.project_id AND cf.file_path = c.file_path
146 WHERE {}
147 ORDER BY c.file_path ASC, c.line_start ASC, c.chunk_index ASC
148 LIMIT {limit_placeholder}",
149 conditions.join(" AND ")
150 );
151 conn.query(&sql, ¶ms)?
152 }
153 ProjectIndexScope::Overlay {
154 overlay_project_id,
155 parent_project_id,
156 ..
157 } => {
158 let mut params: Vec<&(dyn ToSql + Sync)> =
159 vec![overlay_project_id, parent_project_id, &tombstone_language];
160 let mut conditions = vec![
161 "cf.language != $3".to_string(),
162 "(
163 c.project_id = $1
164 OR (
165 c.project_id = $2
166 AND NOT EXISTS (
167 SELECT 1 FROM code_indexed_files shadow
168 WHERE shadow.project_id = $1
169 AND shadow.file_path = c.file_path
170 )
171 )
172 )"
173 .to_string(),
174 ];
175 push_grep_sql_prefilters(&mut conditions, &mut params, "c", filters);
176 let limit = GREP_SQL_SAFETY_LIMIT + 1;
177 let limit_placeholder = format!("${}", params.len() + 1);
178 params.push(&limit);
179 let sql = format!(
180 "SELECT c.file_path,
181 c.line_start::BIGINT AS line_start,
182 c.content
183 FROM code_content_chunks c
184 JOIN code_indexed_files cf
185 ON cf.project_id = c.project_id AND cf.file_path = c.file_path
186 WHERE {}
187 ORDER BY c.file_path ASC, c.line_start ASC, c.chunk_index ASC
188 LIMIT {limit_placeholder}",
189 conditions.join(" AND ")
190 );
191 conn.query(&sql, ¶ms)?
192 }
193 };
194 let mut valid_paths = BTreeMap::<String, bool>::new();
195 let mut truncated = false;
196 for row in rows {
197 if chunks.len() >= GREP_SQL_SAFETY_LIMIT as usize {
198 truncated = true;
199 break;
200 }
201 let file_path: String = row.try_get("file_path")?;
202 let is_valid = match valid_paths.get(&file_path) {
203 Some(is_valid) => *is_valid,
204 None => {
205 let is_valid = scope::current_indexed_path_is_valid(conn, ctx, &file_path);
206 valid_paths.insert(file_path.clone(), is_valid);
207 is_valid
208 }
209 };
210 if !is_valid {
211 continue;
212 }
213 let line_start = i64_to_usize(row.try_get("line_start")?, "line_start")?;
214 chunks.push(IndexedContentChunk {
215 file_path,
216 line_start,
217 content: row.try_get("content")?,
218 });
219 }
220 if matches!(&ctx.index_scope, ProjectIndexScope::Overlay { .. }) {
221 chunks.sort_by(|a, b| {
222 a.file_path
223 .cmp(&b.file_path)
224 .then_with(|| a.line_start.cmp(&b.line_start))
225 });
226 }
227 Ok(LoadedIndexedChunks { chunks, truncated })
228}
229
230fn push_grep_sql_prefilters<'a>(
231 conditions: &mut Vec<String>,
232 params: &mut Vec<&'a (dyn ToSql + Sync)>,
233 alias: &str,
234 filters: &'a GrepFilters,
235) {
236 push_grep_sql_prefix_filter(
237 conditions,
238 params,
239 alias,
240 filters.path_sql_prefixes.as_ref(),
241 );
242 push_grep_sql_prefix_filter(
243 conditions,
244 params,
245 alias,
246 filters.glob_sql_prefixes.as_ref(),
247 );
248}
249
250fn push_grep_sql_prefix_filter<'a>(
251 conditions: &mut Vec<String>,
252 params: &mut Vec<&'a (dyn ToSql + Sync)>,
253 alias: &str,
254 prefixes: Option<&'a Vec<String>>,
255) {
256 let Some(prefixes) = prefixes else {
257 return;
258 };
259 if prefixes.is_empty() {
260 return;
261 }
262 let placeholder = format!("${}", params.len() + 1);
263 params.push(prefixes);
264 conditions.push(format!(
265 "EXISTS (
266 SELECT 1 FROM unnest({placeholder}::TEXT[]) AS grep_prefix(value)
267 WHERE {alias}.file_path LIKE grep_prefix.value ESCAPE '\\'
268 )"
269 ));
270}
271
272#[cfg(test)]
273fn grep_chunks(
274 chunks: &[IndexedContentChunk],
275 options: &GrepOptions<'_>,
276) -> anyhow::Result<GrepResult> {
277 let filters = GrepFilters::new(options.paths, options.globs)?;
278 grep_chunks_with_filters(chunks, options, &filters)
279}
280
281fn grep_chunks_with_filters(
282 chunks: &[IndexedContentChunk],
283 options: &GrepOptions<'_>,
284 filters: &GrepFilters,
285) -> anyhow::Result<GrepResult> {
286 let matcher = GrepMatcher::new(options.pattern, options.fixed_strings, options.ignore_case)?;
287 let before_context = options.before_context.or(options.context).unwrap_or(0);
288 let after_context = options.after_context.or(options.context).unwrap_or(0);
289
290 let mut scanned_chunks = 0usize;
291 let mut matches: BTreeMap<(String, usize), GrepMatch> = BTreeMap::new();
292
293 for chunk in chunks {
294 if !filters.matches(&chunk.file_path) {
295 continue;
296 }
297 scanned_chunks += 1;
298
299 for (offset, line_text) in chunk.content.lines().enumerate() {
300 let line = chunk.line_start + offset;
301 let key = (chunk.file_path.clone(), line);
302 if matches.contains_key(&key) {
303 continue;
304 }
305
306 let spans = matcher.find_spans(line_text);
307 if !spans.is_empty() {
308 matches.insert(
309 key,
310 GrepMatch {
311 path: chunk.file_path.clone(),
312 line,
313 text: line_text.to_string(),
314 spans,
315 before: Vec::new(),
316 after: Vec::new(),
317 },
318 );
319 }
320 }
321 }
322
323 let total_matching_lines = matches.len();
324 let max = options.max_count.unwrap_or(usize::MAX);
325 let mut retained = matches.into_values().take(max).collect::<Vec<_>>();
326 let needed_context = context_line_numbers(&retained, before_context, after_context);
327 let context_lines = collect_context_lines(chunks, filters, &needed_context);
328 for item in &mut retained {
329 if let Some(lines) = context_lines.get(&item.path) {
330 item.before = context_before(lines, item.line, before_context);
331 item.after = context_after(lines, item.line, after_context);
332 }
333 }
334
335 Ok(GrepResult {
336 scanned_chunks,
337 matched_lines: total_matching_lines,
338 truncated: total_matching_lines > retained.len(),
339 matches: retained,
340 })
341}
342
343fn context_line_numbers(
344 matches: &[GrepMatch],
345 before_context: usize,
346 after_context: usize,
347) -> BTreeMap<String, BTreeSet<usize>> {
348 let mut needed = BTreeMap::<String, BTreeSet<usize>>::new();
349 for item in matches {
350 let lines = needed.entry(item.path.clone()).or_default();
351 if before_context > 0 {
352 for line in item.line.saturating_sub(before_context)..item.line {
353 lines.insert(line);
354 }
355 }
356 if after_context > 0 {
357 let end = item.line.saturating_add(after_context);
358 for line in item.line.saturating_add(1)..=end {
359 lines.insert(line);
360 }
361 }
362 }
363 needed
364}
365
366fn collect_context_lines(
367 chunks: &[IndexedContentChunk],
368 filters: &GrepFilters,
369 needed: &BTreeMap<String, BTreeSet<usize>>,
370) -> BTreeMap<String, BTreeMap<usize, String>> {
371 let mut context_lines = BTreeMap::<String, BTreeMap<usize, String>>::new();
372 if needed.is_empty() {
373 return context_lines;
374 }
375
376 for chunk in chunks {
377 if !filters.matches(&chunk.file_path) {
378 continue;
379 }
380 let Some(needed_lines) = needed.get(&chunk.file_path) else {
381 continue;
382 };
383 for (offset, line_text) in chunk.content.lines().enumerate() {
384 let line = chunk.line_start + offset;
385 if needed_lines.contains(&line) {
386 context_lines
387 .entry(chunk.file_path.clone())
388 .or_default()
389 .entry(line)
390 .or_insert_with(|| line_text.to_string());
391 }
392 }
393 }
394
395 context_lines
396}
397
398struct GrepMatcher {
399 regex: Regex,
400}
401
402impl GrepMatcher {
403 fn new(pattern: &str, fixed_strings: bool, ignore_case: bool) -> anyhow::Result<Self> {
404 if pattern.is_empty() {
405 anyhow::bail!("gcode grep pattern must not be empty");
406 }
407 let pattern = if fixed_strings {
408 regex::escape(pattern)
409 } else {
410 pattern.to_string()
411 };
412 let regex = regex::RegexBuilder::new(&pattern)
413 .case_insensitive(ignore_case)
414 .build()
415 .with_context(|| "invalid gcode grep pattern")?;
416 Ok(Self { regex })
417 }
418
419 fn find_spans(&self, line: &str) -> Vec<GrepSpan> {
420 self.regex
421 .find_iter(line)
422 .filter(|m| m.start() != m.end())
423 .map(|m| GrepSpan {
424 start: m.start(),
425 end: m.end(),
426 })
427 .collect()
428 }
429}
430
431struct GrepFilters {
432 paths: Vec<glob::Pattern>,
433 globs: Vec<CompiledGlob>,
434 path_sql_prefixes: Option<Vec<String>>,
435 glob_sql_prefixes: Option<Vec<String>>,
436}
437
438impl GrepFilters {
439 fn new(paths: &[String], globs: &[String]) -> anyhow::Result<Self> {
440 let expanded_paths = fts::expand_paths(paths);
441 let path_sql_prefixes = sql_like_prefixes(&expanded_paths);
442 let glob_sql_prefixes = sql_like_prefixes(globs);
443 Ok(Self {
444 paths: fts::compile_patterns(&expanded_paths)?,
445 globs: globs
446 .iter()
447 .map(|glob| CompiledGlob::new(glob))
448 .collect::<anyhow::Result<Vec<_>>>()?,
449 path_sql_prefixes,
450 glob_sql_prefixes,
451 })
452 }
453
454 fn matches(&self, file_path: &str) -> bool {
455 let path_matches =
456 self.paths.is_empty() || self.paths.iter().any(|pattern| pattern.matches(file_path));
457 let glob_matches =
458 self.globs.is_empty() || self.globs.iter().any(|glob| glob.matches(file_path));
459 path_matches && glob_matches
460 }
461}
462
463fn sql_like_prefixes(patterns: &[String]) -> Option<Vec<String>> {
464 if patterns.is_empty() {
465 return None;
466 }
467 let mut prefixes = Vec::new();
468 for pattern in patterns {
469 let prefix = pattern
470 .chars()
471 .take_while(|ch| !matches!(ch, '*' | '?' | '['))
472 .collect::<String>();
473 if !prefix.is_empty() {
474 prefixes.push(format!("{}%", escape_like_prefix(&prefix)));
475 }
476 }
477 (!prefixes.is_empty()).then_some(prefixes)
478}
479
480fn escape_like_prefix(value: &str) -> String {
481 let mut escaped = String::with_capacity(value.len());
482 for ch in value.chars() {
483 if matches!(ch, '%' | '_' | '\\') {
484 escaped.push('\\');
485 }
486 escaped.push(ch);
487 }
488 escaped
489}
490
491struct CompiledGlob {
492 raw: String,
493 pattern: glob::Pattern,
494}
495
496impl CompiledGlob {
497 fn new(raw: &str) -> anyhow::Result<Self> {
498 Ok(Self {
499 raw: raw.to_string(),
500 pattern: glob::Pattern::new(raw)
501 .map_err(|err| anyhow::anyhow!("invalid grep glob `{raw}`: {err}"))?,
502 })
503 }
504
505 fn matches(&self, file_path: &str) -> bool {
506 if self.pattern.matches(file_path) {
509 return true;
510 }
511 if self.raw.contains('/') {
512 return false;
513 }
514 file_path
515 .rsplit('/')
516 .next()
517 .is_some_and(|name| self.pattern.matches(name))
518 }
519}
520
521fn context_before(
522 lines: &BTreeMap<usize, String>,
523 line: usize,
524 context: usize,
525) -> Vec<GrepContextLine> {
526 if context == 0 {
527 return Vec::new();
528 }
529 let start = line.saturating_sub(context);
530 lines
531 .range(start..line)
532 .map(|(line, text)| GrepContextLine {
533 line: *line,
534 text: text.clone(),
535 })
536 .collect()
537}
538
539fn context_after(
540 lines: &BTreeMap<usize, String>,
541 line: usize,
542 context: usize,
543) -> Vec<GrepContextLine> {
544 if context == 0 {
545 return Vec::new();
546 }
547 let end = line.saturating_add(context);
548 lines
549 .range((line.saturating_add(1))..=end)
550 .map(|(line, text)| GrepContextLine {
551 line: *line,
552 text: text.clone(),
553 })
554 .collect()
555}
556
557fn format_text_matches(matches: &[GrepMatch]) -> String {
558 let matching_lines: BTreeSet<(String, usize)> =
559 matches.iter().map(|m| (m.path.clone(), m.line)).collect();
560 let mut emitted_context = BTreeSet::new();
561 let mut current_path: Option<&str> = None;
562 let mut lines = Vec::new();
563
564 for item in matches {
565 for context in &item.before {
566 let key = (item.path.clone(), context.line);
567 if !matching_lines.contains(&key) && emitted_context.insert(key) {
568 push_grouped_grep_line(
569 &mut lines,
570 &mut current_path,
571 &item.path,
572 context.line,
573 '-',
574 &context.text,
575 );
576 }
577 }
578
579 push_grouped_grep_line(
580 &mut lines,
581 &mut current_path,
582 &item.path,
583 item.line,
584 ':',
585 &item.text,
586 );
587
588 for context in &item.after {
589 let key = (item.path.clone(), context.line);
590 if !matching_lines.contains(&key) && emitted_context.insert(key) {
591 push_grouped_grep_line(
592 &mut lines,
593 &mut current_path,
594 &item.path,
595 context.line,
596 '-',
597 &context.text,
598 );
599 }
600 }
601 }
602
603 lines.join("\n")
604}
605
606fn push_grouped_grep_line<'a>(
607 lines: &mut Vec<String>,
608 current_path: &mut Option<&'a str>,
609 path: &'a str,
610 line: usize,
611 marker: char,
612 text: &str,
613) {
614 if *current_path != Some(path) {
615 lines.push(path.to_string());
616 *current_path = Some(path);
617 }
618 lines.push(format!("{line}{marker}{}", text.trim_start()));
619}
620
621fn i64_to_usize(value: i64, column: &str) -> anyhow::Result<usize> {
622 value
623 .try_into()
624 .with_context(|| format!("column `{column}` contains negative or too-large value {value}"))
625}
626
627#[cfg(test)]
628mod tests {
629 use super::*;
630
631 fn chunk(path: &str, line_start: usize, content: &str) -> IndexedContentChunk {
632 IndexedContentChunk {
633 file_path: path.to_string(),
634 line_start,
635 content: content.to_string(),
636 }
637 }
638
639 fn options(pattern: &str) -> GrepOptions<'_> {
640 GrepOptions {
641 pattern,
642 paths: &[],
643 globs: &[],
644 fixed_strings: false,
645 ignore_case: false,
646 context: None,
647 before_context: None,
648 after_context: None,
649 max_count: None,
650 format: Format::Json,
651 }
652 }
653
654 #[test]
655 fn text_renders_grouped_grep_shape() {
656 let chunks = vec![chunk("src/lib.rs", 1, "one\nneedle\nthree")];
657 let result = grep_chunks(&chunks, &options("needle")).expect("grep chunks");
658
659 assert_eq!(format_text_matches(&result.matches), "src/lib.rs\n2:needle");
660 }
661
662 #[test]
663 fn text_groups_multiple_files() {
664 let chunks = vec![
665 chunk("src/a.rs", 1, "needle a"),
666 chunk("tests/b.rs", 10, "needle b"),
667 ];
668 let result = grep_chunks(&chunks, &options("needle")).expect("grep chunks");
669
670 assert_eq!(
671 format_text_matches(&result.matches),
672 "src/a.rs\n1:needle a\ntests/b.rs\n10:needle b"
673 );
674 }
675
676 #[test]
677 fn ordering_is_path_then_line() {
678 let chunks = vec![
679 chunk("b.rs", 10, "needle later"),
680 chunk("a.rs", 3, "needle first"),
681 chunk("a.rs", 1, "needle earliest"),
682 ];
683 let result = grep_chunks(&chunks, &options("needle")).expect("grep chunks");
684
685 let keys: Vec<_> = result
686 .matches
687 .iter()
688 .map(|m| (m.path.as_str(), m.line))
689 .collect();
690 assert_eq!(keys, vec![("a.rs", 1), ("a.rs", 3), ("b.rs", 10)]);
691 }
692
693 #[test]
694 fn ignore_case_matches_case_insensitively() {
695 let chunks = vec![chunk("src/lib.rs", 1, "Needle")];
696 let mut opts = options("needle");
697 opts.ignore_case = true;
698 let result = grep_chunks(&chunks, &opts).expect("grep chunks");
699
700 assert_eq!(result.matches.len(), 1);
701 }
702
703 #[test]
704 fn fixed_strings_treat_regex_metacharacters_literally() {
705 let chunks = vec![chunk("src/lib.rs", 1, "a.b\naxb")];
706 let mut opts = options("a.b");
707 opts.fixed_strings = true;
708 let result = grep_chunks(&chunks, &opts).expect("grep chunks");
709
710 assert_eq!(result.matches.len(), 1);
711 assert_eq!(result.matches[0].line, 1);
712 }
713
714 #[test]
715 fn sql_prefix_prefilter_requires_convertible_globs() {
716 let paths = vec!["src/foo_bar".to_string(), "src/foo_bar/**".to_string()];
717 assert_eq!(
718 sql_like_prefixes(&paths).expect("path prefixes"),
719 vec!["src/foo\\_bar%", "src/foo\\_bar/%"]
720 );
721
722 let globs = vec!["*.rs".to_string(), "src/*.rs".to_string()];
723 assert_eq!(
724 sql_like_prefixes(&globs).expect("glob prefixes"),
725 vec!["src/%"]
726 );
727
728 assert_eq!(sql_like_prefixes(&[]), None);
729 assert_eq!(sql_like_prefixes(&["*.rs".to_string()]), None);
730 }
731
732 #[test]
733 fn context_flags_include_bounded_neighbors() {
734 let chunks = vec![chunk("src/lib.rs", 1, "one\ntwo\nneedle\nfour\nfive")];
735 let mut opts = options("needle");
736 opts.before_context = Some(1);
737 opts.after_context = Some(2);
738 let result = grep_chunks(&chunks, &opts).expect("grep chunks");
739 let item = &result.matches[0];
740
741 assert_eq!(
742 item.before,
743 vec![GrepContextLine {
744 line: 2,
745 text: "two".to_string()
746 }]
747 );
748 assert_eq!(
749 item.after,
750 vec![
751 GrepContextLine {
752 line: 4,
753 text: "four".to_string()
754 },
755 GrepContextLine {
756 line: 5,
757 text: "five".to_string()
758 }
759 ]
760 );
761 assert_eq!(
762 format_text_matches(&result.matches),
763 "src/lib.rs\n2-two\n3:needle\n4-four\n5-five"
764 );
765 }
766
767 #[test]
768 fn text_output_trims_leading_whitespace_without_changing_matches() {
769 let chunks = vec![chunk(
770 "src/lib.rs",
771 1,
772 " before\n needle\n\t\tafter",
773 )];
774 let mut opts = options("needle");
775 opts.context = Some(1);
776 let result = grep_chunks(&chunks, &opts).expect("grep chunks");
777 let item = &result.matches[0];
778
779 assert_eq!(item.text, " needle");
780 assert_eq!(item.before[0].text, " before");
781 assert_eq!(item.after[0].text, "\t\tafter");
782 assert_eq!(
783 format_text_matches(&result.matches),
784 "src/lib.rs\n1-before\n2:needle\n3-after"
785 );
786 }
787
788 #[test]
789 fn text_suppresses_duplicate_context_lines() {
790 let chunks = vec![chunk(
791 "src/lib.rs",
792 1,
793 "one\nneedle one\nmiddle\nneedle two\nfive",
794 )];
795 let mut opts = options("needle");
796 opts.context = Some(1);
797 let result = grep_chunks(&chunks, &opts).expect("grep chunks");
798
799 assert_eq!(
800 format_text_matches(&result.matches),
801 "src/lib.rs\n1-one\n2:needle one\n3-middle\n4:needle two\n5-five"
802 );
803 }
804
805 #[test]
806 fn max_count_caps_retained_matches_not_total_matching_lines() {
807 let chunks = vec![chunk(
808 "src/lib.rs",
809 1,
810 "before\nneedle one\nmiddle\nneedle two\nafter",
811 )];
812 let mut opts = options("needle");
813 opts.context = Some(1);
814 opts.max_count = Some(1);
815 let result = grep_chunks(&chunks, &opts).expect("grep chunks");
816
817 assert_eq!(result.matched_lines, 2);
818 assert!(result.truncated);
819 assert_eq!(result.matches[0].line, 2);
820 assert_eq!(result.matches[0].before.len(), 1);
821 assert_eq!(result.matches[0].after.len(), 1);
822 assert_eq!(
823 format_text_matches(&result.matches),
824 "src/lib.rs\n1-before\n2:needle one\n3-middle"
825 );
826 }
827
828 #[test]
829 fn json_match_contains_spans_and_context() {
830 let chunks = vec![chunk("src/lib.rs", 1, "before\nneedle needle\nafter")];
831 let mut opts = options("needle");
832 opts.context = Some(1);
833 let result = grep_chunks(&chunks, &opts).expect("grep chunks");
834 let value = serde_json::to_value(&result.matches[0]).expect("serialize match");
835
836 assert_eq!(value["path"], "src/lib.rs");
837 assert_eq!(value["line"], 2);
838 assert_eq!(value["text"], "needle needle");
839 assert_eq!(value["spans"][0]["start"], 0);
840 assert_eq!(value["spans"][0]["end"], 6);
841 assert_eq!(value["spans"][1]["start"], 7);
842 assert_eq!(value["before"][0]["line"], 1);
843 assert_eq!(value["after"][0]["line"], 3);
844 }
845
846 #[test]
847 fn path_and_glob_filters_compose() {
848 let chunks = vec![
849 chunk("src/gobby/app.py", 1, "needle"),
850 chunk("src/gobby/app.rs", 1, "needle"),
851 chunk("tests/app.py", 1, "needle"),
852 ];
853 let paths = vec!["src/gobby".to_string()];
854 let globs = vec!["*.py".to_string()];
855 let opts = GrepOptions {
856 paths: &paths,
857 globs: &globs,
858 ..options("needle")
859 };
860 let result = grep_chunks(&chunks, &opts).expect("grep chunks");
861
862 assert_eq!(result.scanned_chunks, 1);
863 assert_eq!(result.matches[0].path, "src/gobby/app.py");
864 }
865
866 #[test]
867 fn bare_globs_match_basenames_but_slash_globs_match_paths() {
868 let chunks = vec![
869 chunk("src/app.py", 1, "needle"),
870 chunk("tests/app.py", 1, "needle"),
871 ];
872 let bare = vec!["*.py".to_string()];
873 let slash = vec!["src/*.py".to_string()];
874
875 let bare_result = grep_chunks(
876 &chunks,
877 &GrepOptions {
878 globs: &bare,
879 ..options("needle")
880 },
881 )
882 .expect("bare glob grep");
883 let slash_result = grep_chunks(
884 &chunks,
885 &GrepOptions {
886 globs: &slash,
887 ..options("needle")
888 },
889 )
890 .expect("slash glob grep");
891
892 assert_eq!(bare_result.matches.len(), 2);
893 assert_eq!(slash_result.matches.len(), 1);
894 assert_eq!(slash_result.matches[0].path, "src/app.py");
895 }
896
897 #[test]
898 fn overlapping_chunks_dedupe_by_file_and_line() {
899 let chunks = vec![
900 chunk("src/lib.rs", 1, "needle\nother"),
901 chunk("src/lib.rs", 1, "needle\nother"),
902 ];
903 let result = grep_chunks(&chunks, &options("needle")).expect("grep chunks");
904
905 assert_eq!(result.matches.len(), 1);
906 }
907}