Skip to main content

sim_table_fs/
find.rs

1//! Read-only grep and glob search for filesystem-backed tables.
2
3use std::{
4    collections::{BTreeMap, BTreeSet},
5    path::{Path, PathBuf},
6};
7
8use globset::{GlobBuilder, GlobMatcher};
9use regex::Regex;
10use sim_kernel::{Cx, Error, Expr, Result};
11
12use crate::{
13    FsDir,
14    capabilities::{require_table_fs_find, require_table_fs_read},
15    roadmap11::known_exts,
16};
17
18/// One text match returned by [`FsDir::find_grep`].
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct FindMatch {
21    /// Relative file path using `/` separators.
22    pub path: String,
23    /// One-based line number.
24    pub line: u32,
25    /// Matching line text without its trailing line break.
26    pub text: String,
27}
28
29/// Bounded grep result.
30#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct FindGrepResult {
32    /// Matches retained within the requested bound.
33    pub matches: Vec<FindMatch>,
34    /// True when more matches existed after `matches` reached `max`.
35    pub truncated: bool,
36}
37
38/// Bounded glob result.
39#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct FindGlobResult {
41    /// Relative paths retained within the requested bound.
42    pub paths: Vec<String>,
43    /// True when more paths existed after `paths` reached `max`.
44    pub truncated: bool,
45}
46
47impl FsDir {
48    /// Regex-search string leaves under this directory.
49    ///
50    /// Requires both `fs/read` and `find`. The optional glob filters relative
51    /// paths before any file is read. Returned matches are bounded by `max`;
52    /// truncation is reported through [`FindGrepResult::truncated`].
53    pub fn find_grep(
54        &self,
55        cx: &mut Cx,
56        pattern: &str,
57        glob: Option<&str>,
58        max: usize,
59    ) -> Result<FindGrepResult> {
60        require_table_fs_read(cx)?;
61        require_table_fs_find(cx)?;
62        let regex =
63            Regex::new(pattern).map_err(|err| Error::Eval(format!("table/fs: regex {err}")))?;
64        let glob = glob.map(glob_matcher).transpose()?;
65        let mut result = FindGrepResult {
66            matches: Vec::new(),
67            truncated: false,
68        };
69        let mut seen_dirs = self.initial_seen_dirs()?;
70        self.walk_search_entries(&mut seen_dirs, |path, rel, is_dir| {
71            if is_dir || !glob_matches(glob.as_ref(), rel) {
72                return Ok(false);
73            }
74            let Some(ext) = known_search_ext(path) else {
75                return Ok(false);
76            };
77            let text = self.search_text_for_path(cx, path, ext)?;
78            for (line, line_text) in text.lines().enumerate() {
79                if regex.is_match(line_text) {
80                    if result.matches.len() >= max {
81                        result.truncated = true;
82                        return Ok(true);
83                    }
84                    result.matches.push(FindMatch {
85                        path: rel.to_owned(),
86                        line: (line + 1).try_into().unwrap_or(u32::MAX),
87                        text: line_text.to_owned(),
88                    });
89                }
90            }
91            Ok(false)
92        })?;
93        Ok(result)
94    }
95
96    /// Glob relative paths under this directory without reading file contents.
97    ///
98    /// Requires both `fs/read` and `find`. Returned paths are bounded by `max`;
99    /// truncation is reported through [`FindGlobResult::truncated`].
100    pub fn find_glob(&self, cx: &mut Cx, pattern: &str, max: usize) -> Result<FindGlobResult> {
101        require_table_fs_read(cx)?;
102        require_table_fs_find(cx)?;
103        let glob = glob_matcher(pattern)?;
104        let mut result = FindGlobResult {
105            paths: Vec::new(),
106            truncated: false,
107        };
108        let mut seen_dirs = self.initial_seen_dirs()?;
109        self.walk_search_entries(&mut seen_dirs, |_path, rel, _is_dir| {
110            if glob.is_match(rel) {
111                if result.paths.len() >= max {
112                    result.truncated = true;
113                    return Ok(true);
114                }
115                result.paths.push(rel.to_owned());
116            }
117            Ok(false)
118        })?;
119        Ok(result)
120    }
121
122    fn initial_seen_dirs(&self) -> Result<BTreeSet<PathBuf>> {
123        let mut seen = BTreeSet::new();
124        seen.insert(
125            std::fs::canonicalize(self.root_path())
126                .map_err(|err| Error::Eval(format!("table/fs: find root {err}")))?,
127        );
128        Ok(seen)
129    }
130
131    fn walk_search_entries<F>(&self, seen_dirs: &mut BTreeSet<PathBuf>, mut visit: F) -> Result<()>
132    where
133        F: FnMut(&Path, &str, bool) -> Result<bool>,
134    {
135        self.walk_search_dir(self.root_path(), "", seen_dirs, &mut visit)
136            .map(|_| ())
137    }
138
139    fn walk_search_dir<F>(
140        &self,
141        dir: &Path,
142        rel_prefix: &str,
143        seen_dirs: &mut BTreeSet<PathBuf>,
144        visit: &mut F,
145    ) -> Result<bool>
146    where
147        F: FnMut(&Path, &str, bool) -> Result<bool>,
148    {
149        let entries = sorted_entries(dir)?;
150        for (name, path) in entries {
151            if name.starts_with('.') {
152                continue;
153            }
154            self.ensure_internal_path(&path)?;
155            let rel = if rel_prefix.is_empty() {
156                name
157            } else {
158                format!("{rel_prefix}/{name}")
159            };
160            let metadata = std::fs::metadata(&path)
161                .map_err(|err| Error::Eval(format!("table/fs: find metadata {err}")))?;
162            if metadata.is_dir() {
163                if visit(&path, &rel, true)? {
164                    return Ok(true);
165                }
166                let canonical = std::fs::canonicalize(&path)
167                    .map_err(|err| Error::Eval(format!("table/fs: find path {err}")))?;
168                if seen_dirs.insert(canonical)
169                    && self.walk_search_dir(&path, &rel, seen_dirs, visit)?
170                {
171                    return Ok(true);
172                }
173            } else if metadata.is_file() && visit(&path, &rel, false)? {
174                return Ok(true);
175            }
176        }
177        Ok(false)
178    }
179
180    fn search_text_for_path(&self, cx: &mut Cx, path: &Path, ext: &str) -> Result<String> {
181        let expr = self.read_leaf_path(cx, path, ext)?;
182        match expr {
183            Expr::String(text) => Ok(text),
184            Expr::Extension { payload, .. } => match *payload {
185                Expr::String(text) => Ok(text),
186                _ => Err(Error::Eval(format!(
187                    "table/fs: find expects string payload at {}",
188                    relative_path(self.root_path(), path)?
189                ))),
190            },
191            _ => Err(Error::Eval(format!(
192                "table/fs: find expects string leaf at {}",
193                relative_path(self.root_path(), path)?
194            ))),
195        }
196    }
197}
198
199fn sorted_entries(dir: &Path) -> Result<BTreeMap<String, PathBuf>> {
200    let mut entries = BTreeMap::new();
201    for entry in std::fs::read_dir(dir)
202        .map_err(|err| Error::Eval(format!("table/fs: find read_dir {err}")))?
203    {
204        let entry = entry.map_err(|err| Error::Eval(format!("table/fs: find entry {err}")))?;
205        let name = entry
206            .file_name()
207            .to_str()
208            .ok_or_else(|| Error::Eval("table/fs: find path is not utf-8".to_owned()))?
209            .to_owned();
210        entries.insert(name, entry.path());
211    }
212    Ok(entries)
213}
214
215fn glob_matcher(pattern: &str) -> Result<GlobMatcher> {
216    GlobBuilder::new(pattern)
217        .literal_separator(true)
218        .build()
219        .map_err(|err| Error::Eval(format!("table/fs: glob {err}")))
220        .map(|glob| glob.compile_matcher())
221}
222
223fn glob_matches(glob: Option<&GlobMatcher>, rel: &str) -> bool {
224    glob.is_none_or(|glob| glob.is_match(rel))
225}
226
227fn known_search_ext(path: &Path) -> Option<&'static str> {
228    let ext = path.extension()?.to_str()?;
229    known_exts().into_iter().find(|known| *known == ext)
230}
231
232fn relative_path(root: &Path, path: &Path) -> Result<String> {
233    let rel = path
234        .strip_prefix(root)
235        .map_err(|err| Error::Eval(format!("table/fs: find relative path {err}")))?;
236    Ok(rel
237        .components()
238        .map(|component| component.as_os_str().to_string_lossy())
239        .collect::<Vec<_>>()
240        .join("/"))
241}