1use std::ffi::OsStr;
14use std::path::{Component, Path, PathBuf};
15
16pub fn expand_globs(base: &Path, patterns: &[String]) -> Vec<(String, PathBuf)> {
44 let mut anchors: Vec<(PathBuf, Vec<String>)> = Vec::new();
45 for pattern in patterns {
46 let (anchor, rest) = anchor_pattern(base, pattern);
47 match anchors.iter_mut().find(|(dir, _)| *dir == anchor) {
48 Some((_, group)) => group.push(rest),
49 None => anchors.push((anchor, vec![rest])),
50 }
51 }
52
53 let base_components = lexical_components(base);
54 let mut files: Vec<(String, PathBuf)> = anchors
55 .iter()
56 .flat_map(|(anchor, group)| walk(anchor, group))
57 .map(|absolute| (relative_name(&base_components, &absolute), absolute))
58 .collect();
59 files.sort();
60 files.dedup_by(|later, earlier| later.0 == earlier.0);
61 files
62}
63
64pub fn anchor_pattern(base: &Path, pattern: &str) -> (PathBuf, String) {
75 let mut anchor = base.to_path_buf();
76 let mut rest = pattern;
77 loop {
78 if let Some(tail) = rest.strip_prefix("./") {
79 rest = tail;
80 } else if let Some(tail) = rest.strip_prefix("../") {
81 if anchor.file_name().is_some() {
82 anchor.pop();
83 } else if !anchor.has_root() {
84 anchor.push("..");
86 }
87 rest = tail;
88 } else {
89 return (anchor, rest.to_string());
90 }
91 }
92}
93
94pub fn pattern_dir(base: &Path, pattern: &str) -> PathBuf {
99 let (anchor, rest) = anchor_pattern(base, pattern);
100 let mut literal = literal_prefix(&rest);
101 if literal.len() == rest.split('/').count() {
102 literal.pop();
104 }
105 literal
106 .iter()
107 .fold(anchor, |dir, component| dir.join(component))
108}
109
110pub(crate) fn has_inner_dot_component(pattern: &str) -> bool {
114 let (_, rest) = anchor_pattern(Path::new("."), pattern);
115 rest.split('/')
116 .any(|component| matches!(component, "." | ".."))
117}
118
119fn walk(anchor: &Path, patterns: &[String]) -> Vec<PathBuf> {
122 let matcher = Matcher::compile(patterns);
123 if matcher.is_empty() {
124 return Vec::new();
125 }
126
127 let (root, roots) = (anchor.to_path_buf(), matcher.roots.clone());
129 let walker = ignore::WalkBuilder::new(anchor)
130 .hidden(false)
131 .require_git(false)
132 .git_global(false)
133 .follow_links(true)
134 .filter_entry(move |entry| {
135 entry.file_name() != OsStr::new(".git")
136 && entry.file_name() != OsStr::new(".alba")
137 && worth_visiting(&root, entry.path(), &roots)
138 })
139 .build();
140
141 let mut files = Vec::new();
142 for entry in walker.flatten() {
143 if !entry.file_type().is_some_and(|kind| kind.is_file()) {
144 continue;
145 }
146 let Ok(relative) = entry.path().strip_prefix(anchor) else {
147 continue;
148 };
149 let unified = relative.to_string_lossy().replace('\\', "/");
150 if matcher.set.is_match(&unified) {
151 files.push(entry.into_path());
152 }
153 }
154 files
155}
156
157fn lexical_components(path: &Path) -> Vec<Component<'_>> {
160 let mut components = Vec::new();
161 for component in path.components() {
162 match component {
163 Component::CurDir => {}
164 Component::ParentDir if matches!(components.last(), Some(Component::Normal(_))) => {
165 components.pop();
166 }
167 other => components.push(other),
168 }
169 }
170 components
171}
172
173fn relative_name(base: &[Component<'_>], path: &Path) -> String {
177 let path = lexical_components(path);
178 let shared = base
179 .iter()
180 .zip(&path)
181 .take_while(|(left, right)| left == right)
182 .count();
183 let climb = std::iter::repeat_n("..".to_string(), base.len() - shared);
184 let descend = path[shared..]
185 .iter()
186 .map(|component| component.as_os_str().to_string_lossy().into_owned());
187 climb.chain(descend).collect::<Vec<_>>().join("/")
188}
189
190pub fn outputs_satisfied(base: &Path, patterns: &[String]) -> bool {
194 patterns.iter().all(|pattern| {
195 if pattern.contains(['*', '?', '[']) {
196 glob::glob(&base.join(pattern).to_string_lossy())
197 .map(|mut matches| matches.any(|entry| entry.is_ok()))
198 .unwrap_or(false)
199 } else {
200 base.join(pattern).exists()
201 }
202 })
203}
204
205struct Matcher {
208 set: globset::GlobSet,
209 roots: Vec<Vec<String>>,
213}
214
215impl Matcher {
216 fn compile(patterns: &[String]) -> Self {
221 let mut builder = globset::GlobSetBuilder::new();
222 let mut roots = Vec::new();
223 for pattern in patterns {
224 if let Ok(glob) = globset::Glob::new(pattern) {
225 builder.add(glob);
226 roots.push(literal_prefix(pattern));
227 }
228 }
229 Self {
230 set: builder
233 .build()
234 .unwrap_or_else(|_| globset::GlobSet::empty()),
235 roots,
236 }
237 }
238
239 fn is_empty(&self) -> bool {
240 self.set.is_empty()
241 }
242}
243
244fn worth_visiting(base: &Path, path: &Path, roots: &[Vec<String>]) -> bool {
253 let Ok(relative) = path.strip_prefix(base) else {
254 return true;
255 };
256 let components: Vec<String> = relative
257 .components()
258 .map(|component| component.as_os_str().to_string_lossy().into_owned())
259 .collect();
260 roots.iter().any(|root| {
264 root.iter()
265 .zip(&components)
266 .all(|(expected, actual)| expected == actual)
267 })
268}
269
270fn literal_prefix(pattern: &str) -> Vec<String> {
273 const META: [char; 7] = ['*', '?', '[', ']', '{', '}', '\\'];
274 pattern
275 .split('/')
276 .take_while(|component| !component.is_empty() && !component.contains(META))
277 .map(str::to_string)
278 .collect()
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 #[test]
286 fn leading_dot_components_move_the_anchor_and_leave_the_rest() {
287 let anchor = |base: &str, pattern: &str| {
288 let (dir, rest) = anchor_pattern(Path::new(base), pattern);
289 (dir.to_string_lossy().replace('\\', "/"), rest)
290 };
291
292 assert_eq!(
293 anchor("/repo/contexts/shared", "../../Dockerfile"),
294 ("/repo".to_string(), "Dockerfile".to_string())
295 );
296 assert_eq!(
297 anchor("/repo", "./src/**/*.rs"),
298 ("/repo".to_string(), "src/**/*.rs".to_string())
299 );
300 assert_eq!(
301 anchor("/repo", "src/**/*.rs"),
302 ("/repo".to_string(), "src/**/*.rs".to_string())
303 );
304 assert_eq!(anchor("/", "../x"), ("/".to_string(), "x".to_string()));
306 assert_eq!(anchor(".", "../x"), ("./..".to_string(), "x".to_string()));
308 }
309
310 #[test]
311 fn a_name_climbs_out_of_the_base_only_as_far_as_needed() {
312 let base = lexical_components(Path::new("/repo/contexts/shared"));
313 let name = |path: &str| relative_name(&base, Path::new(path));
314
315 assert_eq!(name("/repo/contexts/shared/src/a.php"), "src/a.php");
316 assert_eq!(name("/repo/Dockerfile"), "../../Dockerfile");
317 assert_eq!(
318 name("/repo/contexts/shared/../../Dockerfile"),
319 "../../Dockerfile"
320 );
321 assert_eq!(name("/repo/contexts/other/b.php"), "../other/b.php");
322 }
323
324 #[test]
325 fn a_pattern_dir_is_the_deepest_directory_every_match_lies_under() {
326 let dir = |pattern: &str| {
327 pattern_dir(Path::new("/repo/project"), pattern)
328 .to_string_lossy()
329 .replace('\\', "/")
330 };
331
332 assert_eq!(dir("src/**/*.rs"), "/repo/project/src");
333 assert_eq!(dir("../shared/config/*.yaml"), "/repo/shared/config");
334 assert_eq!(dir("../Dockerfile"), "/repo");
336 assert_eq!(dir("**/*.rs"), "/repo/project");
337 }
338
339 #[test]
340 fn a_literal_prefix_stops_at_the_first_metacharacter() {
341 assert_eq!(literal_prefix("crates/**/*.rs"), vec!["crates"]);
342 assert_eq!(
343 literal_prefix("crates/alba-core/src/*.rs"),
344 vec!["crates", "alba-core", "src"]
345 );
346 assert_eq!(literal_prefix("Cargo.toml"), vec!["Cargo.toml"]);
347 assert!(literal_prefix("**/*.rs").is_empty());
348 assert!(literal_prefix("/absolute/x.rs").is_empty());
349 }
350
351 #[test]
354 fn a_pattern_without_a_literal_prefix_visits_everything() {
355 let base = Path::new("/project");
356 let roots = vec![Vec::new()];
357 assert!(worth_visiting(base, Path::new("/project/target"), &roots));
358 }
359
360 #[test]
361 fn only_the_way_down_to_a_root_and_its_contents_are_visited() {
362 let base = Path::new("/project");
363 let roots = vec![vec!["crates".to_string(), "alba-core".to_string()]];
364
365 assert!(worth_visiting(base, Path::new("/project/crates"), &roots));
366 assert!(worth_visiting(
367 base,
368 Path::new("/project/crates/alba-core/src/files.rs"),
369 &roots
370 ));
371 assert!(!worth_visiting(base, Path::new("/project/target"), &roots));
372 assert!(!worth_visiting(
373 base,
374 Path::new("/project/crates/alba-cli"),
375 &roots
376 ));
377 assert!(!worth_visiting(
380 base,
381 Path::new("/project/crates-extra"),
382 &roots
383 ));
384 }
385}