Skip to main content

alba_core/
loader.rs

1//! [`load_project`]: the single public entry point onto this crate. Loads
2//! a root Beamfile and every file it (transitively) `import`s, namespacing
3//! each imported beam's id and `needs` entries by the alias it was
4//! imported under, and stamping every beam with the `SourceId` of the file
5//! that declared it and a `dir` (its defining file's directory, always
6//! absolute — see "Three path forms, never mixed" below) used as the
7//! `cwd` base.
8//!
9//! ## Resolution rules
10//!
11//! - `import "api/Beamfile" as api` resolves the path relative to the
12//!   *importing file's* directory, not the process's current directory.
13//! - A beam declared in an imported file gets its id (and every entry of
14//!   its `needs` list) prefixed with `alias:`. A `needs [build]` entry
15//!   local to the imported file stays file-local — it's namespaced to
16//!   `alias:build` by the same prefixing, exactly like the beam it refers
17//!   to. `needs [other_alias:build]`, referring to one of *that file's
18//!   own* imports, is namespaced the same way. Nested imports compose: a
19//!   beam reached through two levels of aliasing ends up with a
20//!   two-segment id (`api:db:migrate`), and `needs` can spell as many
21//!   segments as a reference needs.
22//! - `default` is read only from the root file; an imported file's
23//!   `default` is parsed (so it's still a valid Beamfile on its own) but
24//!   never propagated.
25//! - `version` is parsed (by `alba_syntax`) but not semantically used
26//!   anywhere in this crate, in the root file or an imported one — this
27//!   matches single-file loading's behavior, so imported files are
28//!   treated no differently from the root on this point.
29//! - An import alias can never contain `:`: it's parsed as a single
30//!   identifier token (`alba_syntax`'s `eat_ident`, the same rule beam and
31//!   parameter names follow), so `import "x" as a:b` is already a syntax
32//!   error before this crate ever sees it. No additional validation is
33//!   needed here.
34//! - Two imports in the same file cannot share an alias — an ambiguity
35//!   this crate rejects explicitly (see [`check_duplicate_aliases`]).
36//! - Import cycles are detected by canonicalized path (see
37//!   [`Loader::load_file`]'s doc comment for why that's safe on
38//!   case-insensitive filesystems).
39//!
40//! ## Three path forms, never mixed
41//!
42//! Every file this loader touches has three distinct path forms in play,
43//! each with exactly one job, and they are kept strictly separate:
44//!
45//! - The **display path**: whatever was written or joined lexically —
46//!   `root` exactly as the caller passed it, or an import's path joined
47//!   onto its *importer's own display path* with a plain [`Path::join`],
48//!   never touching the filesystem and never absolutized. This is the
49//!   *only* form ever stored in [`SourceMap`] or shown in a message (a
50//!   missing-import error, an import cycle's chain) — so what a user sees
51//!   in a diagnostic is always traceable back to what they (or an
52//!   `import` statement) actually typed, never a path this loader
53//!   invented.
54//! - The **canonical path**: `std::fs::canonicalize`'s output. Used
55//!   *exclusively* as the identity key on [`Loader::stack`] for cycle
56//!   detection — never stored, joined onto, or displayed.
57//! - The **beam directory** (`beam_dir` in [`Loader::load_file`]):
58//!   `std::path::absolute`'s output, used *exclusively* to become
59//!   `Beam::dir` (the `cwd` base a later engine hands to a process spawn,
60//!   possibly long after loading finished and the process's current
61//!   directory may have changed). Computed independently from the display
62//!   path for each file — never joined onto to build another file's
63//!   display path, and never itself absolutized further by a child's
64//!   `beam_dir` (each file's `beam_dir` comes straight from its own
65//!   `path`, not from its importer's `beam_dir`).
66//!
67//! Mixing the display path with either of the other two is exactly the
68//! bug each split exists to prevent:
69//!
70//! - Display vs. canonical: on Windows, `canonicalize` returns a verbatim
71//!   (`\\?\`) path, and Rust's standard library passes verbatim paths to
72//!   Win32 completely unnormalized — no `.`/`..` resolution, no
73//!   forward-slash-to-backslash conversion. Joining a further relative
74//!   import path onto a canonicalized directory would silently produce an
75//!   unreadable path on Windows even though every path segment involved
76//!   is individually valid.
77//! - Display vs. beam directory: joining onto an *absolutized* directory
78//!   instead of the display path would make every descendant's display
79//!   path (and `SourceMap` entry) absolute too, even when the user typed
80//!   a relative `root` — silently showing them a path they never wrote.
81//!   `std::path::absolute` itself stays safe to use for `beam_dir`
82//!   precisely because it's *not* threaded into the display-path chain:
83//!   unlike `canonicalize`, it's purely lexical (it does not touch the
84//!   filesystem or require the path to exist) and, per
85//!   `library/std/src/sys/path/windows.rs`'s `absolute` implementation,
86//!   it only returns a path unchanged (skipping normalization) when the
87//!   *input* is already verbatim — our inputs never are, since they're
88//!   always built by joining onto a display path, never a canonicalized
89//!   one.
90
91use std::collections::{HashMap, HashSet};
92use std::path::{Path, PathBuf};
93use std::sync::Arc;
94
95use alba_syntax::{File, Span, Spanned};
96
97use crate::error::{CoreError, SourceIdScope};
98use crate::eval::{LazyGit, build_project, parse_error_to_core_error};
99use crate::model::{Beam, BeamId, Hook, Project, SourceId};
100
101/// The path and source text of every file [`load_project`] read, indexed
102/// by [`SourceId`], so a [`CoreError`]'s `source_id` can be turned back
103/// into something renderable: look up `(path, source)` here and hand both
104/// to `alba_syntax::render_diagnostic(source, &path.display().to_string(),
105/// &diagnostic)`.
106///
107/// Two maps are equal when they registered the same files with the same
108/// text — which is to say when loading found the project unchanged. A
109/// caller that reloads repeatedly (the watch session, on every batch its
110/// watcher delivers) uses that to tell a reload that changed something
111/// from one that only cost a file read: loading is deterministic, so equal
112/// sources mean an equal outcome, down to the rendered diagnostic.
113#[derive(Debug, Default, Clone, PartialEq, Eq)]
114pub struct SourceMap {
115    entries: Vec<(PathBuf, String)>,
116}
117
118impl SourceMap {
119    fn push(&mut self, path: PathBuf, source: String) -> SourceId {
120        let id = SourceId(self.entries.len());
121        self.entries.push((path, source));
122        id
123    }
124
125    /// The path and source text of the file `id` was assigned to.
126    /// `None` only when `id` was never registered — the sole way that
127    /// happens is [`load_project`] failing to read the root file itself,
128    /// before any entry exists to assign an id to.
129    pub fn get(&self, id: SourceId) -> Option<(&Path, &str)> {
130        self.entries
131            .get(id.0)
132            .map(|(path, source)| (path.as_path(), source.as_str()))
133    }
134
135    /// The path of every file this map registered, in [`SourceId`] order:
136    /// the root Beamfile first, then each import in load order. This is the
137    /// watch loop's source of truth for which Beamfiles to put under watch.
138    pub fn paths(&self) -> impl Iterator<Item = &Path> {
139        self.entries.iter().map(|(path, _)| path.as_path())
140    }
141}
142
143/// A [`load_project`] failure, paired with every source file successfully
144/// read before it happened. `sources` lets a caller render `error` (via
145/// [`SourceMap::get`] and `alba_syntax::render_diagnostic`) even though
146/// loading never finished — `error.source_id` is always resolvable
147/// through it, since a file is registered in `sources` before it's parsed
148/// or evaluated.
149///
150/// Implements [`std::error::Error`] (via `thiserror`, `#[source]`-linked to
151/// `error`) so it composes with `?` into `Box<dyn Error>`/`anyhow`, not
152/// just with this crate's own `Result<_, LoadError>`.
153#[derive(Debug, thiserror::Error)]
154#[error("{error}")]
155pub struct LoadError {
156    #[source]
157    pub error: CoreError,
158    pub sources: SourceMap,
159}
160
161/// Loads `root` and every Beamfile it (transitively) `import`s into one
162/// [`Project`], namespacing imported beams by the alias they were
163/// imported under. See the module doc comment for the resolution rules.
164///
165/// This is the single public entry point the CLI calls: `load_str` (used
166/// internally by this crate's and the engine's tests) stays
167/// `#[doc(hidden)]`.
168///
169/// Runs [`crate::graph::validate_graph`] once every file has finished
170/// loading, before returning the assembled [`Project`] — that check runs
171/// with no [`SourceIdScope`] active (loading is over by then), so it
172/// stamps each error it produces with the offending beam's own `source`
173/// field itself, rather than leaving it at whatever this thread's ambient
174/// id defaults to.
175pub fn load_project(root: &Path) -> Result<(Project, SourceMap), LoadError> {
176    let mut loader = Loader::default();
177    match loader.load_file(root, Span::new(0, 0), true) {
178        Ok((beams, default, hooks)) => {
179            let project = Project {
180                beams,
181                default,
182                hooks,
183            };
184            match crate::graph::validate_graph(&project) {
185                Ok(()) => Ok((project, loader.sources)),
186                Err(error) => Err(LoadError {
187                    error,
188                    sources: loader.sources,
189                }),
190            }
191        }
192        Err(error) => Err(LoadError {
193            error,
194            sources: loader.sources,
195        }),
196    }
197}
198
199/// What one file's load produced: its own beams and everything it
200/// imports, ids and `needs` namespaced relative to that file itself, plus
201/// its own `default` and `hooks`.
202type LoadedFile = (Vec<Beam>, Option<Spanned<BeamId>>, Vec<Hook>);
203
204#[derive(Default)]
205struct Loader {
206    sources: SourceMap,
207    /// `(canonical path, display path)` pairs for the files currently on
208    /// the DFS chain from the root to whichever file is being loaded right
209    /// now. Only the canonical half is ever compared (to detect an import
210    /// cycle); the display half is what a cycle's error message actually
211    /// shows.
212    stack: Vec<(PathBuf, PathBuf)>,
213    /// Every file already loaded to completion, keyed by canonical path.
214    ///
215    /// Two import sites can legitimately reach the same file under
216    /// different aliases and must end up with differently-namespaced
217    /// beams — but that difference is applied *afterwards*, by the
218    /// importing file, and is purely textual. Reading, parsing, and
219    /// evaluating the file itself is identical either way, so it happens
220    /// once. Without this, cost is exponential in nesting depth: a chain
221    /// of files that each import the next twice doubles the work per
222    /// level, and nineteen three-line files were enough to make loading
223    /// take half a minute.
224    ///
225    /// A file on `stack` is by definition not in here (nothing is recorded
226    /// until its load returns), so memoization can never mask an import
227    /// cycle. The [`SourceMap`] entry, and therefore what a diagnostic
228    /// about that file shows, comes from whichever import site reached it
229    /// first — the display path the *other* site would have produced names
230    /// the same file, so both point a reader at the same text.
231    loaded: HashMap<PathBuf, LoadedFile>,
232    /// The one `LazyGit` shared by every file this load reads, rooted at
233    /// the first Beamfile's directory (the root, since it is the first
234    /// file [`Loader::load_file`] ever sees). Lazily created on that first
235    /// call so a `load_project` that never even opens a file spawns
236    /// nothing.
237    git: Option<Arc<LazyGit>>,
238}
239
240impl Loader {
241    /// The canonical form of `path` (a display path — see the module doc
242    /// comment), reporting a failure at `error_span` (stamped with
243    /// whichever file is currently loading — the importer, for every call
244    /// except the very first).
245    ///
246    /// Canonicalizing is what makes cycle detection and memoization in
247    /// [`Loader::load_file`] correct — on a case-insensitive filesystem
248    /// (the default on macOS and Windows), `std::fs::canonicalize`
249    /// normalizes a path to the casing actually on disk, so two imports
250    /// spelling the same file with different casing still compare equal.
251    /// The canonical path is used for nothing else: reading goes through
252    /// `path` as given, and the caller never derives `dir` or a
253    /// [`SourceMap`] entry from the canonical form (see the module doc
254    /// comment for why).
255    fn canonical(path: &Path, error_span: Span) -> Result<PathBuf, CoreError> {
256        std::fs::canonicalize(path).map_err(|e| unreadable(path, &e, error_span))
257    }
258
259    /// Reads `path` as it was written. Split from [`Loader::canonical`] so
260    /// a file already loaded through another import site can be recognized
261    /// before it is read a second time.
262    fn read(path: &Path, error_span: Span) -> Result<String, CoreError> {
263        std::fs::read_to_string(path).map_err(|e| unreadable(path, &e, error_span))
264    }
265
266    /// Recursively loads `path` (a display path: already resolved,
267    /// lexically, relative to whatever imported it — or the root path the
268    /// caller passed to [`load_project`]) and everything it imports in
269    /// turn, returning that subtree's beams — ids and `needs` namespaced
270    /// relative to `path` itself — plus `path`'s own `default` (the caller
271    /// decides whether that's meaningful: only [`load_project`]'s
272    /// top-level call uses it, every recursive call for an `import`
273    /// discards it).
274    ///
275    /// A file already loaded through another import site is returned from
276    /// [`Loader::loaded`] rather than read again — see that field's doc
277    /// comment.
278    ///
279    /// `error_span` is where a failure to read `path`, or an import cycle
280    /// closing on it, gets reported; it's the span of the `import` that
281    /// named `path` for every call except the very first (the root has no
282    /// such span, so [`load_project`] passes a zero-width one).
283    ///
284    /// `is_root` is `true` only for [`load_project`]'s own top-level call;
285    /// every recursive call for an `import` passes `false`. Threaded down
286    /// into [`Loader::load_file_body`] and then [`build_project`], whose
287    /// unknown-hook-name and duplicate-hook checks must only ever run for
288    /// the root file — a `hook` problem in an imported file must not fail
289    /// the whole load when that file's hooks are discarded anyway (see
290    /// this function's `default` handling above for the same rule applied
291    /// to a different field).
292    fn load_file(
293        &mut self,
294        path: &Path,
295        error_span: Span,
296        is_root: bool,
297    ) -> Result<LoadedFile, CoreError> {
298        let canonical = Self::canonical(path, error_span)?;
299
300        if let Some(start) = self.stack.iter().position(|(c, _)| *c == canonical) {
301            let mut chain: Vec<String> = self.stack[start..]
302                .iter()
303                .map(|(_, display)| display.display().to_string())
304                .collect();
305            chain.push(path.display().to_string());
306            return Err(CoreError::new(
307                format!("import cycle: {}", chain.join(" -> ")),
308                error_span,
309            ));
310        }
311
312        if let Some(loaded) = self.loaded.get(&canonical) {
313            return Ok(loaded.clone());
314        }
315
316        let source = Self::read(path, error_span)?;
317
318        // `import_base` (a display path) is derived from `path`, never
319        // from `canonical` — see the module doc comment. `path.parent()`
320        // for a bare filename like `"Beamfile"` (no directory component)
321        // is `Some("")`, not `None`, so the `unwrap_or_else` below rarely
322        // fires in practice — but joining onto an empty `PathBuf` is a
323        // harmless no-op, so this still resolves further imports relative
324        // to the process's current directory in that case. Used *only* to
325        // build each import's display path (`import_path` below); it is
326        // never what becomes `Beam::dir` (see `beam_dir`).
327        let import_base = path
328            .parent()
329            .map(Path::to_path_buf)
330            .unwrap_or_else(|| PathBuf::from("."));
331
332        let beam_dir = beam_dir_for(path, error_span)?;
333        let git = Arc::clone(
334            self.git
335                .get_or_insert_with(|| Arc::new(LazyGit::new(beam_dir.clone()))),
336        );
337
338        let source_id = self.sources.push(path.to_path_buf(), source.clone());
339
340        self.stack.push((canonical.clone(), path.to_path_buf()));
341        let result = self.load_file_body(&source, &import_base, &beam_dir, source_id, git, is_root);
342        self.stack.pop();
343
344        let loaded = result?;
345        self.loaded.insert(canonical, loaded.clone());
346        Ok(loaded)
347    }
348
349    /// The part of [`Loader::load_file`] that runs once `path` has been
350    /// read and pushed onto the cycle-detection stack: parse, evaluate
351    /// this file's own beams (stamped with `beam_dir`), check for a
352    /// duplicate import alias, then recurse into each import (joined onto
353    /// `import_base`) and namespace its beams. Split out so `load_file`
354    /// can guarantee `stack.pop()` runs via a plain `?`-propagating helper
355    /// rather than a closure.
356    fn load_file_body(
357        &mut self,
358        source: &str,
359        import_base: &Path,
360        beam_dir: &Path,
361        source_id: SourceId,
362        git: Arc<LazyGit>,
363        is_root: bool,
364    ) -> Result<LoadedFile, CoreError> {
365        let _scope = SourceIdScope::enter(source_id);
366        let file: File = alba_syntax::parse(source).map_err(parse_error_to_core_error)?;
367        check_duplicate_aliases(&file)?;
368
369        let local = build_project(&file, beam_dir, git, is_root)?;
370        let mut beams = local.beams;
371
372        for import in &file.imports {
373            let import_path = import_base.join(&import.path.value);
374            let (mut child_beams, _child_default, _child_hooks) =
375                self.load_file(&import_path, import.path.span, false)?;
376            let alias = &import.alias.value;
377            for beam in &mut child_beams {
378                beam.id.0 = format!("{alias}:{}", beam.id.0);
379                for need in &mut beam.needs {
380                    need.value.0 = format!("{alias}:{}", need.value.0);
381                }
382            }
383            beams.extend(child_beams);
384        }
385
386        Ok((beams, local.default, local.hooks))
387    }
388}
389
390/// The single "cannot read this Beamfile" message, shared by the two
391/// filesystem calls [`Loader::load_file`] makes on a file's path, so the
392/// two cannot drift into describing the same missing file differently.
393fn unreadable(path: &Path, error: &std::io::Error, error_span: Span) -> CoreError {
394    CoreError::new(
395        format!("cannot read Beamfile `{}`: {error}", path.display()),
396        error_span,
397    )
398}
399
400/// Computes the directory that becomes `Beam::dir` for every beam declared
401/// in the file at `path`: `std::path::absolute(path)`'s parent. Reported
402/// at `error_span` if `path` can't be absolutized (rare — see
403/// `std::path::absolute`'s docs for when it fails; an empty `path` is the
404/// only realistic case, and `path` here is never empty).
405///
406/// Deliberately a free function taking a `&Path` rather than a
407/// `Loader` method: it touches neither `self` nor the filesystem
408/// (`std::path::absolute` does not require `path` to exist), which is
409/// what lets it be unit tested directly against synthetic paths — a bare
410/// filename, a relative path, an already-absolute one — without a real
411/// file, a `tempdir`, or (worse) mutating the process's current directory
412/// to fabricate a "relative root" scenario, which would race every other
413/// test in this crate's suite (`cargo test` runs test functions
414/// concurrently on a shared process).
415fn beam_dir_for(path: &Path, error_span: Span) -> Result<PathBuf, CoreError> {
416    let absolute = std::path::absolute(path).map_err(|e| {
417        CoreError::new(
418            format!(
419                "cannot resolve an absolute path for `{}`: {e}",
420                path.display()
421            ),
422            error_span,
423        )
424    })?;
425    Ok(absolute
426        .parent()
427        .map(Path::to_path_buf)
428        // Unreachable in practice: `std::path::absolute` keeps `path`'s
429        // final component (the Beamfile's own file name), so the result
430        // always has a parent — even a filesystem-root file (`/Beamfile`)
431        // absolutizes to `/Beamfile`, whose parent is `/`, itself a valid
432        // absolute directory (see this function's tests). Kept as a
433        // non-panicking fallback rather than `.expect(..)`: this is a
434        // library, and a defensive branch should never crash the caller
435        // even if the "unreachable" reasoning turns out wrong on some
436        // future platform.
437        .unwrap_or_else(|| PathBuf::from(".")))
438}
439
440/// Rejects two `import`s in the same file sharing an alias — otherwise
441/// namespace prefixing would silently merge two unrelated files' beams
442/// under the same prefix.
443fn check_duplicate_aliases(file: &File) -> Result<(), CoreError> {
444    let mut seen = HashSet::new();
445    for import in &file.imports {
446        if !seen.insert(import.alias.value.as_str()) {
447            return Err(CoreError::new(
448                format!("duplicate import alias `{}`", import.alias.value),
449                import.alias.span,
450            ));
451        }
452    }
453    Ok(())
454}
455
456#[cfg(test)]
457mod tests {
458    use super::*;
459
460    /// Regression test for the fix-round bug where `Beam::dir` for a
461    /// bare-filename root (`load_project(Path::new("Beamfile"))`, a
462    /// plausible CLI default) was the *empty* path: `Path::new("Beamfile")
463    /// .parent()` is `Some("")`, not `None`, so a fallback keyed on `None`
464    /// never fired. `beam_dir_for` must never produce that: it's absolute
465    /// and non-empty here.
466    #[test]
467    fn beam_dir_for_bare_filename_is_absolute_and_non_empty() {
468        let dir = beam_dir_for(Path::new("Beamfile"), Span::new(0, 0)).unwrap();
469        assert!(dir.is_absolute());
470        assert!(!dir.as_os_str().is_empty());
471        assert!(!dir.to_string_lossy().starts_with(r"\\?\"));
472    }
473
474    /// A relative root with a directory component (`load_project(Path::new(
475    /// "sub/Beamfile"))`) must still resolve to an absolute `Beam::dir` —
476    /// otherwise it's only correct for as long as the process's current
477    /// directory doesn't change between loading and running, a coupling
478    /// the previous (`canonicalize`-based) implementation didn't have.
479    #[test]
480    fn beam_dir_for_relative_path_is_absolute() {
481        let dir = beam_dir_for(Path::new("sub/Beamfile"), Span::new(0, 0)).unwrap();
482        assert!(dir.is_absolute());
483        assert!(dir.ends_with("sub"));
484        assert!(!dir.to_string_lossy().starts_with(r"\\?\"));
485    }
486
487    /// An already-absolute root stays absolute and gains no verbatim
488    /// prefix — `std::path::absolute` only returns a path unchanged
489    /// (skipping its own normalization) when the *input* is already
490    /// verbatim, which nothing in this crate ever constructs (see the
491    /// module doc comment's "beam directory" bullet).
492    #[test]
493    fn beam_dir_for_absolute_path_has_no_verbatim_prefix() {
494        let root = if cfg!(windows) {
495            PathBuf::from(r"C:\projects\x\Beamfile")
496        } else {
497            PathBuf::from("/projects/x/Beamfile")
498        };
499        let dir = beam_dir_for(&root, Span::new(0, 0)).unwrap();
500        assert!(dir.is_absolute());
501        assert!(!dir.to_string_lossy().starts_with(r"\\?\"));
502    }
503
504    /// A root whose parent is the filesystem root itself (`/Beamfile` on
505    /// Unix) still yields a valid, absolute, non-empty directory (`/`) —
506    /// the one case where `Path::parent()` could plausibly be `None`
507    /// doesn't arise here because `std::path::absolute` always keeps the
508    /// file name component.
509    #[test]
510    fn beam_dir_for_root_level_file_is_still_a_valid_directory() {
511        let root = if cfg!(windows) {
512            PathBuf::from(r"C:\Beamfile")
513        } else {
514            PathBuf::from("/Beamfile")
515        };
516        let dir = beam_dir_for(&root, Span::new(0, 0)).unwrap();
517        assert!(dir.is_absolute());
518        assert!(!dir.as_os_str().is_empty());
519        assert!(!dir.to_string_lossy().starts_with(r"\\?\"));
520    }
521
522    /// The watch loop needs every loaded Beamfile path — root and imports —
523    /// to put them under watch; `paths()` yields them in load order.
524    #[test]
525    fn source_map_lists_every_loaded_file_in_load_order() {
526        let dir = tempfile::tempdir().unwrap();
527        std::fs::create_dir(dir.path().join("api")).unwrap();
528        std::fs::write(
529            dir.path().join("Beamfile"),
530            "import \"api/Beamfile\" as api\nbeam build { run \"echo root\" }\n",
531        )
532        .unwrap();
533        std::fs::write(
534            dir.path().join("api").join("Beamfile"),
535            "beam build { run \"echo api\" }\n",
536        )
537        .unwrap();
538
539        let (_, sources) = load_project(&dir.path().join("Beamfile")).unwrap();
540
541        let paths: Vec<_> = sources.paths().collect();
542        assert_eq!(paths.len(), 2);
543        assert!(paths[0].ends_with("Beamfile"));
544        assert!(paths[1].ends_with(std::path::Path::new("api").join("Beamfile")));
545    }
546}