Skip to main content

gobby_code/commands/
grep.rs

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