Skip to main content

vtcode_commons/
content_search.rs

1//! Native content search over a workspace tree.
2//!
3//! This replaces the external `ripgrep` binary that VT Code used to download
4//! and spawn. The engine walks the tree with the `ignore` crate (so
5//! `.gitignore`, `.ignore`, `.git/exclude`, and `.vtcodegitignore` keep
6//! applying), then scans each file's bytes with the `regex` crate.
7//!
8//! Two matching modes exist, mirroring ripgrep's:
9//!
10//! - **Literal**: the pattern is escaped and matched verbatim, so regex
11//!   metacharacters in the pattern carry no meaning.
12//! - **Regex**: the pattern is compiled as a regular expression.
13//!
14//! Matching is line-oriented: a match is reported with the line that contains
15//! it, the byte column of the match within that line, and the file-absolute
16//! byte offsets of the match and of the line it sits on. That keeps the
17//! result shape identical to ripgrep's `--json` stream, which the tool layer
18//! still consumes.
19
20use std::path::{Path, PathBuf};
21use std::result::Result;
22
23use regex::Regex;
24
25/// How the search pattern should be interpreted.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum MatchMode {
28    /// The pattern is escaped and matched verbatim.
29    Literal,
30    /// The pattern is compiled as a regular expression.
31    Regex,
32}
33
34/// Whether matching is case-sensitive.
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum CaseSensitivity {
37    /// Match exactly as written.
38    Sensitive,
39    /// Match regardless of case.
40    Insensitive,
41    /// Case-insensitive when the pattern is all lower-case, case-sensitive once
42    /// it contains an upper-case character.
43    Smart,
44}
45
46/// A single matching line within a file.
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct LineMatch {
49    /// Path of the file the match was found in.
50    pub path: PathBuf,
51    /// One-based line number of the match.
52    pub line: usize,
53    /// One-based byte column of the match start, counted from the start of
54    /// the line, matching `rg --column`.
55    pub column: usize,
56    /// Byte offset of the start of the matching line within the file.
57    ///
58    /// This is the anchor for the line-relative match offsets, exactly like
59    /// ripgrep's `absolute_offset`.
60    pub line_start: usize,
61    /// Byte offset of the match start within the file.
62    pub byte_start: usize,
63    /// Byte offset of the match end within the file.
64    pub byte_end: usize,
65    /// The text of the matching line, without its trailing newline.
66    pub line_text: String,
67    /// The matched substring itself.
68    pub matched_text: String,
69    /// Whether the match is a whole word.
70    ///
71    /// A match is word-bounded when the characters immediately before and
72    /// after it are not identifier characters, so `Widget` inside
73    /// `WidgetExtra` is not word-bounded.
74    pub word_bounded: bool,
75}
76
77/// Configuration for a content search.
78#[derive(Debug, Clone)]
79pub struct SearchRequest {
80    /// The pattern to match.
81    pub pattern: String,
82    /// Root directory (or file) to search.
83    pub root: PathBuf,
84    /// How to interpret [`SearchRequest::pattern`].
85    pub mode: MatchMode,
86    /// Whether matching is case-sensitive.
87    pub case: CaseSensitivity,
88    /// Only report files whose path matches one of these globs.
89    ///
90    /// Matched case-sensitively, mirroring ripgrep's `--glob`.
91    pub include_globs: Vec<String>,
92    /// Only report files whose path matches one of these globs.
93    ///
94    /// Matched case-insensitively, mirroring ripgrep's `--iglob`, which is
95    /// what the language filters use so `*.RS` still matches a Rust file.
96    pub language_globs: Vec<String>,
97    /// Never report files whose path matches one of these globs.
98    pub exclude_globs: Vec<String>,
99    /// Maximum number of matches to report.
100    pub max_results: usize,
101    /// Maximum number of matches to report per file.
102    pub max_per_file: usize,
103    /// Skip files larger than this many bytes.
104    pub max_file_size: Option<u64>,
105    /// Include files inside hidden directories.
106    pub include_hidden: bool,
107    /// Honour `.gitignore`, `.ignore`, and `.git/exclude` while walking.
108    pub respect_ignore_files: bool,
109    /// Report files that contain a match instead of the matching lines.
110    pub files_with_matches: bool,
111}
112
113impl SearchRequest {
114    /// Creates a literal, case-sensitive search request.
115    #[must_use]
116    pub fn literal(pattern: impl Into<String>, root: impl Into<PathBuf>) -> Self {
117        Self {
118            pattern: pattern.into(),
119            root: root.into(),
120            mode: MatchMode::Literal,
121            case: CaseSensitivity::Smart,
122            include_globs: Vec::new(),
123            language_globs: Vec::new(),
124            exclude_globs: Vec::new(),
125            max_results: 100,
126            max_per_file: usize::MAX,
127            max_file_size: None,
128            include_hidden: false,
129            respect_ignore_files: true,
130            files_with_matches: false,
131        }
132    }
133
134    /// Creates a regex, case-sensitive search request.
135    #[must_use]
136    pub fn regex(pattern: impl Into<String>, root: impl Into<PathBuf>) -> Self {
137        Self {
138            mode: MatchMode::Regex,
139            ..Self::literal(pattern, root)
140        }
141    }
142
143    /// Sets the case-sensitivity of the search.
144    #[must_use]
145    pub fn with_case(mut self, case: CaseSensitivity) -> Self {
146        self.case = case;
147        self
148    }
149
150    /// Sets the maximum number of matches reported overall.
151    #[must_use]
152    pub fn with_max_results(mut self, max_results: usize) -> Self {
153        self.max_results = max_results;
154        self
155    }
156
157    /// Sets the maximum number of matches reported per file.
158    #[must_use]
159    pub fn with_max_per_file(mut self, max_per_file: usize) -> Self {
160        self.max_per_file = max_per_file;
161        self
162    }
163
164    /// Adds a glob that candidate file paths must match.
165    ///
166    /// The glob is matched case-sensitively, like ripgrep's `--glob`.
167    #[must_use]
168    pub fn with_include_glob(mut self, glob: impl Into<String>) -> Self {
169        self.include_globs.push(glob.into());
170        self
171    }
172
173    /// Adds a glob that candidate file paths must match, case-insensitively.
174    ///
175    /// This mirrors ripgrep's `--iglob`, which the language filters use so a
176    /// `*.RS` file still counts as Rust.
177    #[must_use]
178    pub fn with_language_glob(mut self, glob: impl Into<String>) -> Self {
179        self.language_globs.push(glob.into());
180        self
181    }
182
183    /// Includes hidden files and directories in the walk.
184    #[must_use]
185    pub fn with_hidden(mut self) -> Self {
186        self.include_hidden = true;
187        self
188    }
189
190    /// Sets the maximum size of a file that may be scanned.
191    #[must_use]
192    pub fn with_max_file_size(mut self, max_file_size: u64) -> Self {
193        self.max_file_size = Some(max_file_size);
194        self
195    }
196
197    /// Reports only the files that contain a match, not the matching lines.
198    #[must_use]
199    pub fn with_files_with_matches(mut self) -> Self {
200        self.files_with_matches = true;
201        self
202    }
203
204    /// Excludes files whose path matches `glob`.
205    ///
206    /// Exclusions are applied after inclusions, so an excluded file never
207    /// reaches the scanner even when it also matches an include glob.
208    #[must_use]
209    pub fn with_exclude_glob(mut self, glob: impl Into<String>) -> Self {
210        self.exclude_globs.push(glob.into());
211        self
212    }
213
214    /// Disables ignore-file handling, so every file in the tree is scanned.
215    #[must_use]
216    pub fn without_ignore_files(mut self) -> Self {
217        self.respect_ignore_files = false;
218        self
219    }
220
221    /// Compiles the request's pattern into a matcher.
222    ///
223    /// Returns `Err` when the pattern is not a valid regular expression. A
224    /// literal pattern always compiles.
225    pub fn compile(&self) -> Result<Regex, regex::Error> {
226        let pattern = match self.mode {
227            MatchMode::Literal => regex::escape(&self.pattern),
228            MatchMode::Regex => self.pattern.clone(),
229        };
230        let pattern = match self.case {
231            CaseSensitivity::Sensitive => pattern,
232            CaseSensitivity::Insensitive => format!("(?i){pattern}"),
233            // Smart case only relaxes the match when the pattern carries no
234            // upper-case character, so an explicit `Foo` still matches `Foo`.
235            CaseSensitivity::Smart => {
236                if self.pattern.chars().any(char::is_uppercase) {
237                    pattern
238                } else {
239                    format!("(?i){pattern}")
240                }
241            }
242        };
243        Regex::new(&pattern)
244    }
245}
246
247/// Errors that can occur while running a content search.
248#[derive(Debug)]
249pub enum SearchError {
250    /// The pattern was not a valid regular expression.
251    InvalidPattern(regex::Error),
252}
253
254impl std::fmt::Display for SearchError {
255    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256        match self {
257            Self::InvalidPattern(error) => write!(formatter, "invalid search pattern: {error}"),
258        }
259    }
260}
261
262impl std::error::Error for SearchError {}
263
264impl From<regex::Error> for SearchError {
265    fn from(error: regex::Error) -> Self {
266        Self::InvalidPattern(error)
267    }
268}
269
270/// Runs `request` against the tree and returns the matching lines.
271///
272/// The walk stops once `request.max_results` matches have been collected, so a
273/// broad pattern over a large tree does not scan the whole repository.
274pub fn search(request: &SearchRequest) -> Result<Vec<LineMatch>, SearchError> {
275    let matcher = request.compile()?;
276    let mut matches = Vec::new();
277
278    // The candidate files are sorted before scanning so that a capped result
279    // set is a stable, path-ordered prefix rather than whichever files the
280    // directory walk happened to reach first.
281    let mut candidates = candidate_files(request);
282    candidates.sort();
283
284    for path in candidates {
285        // The remaining budget bounds both the per-file cap and the total, so
286        // one large file cannot push the result set past `max_results`.
287        let remaining = request.max_results.saturating_sub(matches.len());
288        if remaining == 0 {
289            break;
290        }
291        let Ok(content) = std::fs::read_to_string(&path) else {
292            continue;
293        };
294        // In path-only mode one hit per file is enough, so the scan stops at
295        // the first match instead of collecting every hit in the file.
296        let per_file_cap = if request.files_with_matches {
297            1
298        } else {
299            request.max_per_file.min(remaining)
300        };
301        let file_matches = scan_file(&path, &content, &matcher, per_file_cap);
302        if file_matches.is_empty() {
303            continue;
304        }
305        if request.files_with_matches {
306            // Only the path matters in this mode, so one record per file is
307            // kept. The record is the file's first real match, so every field
308            // stays truthful instead of pointing at a fabricated offset.
309            matches.extend(file_matches.into_iter().next());
310        } else {
311            matches.extend(file_matches);
312        }
313    }
314
315    // Sorting by path makes a capped result set a stable prefix rather than
316    // whatever order the directory walk happened to produce.
317    matches.sort_by(|left, right| {
318        left.path
319            .cmp(&right.path)
320            .then_with(|| left.line.cmp(&right.line))
321            .then_with(|| left.column.cmp(&right.column))
322    });
323    Ok(matches)
324}
325
326/// Walks the request's root and yields the files eligible for scanning.
327fn candidate_files(request: &SearchRequest) -> Vec<PathBuf> {
328    // A search root that is a single file is scanned directly: the walker
329    // only yields directories, so a file root would otherwise produce nothing.
330    if request.root.is_file() {
331        return vec![request.root.clone()];
332    }
333    let mut builder = crate::walk::build_default_walker(&request.root);
334    let _ = builder.hidden(!request.include_hidden);
335    let _ = builder.filter_entry(|entry| !crate::walk::is_excluded_dir(entry));
336    if !request.respect_ignore_files {
337        let _ = builder.git_ignore(false);
338        let _ = builder.git_global(false);
339        let _ = builder.git_exclude(false);
340        let _ = builder.ignore(false);
341        let _ = builder.parents(false);
342    }
343
344    // The language globs are compared case-insensitively, so they are
345    // lower-cased once here instead of once per candidate file.
346    let language_globs = lowercase_globs(&request.language_globs);
347
348    builder
349        .build()
350        .filter_map(Result::ok)
351        .filter(|entry| entry.file_type().is_some_and(|kind| kind.is_file()))
352        .map(|entry| entry.into_path())
353        .filter(|path| {
354            // The relative path is empty when the search root is the file
355            // itself, so the file name is used instead: a glob must still be
356            // able to select the file that was searched directly.
357            let relative = path.strip_prefix(&request.root).unwrap_or(path);
358            let relative = if relative.as_os_str().is_empty() {
359                path
360            } else {
361                relative
362            };
363            path_matches_include_globs(relative, &request.include_globs)
364                && path_matches_language_globs(relative, &language_globs)
365                && !path_matches_exclude_globs(relative, &request.exclude_globs)
366        })
367        .filter(|path| {
368            request
369                .max_file_size
370                .is_none_or(|limit| std::fs::metadata(path).map(|meta| meta.len() <= limit).unwrap_or(false))
371        })
372        .collect()
373}
374
375/// Whether `relative_path` matches any of the include `globs`.
376///
377/// An empty list includes everything. Globs follow gitignore-style rules: a
378/// pattern without a slash matches the file name at any depth (`*.rs`), while
379/// a pattern with a slash matches the whole relative path (`src/**`).
380fn path_matches_include_globs(relative_path: &Path, globs: &[String]) -> bool {
381    if globs.is_empty() {
382        return true;
383    }
384    globs.iter().any(|glob| glob_matches_path(relative_path, glob))
385}
386
387/// Lower-cases a list of globs so they can be matched case-insensitively.
388fn lowercase_globs(globs: &[String]) -> Vec<String> {
389    globs.iter().map(|glob| glob.to_ascii_lowercase()).collect()
390}
391
392/// Whether `relative_path` matches any of the case-insensitive language
393/// globs.
394///
395/// Both sides are lower-cased — the globs by [`lowercase_globs`], the path by
396/// [`lowercase_relative_path`] — so `*.RS` matches `main.rs`.
397fn path_matches_language_globs(relative_path: &Path, globs: &[String]) -> bool {
398    if globs.is_empty() {
399        return true;
400    }
401    let Some(lowered) = lowercase_relative_path(relative_path) else {
402        return false;
403    };
404    let lowered = Path::new(&lowered);
405    globs.iter().any(|glob| {
406        glob_matches_path(lowered, glob)
407            // A language glob is an extension filter, so it must also match
408            // the file name when the relative path carries directories.
409            || lowered.file_name().is_some_and(|name| glob_matches_path(Path::new(name), glob))
410    })
411}
412
413/// Lower-cases a relative path so it can be matched case-insensitively.
414///
415/// The result keeps the directory separators so it can be re-parsed as a
416/// path, and it is empty only when the relative path itself is empty.
417fn lowercase_relative_path(relative_path: &Path) -> Option<String> {
418    let mut lowered = String::new();
419    for component in relative_path.components() {
420        let std::path::Component::Normal(name) = component else {
421            return None;
422        };
423        if !lowered.is_empty() {
424            lowered.push('/');
425        }
426        lowered.push_str(&name.to_string_lossy().to_ascii_lowercase());
427    }
428    Some(lowered)
429}
430/// Whether `relative_path` is excluded by any of the exclude `globs`.
431///
432/// An empty list excludes nothing. Globs follow the same rules as includes, so
433/// `**/target/**` excludes a `target` directory at any depth.
434fn path_matches_exclude_globs(relative_path: &Path, globs: &[String]) -> bool {
435    globs.iter().any(|glob| glob_matches_path(relative_path, glob))
436}
437
438/// Whether a character can appear inside an identifier.
439fn is_identifier_char(character: char) -> bool {
440    character.is_alphanumeric() || character == '_'
441}
442
443/// Whether `relative_path` matches a single gitignore-style glob.
444///
445/// `**` matches any number of path components, `*` matches any run of
446/// characters within one component, and `?` matches exactly one character. A
447/// pattern without a slash is matched against the file name alone.
448fn glob_matches_path(relative_path: &Path, glob: &str) -> bool {
449    let glob = glob.trim_start_matches('/');
450    let pattern_components: Vec<&str> = glob.split('/').collect();
451    let path_components: Vec<String> = relative_path
452        .components()
453        .filter_map(|component| match component {
454            std::path::Component::Normal(name) => name.to_str().map(str::to_owned),
455            _ => None,
456        })
457        .collect();
458
459    if !glob.contains('/') {
460        let Some(name) = path_components.last() else {
461            return false;
462        };
463        return match_components(&pattern_components, &[name.as_str()]);
464    }
465
466    let path_components: Vec<&str> = path_components.iter().map(String::as_str).collect();
467    match_components(&pattern_components, &path_components)
468}
469
470/// Matches `pattern` against `path` component by component.
471fn match_components(pattern: &[&str], path: &[&str]) -> bool {
472    match pattern.split_first() {
473        None => path.is_empty(),
474        Some((&"**", rest)) => {
475            // `**` matches zero or more components.
476            (0..=path.len()).any(|skip| match_components(rest, path.get(skip..).unwrap_or_default()))
477        }
478        Some((&head, rest)) => match path.split_first() {
479            Some((&first, tail)) if component_matches(head, first) => match_components(rest, tail),
480            _ => false,
481        },
482    }
483}
484
485/// Whether one path component matches one pattern component.
486fn component_matches(pattern: &str, component: &str) -> bool {
487    let pattern: Vec<char> = pattern.chars().collect();
488    let component: Vec<char> = component.chars().collect();
489    component_glob_matches(&pattern, &component)
490}
491
492/// Matches a single-component glob against a single-component name.
493fn component_glob_matches(pattern: &[char], component: &[char]) -> bool {
494    match pattern.split_first() {
495        None => component.is_empty(),
496        Some((&'*', rest)) => {
497            // `*` matches zero or more characters.
498            (0..=component.len()).any(|skip| component_glob_matches(rest, component.get(skip..).unwrap_or_default()))
499        }
500        Some((&'?', rest)) => match component.split_first() {
501            Some((_, tail)) => component_glob_matches(rest, tail),
502            None => false,
503        },
504        Some((&head, rest)) => match component.split_first() {
505            Some((&first, tail)) if head == first => component_glob_matches(rest, tail),
506            _ => false,
507        },
508    }
509}
510
511/// Scans one file's content, returning its matching lines.
512fn scan_file(path: &Path, content: &str, matcher: &Regex, max_per_file: usize) -> Vec<LineMatch> {
513    let mut matches = Vec::new();
514    // The offsets are reported relative to the file, not the line, so a caller
515    // can compare a hit against a declaration range in the same file.
516    let mut line_start = 0usize;
517
518    for (index, line) in content.split_inclusive('\n').enumerate() {
519        let line_without_newline = line.trim_end_matches(['\n', '\r']);
520        for found in matcher.find_iter(line_without_newline) {
521            let matched_text = found.as_str();
522            // The column counts bytes from the start of the line, matching
523            // `rg --column`, so the column indexes into the line text.
524            let column = found.start() + 1;
525            let before = line_without_newline
526                .get(..found.start())
527                .and_then(|text| text.chars().next_back());
528            let after = line_without_newline.get(found.end()..).and_then(|text| text.chars().next());
529            let word_bounded = !before.is_some_and(is_identifier_char) && !after.is_some_and(is_identifier_char);
530            matches.push(LineMatch {
531                path: path.to_path_buf(),
532                line: index + 1,
533                column,
534                line_start,
535                byte_start: line_start + found.start(),
536                byte_end: line_start + found.end(),
537                line_text: line_without_newline.to_string(),
538                matched_text: matched_text.to_string(),
539                word_bounded,
540            });
541            if matches.len() >= max_per_file {
542                return matches;
543            }
544        }
545        line_start += line.len();
546    }
547
548    matches
549}
550
551#[cfg(test)]
552mod tests {
553    use super::{CaseSensitivity, MatchMode, SearchRequest, glob_matches_path, search};
554
555    use std::fs;
556    use std::path::Path;
557
558    use tempfile::tempdir;
559
560    fn fixture() -> tempfile::TempDir {
561        let dir = tempdir().expect("tempdir");
562        fs::write(dir.path().join("a.rs"), "fn main() {}\nlet needle = 1;\n").expect("write a.rs");
563        fs::write(dir.path().join("b.txt"), "needle in a text file\n").expect("write b.txt");
564        dir
565    }
566
567    #[test]
568    fn literal_mode_matches_metacharacters_verbatim() {
569        let dir = fixture();
570        let request = SearchRequest::literal("needle =", dir.path().to_path_buf());
571        let matches = search(&request).expect("search runs");
572
573        assert_eq!(matches.len(), 1, "only the literal occurrence matches");
574        assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
575        assert_eq!(matches[0].line, 2);
576        assert_eq!(matches[0].matched_text, "needle =");
577    }
578
579    #[test]
580    fn regex_mode_treats_pattern_as_a_regular_expression() {
581        let dir = fixture();
582        let request = SearchRequest::regex(r"needle\s+in", dir.path().to_path_buf());
583        let matches = search(&request).expect("search runs");
584
585        assert_eq!(matches.len(), 1);
586        assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("b.txt"));
587    }
588
589    #[test]
590    fn smart_case_relaxes_lowercase_patterns_only() {
591        let dir = tempdir().expect("tempdir");
592        fs::write(dir.path().join("case.txt"), "Needle needle\n").expect("write");
593
594        let lower = SearchRequest::literal("needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Smart);
595        assert_eq!(search(&lower).expect("search runs").len(), 2, "lower-case pattern matches both cases");
596
597        let upper = SearchRequest::literal("Needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Smart);
598        assert_eq!(search(&upper).expect("search runs").len(), 1, "upper-case pattern stays case-sensitive");
599    }
600
601    #[test]
602    fn explicit_case_sensitivity_overrides_smart_case() {
603        let dir = tempdir().expect("tempdir");
604        fs::write(dir.path().join("case.txt"), "Needle needle\n").expect("write");
605
606        let request =
607            SearchRequest::literal("Needle", dir.path().to_path_buf()).with_case(CaseSensitivity::Insensitive);
608        assert_eq!(search(&request).expect("search runs").len(), 2);
609    }
610
611    #[test]
612    fn language_globs_match_case_insensitively_in_both_directions() {
613        // Both sides of the comparison are lower-cased, so the case of the
614        // glob and the case of the file name must not decide the outcome.
615        let dir = tempdir().expect("tempdir");
616        fs::write(dir.path().join("main.rs"), "needle\n").expect("write lower-case file");
617        fs::write(dir.path().join("OTHER.RS"), "needle\n").expect("write upper-case file");
618
619        let upper_glob = SearchRequest::literal("needle", dir.path().to_path_buf()).with_language_glob("*.RS");
620        assert_eq!(search(&upper_glob).expect("search runs").len(), 2, "an upper-case glob matches both files");
621
622        let lower_glob = SearchRequest::literal("needle", dir.path().to_path_buf()).with_language_glob("*.rs");
623        assert_eq!(search(&lower_glob).expect("search runs").len(), 2, "a lower-case glob matches both files");
624    }
625
626    #[test]
627    fn include_globs_restrict_the_scanned_files() {
628        let dir = fixture();
629        let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_include_glob("*.rs");
630        let matches = search(&request).expect("search runs");
631
632        assert_eq!(matches.len(), 1);
633        assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
634    }
635
636    #[test]
637    fn max_results_stops_the_search_early() {
638        let dir = tempdir().expect("tempdir");
639        fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
640
641        let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_results(2);
642        let matches = search(&request).expect("search runs");
643
644        assert_eq!(matches.len(), 2);
645    }
646
647    #[test]
648    fn max_per_file_caps_matches_within_one_file() {
649        let dir = tempdir().expect("tempdir");
650        fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
651
652        let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_per_file(1);
653        let matches = search(&request).expect("search runs");
654
655        assert_eq!(matches.len(), 1);
656    }
657
658    #[test]
659    fn files_with_matches_reports_each_file_once() {
660        let dir = fixture();
661        let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
662        request.files_with_matches = true;
663        let matches = search(&request).expect("search runs");
664
665        assert_eq!(matches.len(), 2, "one entry per matching file");
666    }
667
668    #[test]
669    fn files_with_matches_honours_the_result_cap() {
670        let dir = tempdir().expect("tempdir");
671        for index in 0..4 {
672            fs::write(dir.path().join(format!("file{index}.txt")), "needle\n").expect("write");
673        }
674
675        let request = SearchRequest::literal("needle", dir.path().to_path_buf())
676            .with_max_results(2)
677            .with_files_with_matches();
678        let matches = search(&request).expect("search runs");
679
680        assert_eq!(matches.len(), 2, "the cap bounds the number of reported files");
681    }
682
683    #[test]
684    fn files_with_matches_reports_a_real_position_rather_than_a_placeholder() {
685        let dir = tempdir().expect("tempdir");
686        // The hit is on the second line, so a fabricated `line: 1` record would
687        // disagree with the file the caller would open.
688        fs::write(dir.path().join("a.txt"), "first\nsecond needle\n").expect("write");
689
690        let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
691        request.files_with_matches = true;
692        let matches = search(&request).expect("search runs");
693
694        assert_eq!(matches.len(), 1);
695        let hit = &matches[0];
696        assert_eq!(hit.line, 2, "the record points at the line that matched");
697        assert_eq!(hit.column, 8, "and at the byte column of the match");
698        assert_eq!(hit.line_text, "second needle");
699        assert_eq!(hit.matched_text, "needle");
700        assert_eq!(
701            "first\nsecond needle\n".get(hit.byte_start..hit.byte_end),
702            Some("needle"),
703            "the offsets still index into the file"
704        );
705    }
706
707    #[test]
708    fn files_with_matches_reports_each_file_once_when_a_file_has_several_hits() {
709        let dir = tempdir().expect("tempdir");
710        fs::write(dir.path().join("many.txt"), "needle\nneedle\nneedle\n").expect("write");
711        fs::write(dir.path().join("one.txt"), "needle\n").expect("write");
712
713        let mut request = SearchRequest::literal("needle", dir.path().to_path_buf());
714        request.files_with_matches = true;
715        let matches = search(&request).expect("search runs");
716
717        assert_eq!(matches.len(), 2, "one entry per matching file, not per hit");
718    }
719
720    #[test]
721    fn word_bounded_marks_exact_identifier_occurrences() {
722        let dir = tempdir().expect("tempdir");
723        fs::write(dir.path().join("words.txt"), "Widget WidgetExtra\n").expect("write");
724
725        let request = SearchRequest::literal("Widget", dir.path().to_path_buf());
726        let matches = search(&request).expect("search runs");
727
728        assert_eq!(matches.len(), 2);
729        assert!(matches[0].word_bounded, "the standalone occurrence is word-bounded");
730        assert!(!matches[1].word_bounded, "the occurrence inside a longer word is not");
731    }
732
733    #[test]
734    fn byte_offsets_are_relative_to_the_file() {
735        let dir = tempdir().expect("tempdir");
736        fs::write(dir.path().join("offsets.txt"), "first\nsecond needle\n").expect("write");
737
738        let request = SearchRequest::literal("needle", dir.path().to_path_buf());
739        let matches = search(&request).expect("search runs");
740
741        assert_eq!(matches.len(), 1);
742        assert_eq!(matches[0].line, 2);
743        assert_eq!(
744            "first\nsecond needle\n"
745                .get(matches[0].byte_start..matches[0].byte_end)
746                .expect("the offsets index into the file"),
747            "needle",
748            "the offsets must index into the file, not the line"
749        );
750    }
751
752    #[test]
753    fn column_is_counted_in_bytes() {
754        let dir = tempdir().expect("tempdir");
755        // "éé needle": the two `é` take four bytes and the space one more, so
756        // the match starts at byte offset 5 and ripgrep reports column 6.
757        fs::write(dir.path().join("unicode.txt"), "éé needle\n").expect("write");
758
759        let request = SearchRequest::literal("needle", dir.path().to_path_buf());
760        let matches = search(&request).expect("search runs");
761
762        assert_eq!(matches[0].column, 6, "column counts bytes, not characters");
763        assert_eq!(
764            matches[0].line_text.as_bytes().get(matches[0].column - 1..),
765            Some(b"needle".as_slice()),
766            "the column must index into the line text"
767        );
768    }
769
770    #[test]
771    fn multibyte_column_matches_ripgrep() {
772        let dir = tempdir().expect("tempdir");
773        // "中文needle": the two CJK characters take six bytes, so the match
774        // starts at byte offset 6 and ripgrep reports column 7.
775        fs::write(dir.path().join("cjk.txt"), "中文needle\n").expect("write");
776
777        let request = SearchRequest::literal("needle", dir.path().to_path_buf());
778        let matches = search(&request).expect("search runs");
779
780        assert_eq!(matches[0].column, 7, "column counts bytes, matching `rg --column`");
781    }
782
783    #[test]
784    fn line_start_marks_the_start_of_the_matching_line() {
785        let dir = tempdir().expect("tempdir");
786        fs::write(dir.path().join("offsets.txt"), "first\nsecond needle\n").expect("write");
787
788        let request = SearchRequest::literal("needle", dir.path().to_path_buf());
789        let matches = search(&request).expect("search runs");
790
791        assert_eq!(matches[0].line_start, 6, "the second line starts after \"first\\n\"");
792        assert_eq!(
793            matches[0].byte_start - matches[0].line_start,
794            matches[0].column - 1,
795            "the file offset and the line offset must agree"
796        );
797    }
798
799    #[test]
800    fn invalid_regex_is_reported_as_an_error() {
801        let dir = fixture();
802        let request = SearchRequest::regex("(unclosed", dir.path().to_path_buf());
803
804        assert!(search(&request).is_err(), "an invalid regex must not silently match nothing");
805    }
806
807    #[test]
808    fn glob_matching_handles_wildcards_and_prefixes() {
809        assert!(glob_matches_path(Path::new("main.rs"), "*.rs"));
810        assert!(!glob_matches_path(Path::new("main.txt"), "*.rs"));
811        assert!(glob_matches_path(Path::new("main.rs"), "**/*.rs"));
812        assert!(glob_matches_path(Path::new("main.rs"), "main.rs"));
813        assert!(glob_matches_path(Path::new("a/b/main.rs"), "**/*.rs"));
814        assert!(!glob_matches_path(Path::new("a/b/main.txt"), "**/*.rs"));
815        assert!(glob_matches_path(Path::new("target/x/y.rs"), "**/target/**"));
816    }
817
818    #[test]
819    fn exclude_globs_remove_matching_files() {
820        let dir = fixture();
821        let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_exclude_glob("*.txt");
822        let matches = search(&request).expect("search runs");
823
824        assert_eq!(matches.len(), 1, "the excluded text file must not be scanned");
825        assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("a.rs"));
826    }
827
828    #[test]
829    fn exclude_globs_match_directory_patterns_at_any_depth() {
830        let dir = tempdir().expect("tempdir");
831        fs::create_dir_all(dir.path().join("target/nested")).expect("nested directory");
832        fs::write(dir.path().join("target/nested/build.rs"), "needle\n").expect("write nested");
833        fs::write(dir.path().join("kept.rs"), "needle\n").expect("write kept");
834
835        let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_exclude_glob("**/target/**");
836        let matches = search(&request).expect("search runs");
837
838        assert_eq!(matches.len(), 1, "the excluded directory must not be scanned");
839        assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("kept.rs"));
840    }
841
842    #[test]
843    fn gitignored_files_are_skipped_by_default() {
844        let dir = tempdir().expect("tempdir");
845        // The `ignore` crate only applies git rules inside a git repository,
846        // so the fixture needs the same `.git` marker the workspace fixtures use.
847        fs::create_dir(dir.path().join(".git")).expect("git marker");
848        fs::write(dir.path().join(".gitignore"), "skipped.rs\n").expect("write ignore file");
849        fs::write(dir.path().join("skipped.rs"), "needle\n").expect("write skipped");
850        fs::write(dir.path().join("kept.rs"), "needle\n").expect("write kept");
851
852        let request = SearchRequest::literal("needle", dir.path().to_path_buf());
853        let matches = search(&request).expect("search runs");
854
855        assert_eq!(matches.len(), 1, "the ignored file must not be scanned");
856        assert_eq!(matches[0].path.file_name().and_then(|name| name.to_str()), Some("kept.rs"));
857    }
858
859    #[test]
860    fn ignore_files_can_be_disabled() {
861        let dir = tempdir().expect("tempdir");
862        fs::create_dir(dir.path().join(".git")).expect("git marker");
863        fs::write(dir.path().join(".gitignore"), "skipped.rs\n").expect("write ignore file");
864        fs::write(dir.path().join("skipped.rs"), "needle\n").expect("write skipped");
865
866        let request = SearchRequest::literal("needle", dir.path().to_path_buf()).without_ignore_files();
867        let matches = search(&request).expect("search runs");
868
869        assert_eq!(matches.len(), 1, "disabling ignore files scans the ignored file");
870    }
871
872    #[test]
873    fn max_file_size_skips_large_files() {
874        let dir = tempdir().expect("tempdir");
875        fs::write(dir.path().join("big.txt"), "needle\n".repeat(100)).expect("write big");
876
877        let request = SearchRequest::literal("needle", dir.path().to_path_buf()).with_max_file_size(16);
878        let matches = search(&request).expect("search runs");
879
880        assert!(matches.is_empty(), "a file over the size limit must not be scanned");
881    }
882
883    #[test]
884    fn request_defaults_to_literal_mode() {
885        let request = SearchRequest::literal("x", "/tmp");
886        assert_eq!(request.mode, MatchMode::Literal);
887    }
888}