1use 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#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct FindMatch {
21 pub path: String,
23 pub line: u32,
25 pub text: String,
27}
28
29#[derive(Clone, Debug, Eq, PartialEq)]
31pub struct FindGrepResult {
32 pub matches: Vec<FindMatch>,
34 pub truncated: bool,
36}
37
38#[derive(Clone, Debug, Eq, PartialEq)]
40pub struct FindGlobResult {
41 pub paths: Vec<String>,
43 pub truncated: bool,
45}
46
47impl FsDir {
48 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 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}