Skip to main content

gobby_code/commands/
grep.rs

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, &params)?
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, &params)?
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        // Match ripgrep-style basename globs (`*.rs`) while keeping slash
485        // globs (`src/*.rs`) scoped to the full indexed path.
486        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}