Skip to main content

recast_core/
search.rs

1use std::path::PathBuf;
2
3use rayon::prelude::*;
4
5use crate::error::{Error, Result};
6use crate::pattern::{CompiledPattern, PatternOptions};
7use crate::plan::{check_match_counts, read_text_or_skip_binary};
8use crate::walker::{WalkOptions, walk_paths};
9
10#[derive(Debug, Clone)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize))]
12pub struct SearchMatch {
13    pub line: usize,
14    pub column: usize,
15    pub snippet: String,
16    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
17    pub capture: Option<String>,
18}
19
20#[derive(Debug, Clone)]
21#[cfg_attr(feature = "serde", derive(serde::Serialize))]
22pub struct SearchFile {
23    pub path: PathBuf,
24    pub matches: Vec<SearchMatch>,
25}
26
27#[derive(Debug, Clone)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize))]
29pub struct SearchPlan {
30    pub files: Vec<SearchFile>,
31    pub total_matches: usize,
32    pub files_scanned: usize,
33}
34
35// PatternOptions and WalkOptions are not Serialize; serde omitted for SearchOptions
36#[derive(Debug, Clone)]
37pub struct SearchOptions {
38    pub pattern_options: PatternOptions,
39    pub walk_options: WalkOptions,
40    pub at_least: Option<usize>,
41    pub at_most: Option<usize>,
42    pub max_bytes: u64,
43    pub max_files: usize,
44}
45
46impl Default for SearchOptions {
47    fn default() -> Self {
48        Self {
49            pattern_options: PatternOptions::default(),
50            walk_options: WalkOptions::default(),
51            at_least: Some(1),
52            at_most: None,
53            max_bytes: 10 * 1024 * 1024,
54            max_files: 1000,
55        }
56    }
57}
58
59// column is byte-based — consistent with tree-sitter's `start_position`
60pub(crate) fn line_col(source: &str, byte_offset: usize) -> (usize, usize) {
61    debug_assert!(source.is_char_boundary(byte_offset), "byte_offset must be on a char boundary");
62    let prefix = &source[..byte_offset];
63    let line = prefix.bytes().filter(|&b| b == b'\n').count() + 1;
64    let col = match prefix.rfind('\n') {
65        Some(nl) => byte_offset - nl,
66        None => byte_offset + 1,
67    };
68    (line, col)
69}
70
71pub(crate) fn truncate_snippet(s: &str) -> String {
72    let first_line = s.lines().next().unwrap_or("").trim();
73    first_line.chars().take(200).collect()
74}
75
76pub fn plan_search<P: AsRef<std::path::Path>>(
77    pattern: &str,
78    roots: &[P],
79    opts: &SearchOptions,
80) -> Result<SearchPlan> {
81    let compiled = CompiledPattern::compile(pattern, "", &opts.pattern_options)?;
82    let files = scan(roots, opts)?;
83    let files_scanned = files.len();
84
85    let results: Vec<Result<Option<SearchFile>>> =
86        files.par_iter().map(|path| search_one(&compiled, path, opts)).collect();
87
88    let found = collect(results)?;
89    let total_matches: usize = found.iter().map(|f| f.matches.len()).sum();
90    check_match_counts(total_matches, opts.at_least, opts.at_most)?;
91
92    Ok(SearchPlan { files: found, total_matches, files_scanned })
93}
94
95pub(crate) fn scan<P: AsRef<std::path::Path>>(
96    roots: &[P],
97    opts: &SearchOptions,
98) -> Result<Vec<PathBuf>> {
99    let files = walk_paths(roots, &opts.walk_options)?;
100    if files.len() > opts.max_files {
101        return Err(Error::TooManyFiles { count: files.len(), limit: opts.max_files });
102    }
103    Ok(files)
104}
105
106pub(crate) fn collect(results: Vec<Result<Option<SearchFile>>>) -> Result<Vec<SearchFile>> {
107    let mut out = Vec::new();
108    for r in results {
109        if let Some(f) = r? {
110            out.push(f);
111        }
112    }
113    Ok(out)
114}
115
116fn search_one(
117    compiled: &CompiledPattern,
118    path: &std::path::Path,
119    opts: &SearchOptions,
120) -> Result<Option<SearchFile>> {
121    let (source, _) = match read_text_or_skip_binary(path, opts.max_bytes)? {
122        Some(pair) => pair,
123        None => return Ok(None),
124    };
125
126    let matches: Vec<SearchMatch> = compiled
127        .regex()
128        .find_iter(&source)
129        .map(|m| {
130            let (line, column) = line_col(&source, m.start());
131            SearchMatch { line, column, snippet: truncate_snippet(m.as_str()), capture: None }
132        })
133        .collect();
134
135    if matches.is_empty() {
136        return Ok(None);
137    }
138    Ok(Some(SearchFile { path: path.to_path_buf(), matches }))
139}
140
141#[cfg(test)]
142#[path = "search_tests.rs"]
143mod tests;