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