Skip to main content

alba_core/
files.rs

1//! Filesystem resolution for `inputs`/`outputs` declarations: which files
2//! a beam's glob patterns actually name on disk.
3//!
4//! The two functions deliberately answer different questions against
5//! different views of the disk. [`expand_globs`] resolves `inputs` for
6//! fingerprinting and is `.gitignore`-aware: hashing `src/**/*` must not
7//! aspirate build artifacts a `.gitignore` already declares uninteresting.
8//! [`outputs_satisfied`] checks `outputs` for presence and queries the raw
9//! disk: outputs typically live in exactly those ignored directories
10//! (`target/`, `dist/`), so filtering them would make their patterns never
11//! match.
12
13use std::ffi::OsStr;
14use std::path::{Component, Path, PathBuf};
15
16/// The files matching any of `patterns`, resolved from `base`, as
17/// `(relative path with '/' separators, absolute path)` pairs sorted by
18/// relative path — sorted so a fingerprint built from the list is
19/// deterministic across platforms and directory iteration orders.
20///
21/// A pattern may climb out of `base` through leading `..` components
22/// (`../../Dockerfile`): each pattern is walked from its own
23/// [`anchor_pattern`], and every file is named by its shortest path from
24/// `base`, `../` included, so a file reached by two spellings of the same
25/// path is listed once.
26///
27/// `.gitignore` files are honored even outside a git repository
28/// (`require_git(false)`), so behaviour does not silently change when a
29/// project is unpacked from a tarball; only the project's own ignore files
30/// apply, not the machine's global excludes, so inputs remain reproducible
31/// regardless of whose machine resolves them. Hidden files are included —
32/// `inputs [".env"]` must work — but `.git/` and `.alba/` are never
33/// walked: the first is noise, and hashing the cache's own directory
34/// would invalidate every beam on every run. Symbolic links are followed:
35/// a linked source file (or a linked source directory, as a vendored or
36/// workspace-shared tree often is) is a real input, and dropping it for
37/// not being a regular file would silently hash nothing.
38///
39/// Never fails. `inputs`/`outputs` patterns are validated when the
40/// Beamfile loads, so a pattern that does not compile here can only come
41/// from a [`crate::Project`] assembled some other way; it is skipped on
42/// its own, leaving its siblings' matches intact.
43pub 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
64/// Where `pattern`'s matches live: `base` with the pattern's leading `.`
65/// and `..` components applied, and the rest of the pattern, relative to
66/// that directory. `../../Dockerfile` declared in `/repo/contexts/shared`
67/// anchors at `/repo` as `Dockerfile`.
68///
69/// Lexical on purpose: `..` names the parent of the directory as the
70/// Beamfile spells it, the way its author reads it, not the parent of
71/// wherever a symbolic link on the way leads. Every consumer that matches
72/// a path against `inputs` (the cache, affected runs, watch mode) anchors
73/// through here, so they cannot disagree about what a pattern names.
74pub 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                // A relative base already at, or above, its own start.
85                anchor.push("..");
86            }
87            rest = tail;
88        } else {
89            return (anchor, rest.to_string());
90        }
91    }
92}
93
94/// The deepest directory every match of `pattern`, resolved from `base`,
95/// lies under: its [`anchor_pattern`] joined with the pattern's leading
96/// literal directories. Watching it recursively sees every file the
97/// pattern can ever match.
98pub 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        // A fully literal pattern names a file, which lies in its parent.
103        literal.pop();
104    }
105    literal
106        .iter()
107        .fold(anchor, |dir, component| dir.join(component))
108}
109
110/// Whether `pattern` holds a `.` or `..` component past the leading ones
111/// [`anchor_pattern`] applies: such a component is matched as a literal
112/// name no walked path ever holds, so the load rejects the pattern.
113pub(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
119/// The absolute paths of the files under `anchor` matching `patterns`,
120/// which are relative to it.
121fn 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    // Owned copies: the walker's filter must outlive this call's borrows.
128    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
157/// `path`'s components with `.` dropped and `..` folded into the component
158/// before it, so two spellings of one path compare equal.
159fn 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
173/// The shortest '/'-separated path from the base (given as its
174/// [`lexical_components`]) to `path`, climbing with `..` when `path` lies
175/// outside it.
176fn 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
190/// Whether every `outputs` pattern is satisfied on disk: a literal path
191/// must exist, a glob pattern must match at least one entry. Raw disk, no
192/// `.gitignore` filtering — see the module doc comment.
193pub 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
205/// A beam's patterns, compiled: what a path is matched against, and which
206/// directories can possibly hold a match.
207struct Matcher {
208    set: globset::GlobSet,
209    /// Per pattern, its leading run of literal path components — `crates`
210    /// for `crates/**/*.rs`, empty for `**/*.rs`. Nothing outside one of
211    /// these can match, so nothing outside one of them is walked.
212    roots: Vec<Vec<String>>,
213}
214
215impl Matcher {
216    /// Compiles `patterns` one at a time, keeping the ones that compile.
217    /// A single bad pattern used to abandon the whole set, turning every
218    /// declared input into "nothing matched" — which the cache reads as a
219    /// beam whose inputs never change, and therefore never reruns.
220    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            // `build` only fails on a glob that already compiled, but the
231            // cache degrades rather than panics on anything unexpected.
232            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
244/// Whether `path` is, or could contain, a match — the walk's pruning rule.
245///
246/// The `GlobSet` alone only filters entries the walk has already produced,
247/// so `crates/**/*.rs` visited the whole project, `target/` included, once
248/// per beam declaring it. A path matching no pattern's literal prefix can
249/// hold no match, so descent stops there. Deliberately conservative: a
250/// pattern with no literal prefix (`**/*.rs`) still walks everything, and
251/// which files end up matched is unchanged.
252fn 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    // `zip` stops at the shorter side, so this holds when either is a
261    // prefix of the other: a directory on the way down to a root, and
262    // anything underneath one.
263    roots.iter().any(|root| {
264        root.iter()
265            .zip(&components)
266            .all(|(expected, actual)| expected == actual)
267    })
268}
269
270/// The leading path components of `pattern` that are plain text, stopping
271/// at the first one holding a glob metacharacter.
272fn 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        // A filesystem root has no parent: climbing past it stays there.
305        assert_eq!(anchor("/", "../x"), ("/".to_string(), "x".to_string()));
306        // A relative base keeps climbing above its own start.
307        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        // A fully literal pattern names a file, which lies in its parent.
335        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    /// A pattern with no literal prefix must not prune anything: the
352    /// pruning rule may only remove paths that cannot match.
353    #[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        // A sibling whose name merely starts with a root's name is not
378        // under it: components are compared whole, never as substrings.
379        assert!(!worth_visiting(
380            base,
381            Path::new("/project/crates-extra"),
382            &roots
383        ));
384    }
385}