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}