Skip to main content

rustyfi_loader/
lib.rs

1//! Multi-file loading layer for SATySFi documents: resolves `@require:` /
2//! `@import:` headers to files on disk, recursively parses the whole
3//! dependency graph, and returns it in dependency-first (topological) load
4//! order.
5//!
6//! Transcribed from v0.0.6's `src/frontend/main.ml` (lines ~95-140):
7//!
8//! - `@import: name` resolves relative to the directory of the file
9//!   *containing* the header (not the entry document's directory).
10//! - `@require: name` resolves against the package/library root
11//!   (`LoadOptions::lib_root`).
12//! - Candidate extensions, tried in order: `.satyh`, then `.satyg` (the
13//!   mode-specific `.satyh-<mode>` extensions from `main.ml` are out of
14//!   scope here).
15//! - The same file reached through two different headers is one graph node
16//!   (deduplicated by canonical path).
17//! - Every dependency must be a library file (`body: None`); the entry must
18//!   be a document (`body: Some(..)`).
19//! - A cycle in the dependency graph is an error naming the files involved.
20
21mod error;
22mod graph;
23pub mod v006;
24mod v01x;
25
26use std::collections::{HashMap, HashSet};
27use std::path::{Path, PathBuf};
28
29pub use error::LoadError;
30pub use rustyfi_syntax::RustyfiVersion;
31
32/// Header resolution on its own, without loading anything.
33///
34/// [`load`] is the whole pipeline — resolve, read, parse, order — and a
35/// language server answering "where does this `@require:` point?" wants only
36/// the first step, against a buffer that may not compile and dependencies it
37/// has no reason to read. These are the very functions [`load`] itself calls,
38/// exported rather than reimplemented so the editor and the compiler can never
39/// disagree about which file a header names.
40pub use v006::resolve::{resolve_import, resolve_require};
41
42/// Where the Legacy loader gets source text from.
43///
44/// Three operations, which is the entire filesystem surface of the
45/// `@require:`/`@import:` path: read a file, probe a resolution candidate,
46/// and turn a path into the identity its dependency-graph node is keyed by.
47///
48/// The seam exists because `wasm32-unknown-unknown` has no filesystem at all,
49/// and a SATySFi document that cannot `@require:` anything is barely a
50/// document — the browser playground serves the bundled 0.0.6 corpus straight
51/// out of the binary through this trait. It is also the honest way to write a
52/// loader test that needs a dependency graph but not a temp directory.
53///
54/// `LoadOptions::sources` is `None` by default, which means [`FsSources`] —
55/// the real filesystem, byte-for-byte the behaviour that predates this trait.
56pub trait SourceProvider {
57    /// The file's text. `Err` is surfaced as [`LoadError::Io`].
58    fn read(&self, path: &Path) -> std::io::Result<String>;
59
60    /// Whether `path` names a readable file. Drives candidate selection in
61    /// `@require:`/`@import:` resolution, so a `false` here is an ordinary
62    /// "try the next candidate", not an error.
63    fn is_file(&self, path: &Path) -> bool;
64
65    /// `path`'s canonical form — the key two headers naming the same file
66    /// must agree on, or it would be loaded (and its bindings emitted) twice.
67    ///
68    /// A provider whose paths are already unique and absolute by construction
69    /// may return `path` unchanged; the loader only requires that the same
70    /// file always maps to the same key. Note that the per-file version
71    /// detector reads the RESULT, so a provider serving the frozen 0.0.6
72    /// corpus must keep the `dist/packages` components in it.
73    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf>;
74}
75
76/// The real filesystem — [`SourceProvider`]'s default.
77#[derive(Debug, Default, Clone, Copy)]
78pub struct FsSources;
79
80impl SourceProvider for FsSources {
81    fn read(&self, path: &Path) -> std::io::Result<String> {
82        std::fs::read_to_string(path)
83    }
84
85    fn is_file(&self, path: &Path) -> bool {
86        path.is_file()
87    }
88
89    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
90        std::fs::canonicalize(path)
91    }
92}
93
94/// The `None` case of [`LoadOptions::sources`], as a borrowable value.
95static FS_SOURCES: FsSources = FsSources;
96
97/// How multi-file dependencies are declared and resolved — Axis B,
98/// orthogonal to [`RustyfiVersion`] (Axis A, the grammar generation),
99/// except that the one combination with no upstream analogue — `V0_0` +
100/// `Envelopes` — is rejected by [`load`] up front.
101#[derive(Debug, Clone, Default, PartialEq, Eq)]
102pub enum LoadMode {
103    /// `@require:`/`@import:` header search against [`LoadOptions::lib_root`],
104    /// and `dev-0-1-0`'s *only* mode too (its headers are byte-identical to
105    /// 0.0.6's, minus `@stage:`). The [`Default`].
106    #[default]
107    Legacy,
108    /// `use package` / `use … of` headers resolved the `saphe-split` way
109    /// (upstream ≈ "0.1.0-alpha.1"): local files by relative path, packages
110    /// from a pre-solved envelope graph. Requires `version == V0_1`.
111    Envelopes {
112        /// Path to a pre-resolved `rustyfi-deps.yaml` (upstream's mandatory
113        /// `--deps` flag on `rustyfi build`, `saphe-split:bin/rustyfi.ml`,
114        /// `flag_deps`). `None` = no package dependencies available: any `use
115        /// package` header is a [`LoadError::PackageDependencyUnresolved`].
116        /// `Some(path)` is decoded, its envelopes are read + topo-
117        /// sorted, and each `use package M` header is validated against the
118        /// config's `used_as` aliases; the envelope source files are prepended
119        /// to the loaded program (dependency-first, before any local file).
120        deps: Option<PathBuf>,
121    },
122}
123
124/// Options controlling header resolution.
125///
126/// Implements [`Default`] so a call site names only the fields it cares
127/// about: `LoadOptions { lib_root: ..., ..Default::default() }`. Adding a
128/// field here must keep that spelling working.
129#[derive(Default)]
130pub struct LoadOptions {
131    /// Root used to resolve `@require: name` (searched as
132    /// `<lib_root>/dist/packages/name.{satyh,satyg}`, then
133    /// `<lib_root>/name.{satyh,satyg}`, then the nested
134    /// `<lib_root>/dist/packages/name/name.{satyh,satyg}` layout the
135    /// Satyrographos installer produces — see `resolve::resolve_require`).
136    /// `None` means there is no package root configured, so any `@require:`
137    /// header fails to resolve.
138    pub lib_root: Option<PathBuf>,
139    /// The REST of the library-root search path, searched in order after
140    /// [`Self::lib_root`] — a project-local root does not hide the wider ones,
141    /// so a document can `@require:` one package a project installed for
142    /// itself and the next from the development tree or the system install.
143    /// Empty by default: one named root and nothing behind it.
144    pub fallback_roots: Vec<PathBuf>,
145    /// The SATySFi language version the input is expected to conform to.
146    /// Defaults to [`RustyfiVersion::DEFAULT`] (0.0.6). [`load`] rejects any
147    /// version for which [`RustyfiVersion::is_implemented`] is false before
148    /// doing any work.
149    pub version: RustyfiVersion,
150    /// How dependencies are resolved. Defaults to [`LoadMode::Legacy`].
151    /// `lib_root`/`fallback_roots` are ignored by the Envelopes backend,
152    /// which resolves `use … of` relative paths and a `rustyfi-deps.yaml`
153    /// envelope graph instead.
154    pub mode: LoadMode,
155    /// Where source text comes from. `None` (the default) is the real
156    /// filesystem, [`FsSources`].
157    ///
158    /// Honoured by [`LoadMode::Legacy`] only. The Envelopes backend reads
159    /// deps/envelope configs through `std::fs` directly and would silently
160    /// ignore this, so [`load`] refuses that combination up front rather than
161    /// half-applying it.
162    pub sources: Option<Box<dyn SourceProvider>>,
163}
164
165impl LoadOptions {
166    /// The library-root search path in order: [`Self::lib_root`], then
167    /// [`Self::fallback_roots`]. Empty when no root is configured at all, in
168    /// which case every `@require:` fails to resolve.
169    fn roots(&self) -> Vec<&Path> {
170        self.lib_root
171            .as_deref()
172            .into_iter()
173            .chain(self.fallback_roots.iter().map(|p| p.as_path()))
174            .collect()
175    }
176
177    /// [`Self::sources`], defaulting to the real filesystem.
178    fn sources(&self) -> &dyn SourceProvider {
179        match &self.sources {
180            Some(provider) => provider.as_ref(),
181            None => &FS_SOURCES,
182        }
183    }
184}
185
186/// A parsed file's CST, tagged by which grammar generation produced it.
187/// `load()` picks the variant per file, from that file's own
188/// [`LoadedFile::version`] — which a cross-version Legacy load can vary
189/// WITHIN one program (see that field). The enum exists so `LoadedFile` has a
190/// single field type rather than forcing every consumer of `LoadedProgram` to
191/// be generic over the CST type.
192#[derive(Debug)]
193pub enum LoadedCst {
194    V0_0(rustyfi_syntax::cst::File),
195    V0_1(rustyfi_syntax::cst_v1::FileV1),
196}
197
198impl LoadedCst {
199    /// Whether this file is a document (has a body) rather than a library.
200    /// Used by `load()`'s entry/dependency-shape validation
201    /// (`DocumentAsDependency`/`LibraryAsEntry`) uniformly across both
202    /// generations, so that validation logic itself needs no `match` at its
203    /// call sites.
204    pub fn is_document(&self) -> bool {
205        match self {
206            Self::V0_0(f) => f.body.is_some(),
207            Self::V0_1(f) => matches!(f, rustyfi_syntax::cst_v1::FileV1::Document { .. }),
208        }
209    }
210
211    /// This `V0_0` file's `@require:`/`@import:`/`@stage:` headers, or
212    /// `None` for a `V0_1` file. Each generation's header list has a distinct
213    /// element type (`V0_1` carries `HeaderV1`, the union grammar), so the
214    /// shared facade offers one total accessor per generation rather than one
215    /// `Header`-typed accessor for both.
216    fn headers_v006(&self) -> Option<&[rustyfi_syntax::cst::Header]> {
217        match self {
218            Self::V0_0(f) => Some(&f.headers),
219            Self::V0_1(_) => None,
220        }
221    }
222
223    /// This `V0_1` file's headers (the `HeaderV1` union — Legacy `@`-headers
224    /// plus the three `use` forms), or `None` for a `V0_0` file.
225    fn headers_v1(&self) -> Option<&[rustyfi_syntax::cst_v1::HeaderV1]> {
226        match self {
227            Self::V0_0(_) => None,
228            Self::V0_1(f) => Some(match f {
229                rustyfi_syntax::cst_v1::FileV1::Document { headers, .. }
230                | rustyfi_syntax::cst_v1::FileV1::Library { headers, .. } => headers,
231            }),
232        }
233    }
234}
235
236/// Where a loaded file came from — metadata for diagnostics and for a
237/// future `used_as` → module binding. Nothing in `rustyfi-lang` reads
238/// it yet.
239///
240/// Only two variants: a Legacy-mode file and an Envelopes-mode *local*
241/// (`use … of`) file / the entry document are both just "a plain local file"
242/// ([`FileOrigin::Local`], the [`Default`]); a distinct `Legacy` variant
243/// would be a distinction without a consumer (revisit if a future need
244/// requires the split). [`FileOrigin::Envelope`] tags a source file that
245/// came out of a deps-config envelope (`rustyfi-envelope.yaml`).
246#[derive(Debug, Clone, PartialEq, Eq, Default)]
247pub enum FileOrigin {
248    /// A Legacy-mode file, an Envelopes-mode local (`use … of`) dependency,
249    /// or the entry document. The [`Default`].
250    #[default]
251    Local,
252    /// A source file of a deps-config envelope: `envelope` is the envelope's
253    /// (deps-config) name, `module` the declared module name of this file.
254    Envelope { envelope: String, module: String },
255}
256
257/// One parsed file in a loaded program.
258#[derive(Debug)]
259pub struct LoadedFile {
260    /// Canonicalized path to the file on disk.
261    pub path: PathBuf,
262    /// The file's parsed concrete syntax tree, tagged by grammar generation;
263    /// every consumer matches on the variant. See [`LoadedCst`].
264    pub cst: LoadedCst,
265    /// Where this file came from ([`FileOrigin::Local`] for Legacy files and
266    /// Envelopes-mode locals; [`FileOrigin::Envelope`] for deps-config
267    /// envelope sources). Additive metadata — no consumer reads it yet.
268    pub origin: FileOrigin,
269    /// The `RustyfiVersion` grammar this SPECIFIC file was parsed under —
270    /// always matches `cst`'s variant (`V0_0` <-> `LoadedCst::V0_0`, `V0_1`
271    /// <-> `LoadedCst::V0_1`). Cross-version import: under `LoadMode::
272    /// Envelopes` and under a `LoadOptions { version: V0_0, .. }` Legacy
273    /// load, every file in one `LoadedProgram` shares one version (the
274    /// load's `opts.version`). Only a `LoadOptions { version: V0_1, mode:
275    /// Legacy, .. }` load can produce a MIXED-version `files` list:
276    /// `load_legacy`'s worklist (see its doc comment) per-file-detects a
277    /// `V0_0` dependency via the per-file version-detection rule below, so a
278    /// `V0_1` document can `@require:` a frozen `V0_0` package.
279    pub version: RustyfiVersion,
280}
281
282/// A fully loaded, dependency-resolved program.
283#[derive(Debug)]
284pub struct LoadedProgram {
285    /// Dependency-first order: every file appears after all the files it
286    /// depends on. Under Legacy mode that is the `@require:`/`@import:`
287    /// order; under Envelopes mode the ordering contract is: all deps-config
288    /// envelope sources first (dependency-first among themselves, each
289    /// envelope's modules closed-sorted), then the local `use … of` files
290    /// (dependency-first), then the entry document last.
291    pub files: Vec<LoadedFile>,
292}
293
294/// Load `entry` (a `.saty` document) and its full transitive dependency
295/// graph, dispatching on [`LoadOptions::mode`] (Axis B). [`LoadMode::Legacy`]
296/// resolves `@require:`/`@import:` headers (`load_legacy`);
297/// [`LoadMode::Envelopes`] resolves `use package`/`use … of` headers
298/// (`v01x::open_doc`).
299pub fn load(entry: &Path, opts: &LoadOptions) -> Result<LoadedProgram, LoadError> {
300    if !opts.version.is_implemented() {
301        return Err(LoadError::UnsupportedVersion {
302            requested: opts.version,
303            supported: RustyfiVersion::supported().to_vec(),
304        });
305    }
306
307    match &opts.mode {
308        LoadMode::Legacy => load_legacy(entry, opts),
309        LoadMode::Envelopes { deps } => {
310            // The Envelopes backend reads its deps/envelope configs through
311            // `std::fs` directly, so a caller-supplied provider would be
312            // half-honoured at best. Refuse loudly rather than serve a
313            // dependency graph half out of memory and half off the disk.
314            if opts.sources.is_some() {
315                return Err(LoadError::SourceProviderUnderEnvelopes);
316            }
317            // The one combination with no upstream analogue: 0.0.6 has no
318            // `use` headers to resolve against an envelope graph at all.
319            // Reject before touching the filesystem,
320            // like the version guard above. `!matches!(.., V0_1)` rather than
321            // `== V0_0`: `RustyfiVersion` is `#[non_exhaustive]`, so any
322            // hypothetical future third variant defaults to *rejected* under
323            // Envelopes until someone decides otherwise.
324            if !matches!(opts.version, RustyfiVersion::V0_1) {
325                return Err(LoadError::InvalidModeVersion {
326                    version: opts.version,
327                });
328            }
329            v01x::open_doc::load(entry, deps.as_deref(), opts)
330        }
331    }
332}
333
334/// Resolve one Legacy (`@require:`/`@import:`/`@stage:`) header to a file
335/// path, or `None` for `@stage:` (which drives no dependency edge). Shared by
336/// the `V0_0` and `V0_1`-Legacy header loops in [`load_legacy`].
337fn resolve_legacy_header(
338    header: &rustyfi_syntax::cst::Header,
339    dir: &Path,
340    from: &Path,
341    opts: &LoadOptions,
342) -> Result<Option<PathBuf>, LoadError> {
343    Ok(Some(match header {
344        rustyfi_syntax::cst::Header::Import(tok) => {
345            let sources = opts.sources();
346            v006::resolve::resolve_import(sources, dir, &tok.content).map_err(|searched| {
347                LoadError::UnresolvedImport {
348                    name: tok.content.clone(),
349                    from: from.to_path_buf(),
350                    searched,
351                }
352            })?
353        }
354        rustyfi_syntax::cst::Header::Require(tok) => {
355            let sources = opts.sources();
356            let roots = opts.roots();
357            v006::resolve::resolve_require(sources, &roots, &tok.content, opts.version).map_err(
358                |searched| LoadError::UnresolvedRequire {
359                    name: tok.content.clone(),
360                    searched,
361                },
362            )?
363        }
364        // `@stage: persistent` / `@stage: 0` / `@stage: 1` — a property of the
365        // file's BINDINGS, read by the compiler (`declared_stage`), not by
366        // header resolution; it drives no dependency edge.
367        rustyfi_syntax::cst::Header::Stage(_) => return Ok(None),
368    }))
369}
370
371/// The `LoadMode::Legacy` backend: `@require:`/`@import:` header resolution
372/// against `lib_root`, recursive parse, and dependency-first ordering. A
373/// shared worklist/validation shell around the `v006::` calls, with the
374/// header loop dispatching per grammar generation so a `V0_1`-under-Legacy
375/// file with a `use` header gets a typed
376/// [`LoadError::EnvelopeHeaderUnderLegacy`] rather than a parse error.
377fn load_legacy(entry: &Path, opts: &LoadOptions) -> Result<LoadedProgram, LoadError> {
378    let sources = opts.sources();
379    let entry_canon = canonicalize_via(sources, entry)?;
380
381    let mut next_id: u32 = 0;
382    let mut id_of: HashMap<PathBuf, u32> = HashMap::new();
383    let mut path_of: HashMap<u32, PathBuf> = HashMap::new();
384    let mut cst_of: HashMap<u32, LoadedCst> = HashMap::new();
385    // The per-file version each graph node was actually parsed
386    // under — see `LoadedFile::version`'s doc comment. Populated in
387    // lockstep with `cst_of` below; a `V0_0` load inserts `V0_0` for every
388    // node.
389    let mut version_of: HashMap<u32, RustyfiVersion> = HashMap::new();
390    // Node ids reached via at least one `@require:` header edge (as
391    // opposed to only `@import:` edges) — the "resolves under `lib_root`'s
392    // package tree" half of the per-file detection rule. Populated as
393    // dependency edges are discovered, below; irrelevant (never consulted)
394    // for a `V0_0` load.
395    let mut require_targets: HashSet<u32> = HashSet::new();
396    // The mirror: node ids reached via at least one `@require:` edge
397    // that resolved PHYSICALLY under `dist-v01/packages/` — the "resolves
398    // under the 0.1 corpus" half of the mirrored per-file detection rule.
399    // Populated in lockstep with `require_targets`, below; irrelevant
400    // (never consulted) for a `V0_1` load (that load uses
401    // `require_targets`/`is_dist_packages_target` instead).
402    let mut require_v01_targets: HashSet<u32> = HashSet::new();
403    // The version of the file that first reached this id over an `@import:`
404    // edge. An `@import:` is a SAME-PACKAGE, path-relative include — it can
405    // never name another package, let alone another generation — so an
406    // `@import:`ed file belongs to whatever generation its importer was
407    // written in, and inherits its version. Only `@require:` crosses a
408    // package (and therefore possibly a generation) boundary; that edge keeps
409    // the physical-provenance rule (`require_targets` /
410    // `require_v01_targets`) below, which is checked FIRST.
411    //
412    // Without this, every intra-package `@import:` of a real published 0.0.6
413    // package (`azmath/azmath.satyh`'s `@import: parens`, `base/bool.satyg`'s
414    // `@import: ord`, `easytable`, `arrows`, `derive`, `lipsum`, `railway`,
415    // `enumitem`, `fss`, …) fell through to `opts.version` under a `V0_1`
416    // load and was parsed with the 0.1 grammar — a parse error on the
417    // package's own `module M : sig` head, which is exactly the shape a
418    // 0.0.6 package is written in. Only files reached by `@require:` were
419    // ever downgraded, and multi-file packages are the norm, not the
420    // exception, in the published corpus.
421    //
422    // First writer wins (`or_insert`): a file `@import:`ed by two importers
423    // of DIFFERENT generations would be ambiguous anyway, and the worklist is
424    // deterministic. The entry's own `@import:`ed siblings still take the
425    // entry's version, since the entry is processed first.
426    let mut import_parent_version: HashMap<u32, RustyfiVersion> = HashMap::new();
427    let mut adjacency: HashMap<u32, Vec<u32>> = HashMap::new();
428    let mut processed: HashSet<u32> = HashSet::new();
429
430    let entry_id = alloc_id(entry_canon, &mut next_id, &mut id_of, &mut path_of);
431
432    let mut worklist = vec![entry_id];
433    while let Some(id) = worklist.pop() {
434        if processed.contains(&id) {
435            continue;
436        }
437        processed.insert(id);
438
439        let path = path_of[&id].clone();
440        let src = sources.read(&path).map_err(|source| LoadError::Io {
441            path: path.clone(),
442            source,
443        })?;
444        // Under a `V0_1` load, every NON-entry file gets its own
445        // per-file version — `sniff_version` first (a `use`/`val`-shaped
446        // file sniffs `Some(V0_1)` even inside the frozen corpus), else
447        // `V0_0` if this id was reached via at least one `@require:` edge
448        // (the corpus IS `dist/packages/`), else `opts.version`
449        // (`@import:`-relative siblings of the entry, and the entry itself,
450        // stay `V0_1`). A `V0_0` load is untouched: `file_version` is
451        // always `opts.version` there, exactly the old unconditional match.
452        let file_version = match opts.version {
453            RustyfiVersion::V0_1 if id != entry_id => rustyfi_syntax::sniff_version(&src)
454                .unwrap_or(
455                    // A non-sniffable `@require:` target defaults to V0_0
456                    // ONLY when it is physically under the frozen 0.0.6 corpus
457                    // `dist/packages/`. This must EXCLUDE `dist-v01/
458                    // packages/` — those are V0_1 packages and the substring
459                    // `/dist/packages/` does not match `/dist-v01/packages/`.
460                    // Everything else (dist-v01 requires, @import: siblings)
461                    // stays `opts.version` = V0_1.
462                    if require_targets.contains(&id)
463                        && path.to_string_lossy().contains("/dist/packages/")
464                    {
465                        RustyfiVersion::V0_0
466                    } else {
467                        // …else inherit the version of whoever `@import:`ed
468                        // it (a same-package sibling of a spliced 0.0.6
469                        // package is itself 0.0.6), falling back to the
470                        // load's own version for the entry's siblings and
471                        // anything reached no other way. See
472                        // `import_parent_version`'s declaration.
473                        import_parent_version
474                            .get(&id)
475                            .copied()
476                            .unwrap_or(RustyfiVersion::V0_1)
477                    },
478                ),
479            // The mirror: a `V0_0`-rooted load's NON-entry file defaults
480            // to `opts.version` (`V0_0`) unless `sniff_version` returns
481            // `Some(V0_1)`, in which case it MUST default to `V0_1` when
482            // this id was reached via at least one `@require:` edge that
483            // resolved physically under the 0.1 corpus `dist-v01/packages/`
484            // (the mirror of `require_targets` + `is_dist_packages_target`
485            // above) — a `module … :> sig …`-headed
486            // 0.1 package (e.g. `v01-sealed.satyh`) sniffs `None` just like a
487            // 0.0.6 `module`-headed corpus file does (`version.rs`'s own doc
488            // comment: a bare `module` head is deliberately no signal), so
489            // this provenance fallback is what actually resolves it. It is a
490            // PURE WIDENING: nothing resolves `V0_1` here unless it is BOTH
491            // under `dist-v01/packages/` AND reached via `@require:`, and
492            // `require_v01_targets` is empty for a load that never resolves a
493            // `dist-v01/packages/` target, so the `unwrap_or` falls through
494            // to `V0_0`.
495            RustyfiVersion::V0_0 if id != entry_id => rustyfi_syntax::sniff_version(&src)
496                .unwrap_or(if require_v01_targets.contains(&id) {
497                    RustyfiVersion::V0_1
498                } else {
499                    // The mirror of the `V0_1` arm's `@import:` inheritance:
500                    // a 0.1 package spliced into a 0.0.6-rooted load may
501                    // `@import:` its own siblings too, and they are 0.1.
502                    // Everything reached only from 0.0.6 files stays `V0_0`.
503                    import_parent_version
504                        .get(&id)
505                        .copied()
506                        .unwrap_or(RustyfiVersion::V0_0)
507                }),
508            other => other,
509        };
510        let cst: LoadedCst = match file_version {
511            RustyfiVersion::V0_0 => {
512                LoadedCst::V0_0(rustyfi_syntax::parse_file(&src).map_err(|source| {
513                    LoadError::Parse {
514                        path: path.clone(),
515                        source,
516                    }
517                })?)
518            }
519            RustyfiVersion::V0_1 => {
520                LoadedCst::V0_1(rustyfi_syntax::parse_file_v1(&src).map_err(|source| {
521                    LoadError::Parse {
522                        path: path.clone(),
523                        source,
524                    }
525                })?)
526            }
527            // `RustyfiVersion` is `#[non_exhaustive]` — a catch-all is
528            // required even though `load`'s `is_implemented()` guard above
529            // already rejects every version this crate doesn't handle
530            // before the loop starts. Unreachable in practice; a clear
531            // message rather than a silent wrong-parse if `is_implemented()`
532            // and this match ever drift apart.
533            other => unreachable!(
534                "RustyfiVersion::is_implemented() admitted {other} but load()'s \
535                 parse dispatch has no arm for it"
536            ),
537        };
538        version_of.insert(id, file_version);
539
540        if id == entry_id {
541            if !cst.is_document() {
542                return Err(LoadError::LibraryAsEntry { path });
543            }
544        } else if cst.is_document() {
545            return Err(LoadError::DocumentAsDependency { path });
546        }
547
548        let dir = path
549            .parent()
550            .map(Path::to_path_buf)
551            .unwrap_or_else(|| PathBuf::from("."));
552
553        // Collect this file's resolved dependency paths (per grammar
554        // generation), then allocate ids for them uniformly below — so the
555        // id/worklist bookkeeping is written exactly once. The `bool` is
556        // whether the header that resolved this path was `@require:` (feeds
557        // `require_targets`, below) as opposed to `@import:`.
558        let mut resolved_deps: Vec<(PathBuf, bool)> = Vec::new();
559        if let Some(headers) = cst.headers_v006() {
560            for header in headers {
561                let is_require = matches!(header, rustyfi_syntax::cst::Header::Require(_));
562                if let Some(resolved) = resolve_legacy_header(header, &dir, &path, opts)? {
563                    resolved_deps.push((resolved, is_require));
564                }
565            }
566        } else if let Some(headers) = cst.headers_v1() {
567            use rustyfi_syntax::cst_v1::HeaderV1;
568            for header in headers {
569                match header {
570                    // dev-0-1-0 semantics under Legacy: an `@`-header on
571                    // a 0.1 file resolves exactly like a 0.0.6 one.
572                    HeaderV1::Legacy(h) => {
573                        let is_require = matches!(h, rustyfi_syntax::cst::Header::Require(_));
574                        if let Some(resolved) = resolve_legacy_header(h, &dir, &path, opts)? {
575                            resolved_deps.push((resolved, is_require));
576                        }
577                    }
578                    // A `use`-family header under Legacy mode: a typed *mode*
579                    // error naming the fix, rather than the parse error a
580                    // grammar-level rejection would give.
581                    HeaderV1::UsePackage { .. } | HeaderV1::UseOf { .. } | HeaderV1::Use { .. } => {
582                        return Err(LoadError::EnvelopeHeaderUnderLegacy {
583                            header: header.display_name(),
584                            from: path.clone(),
585                        });
586                    }
587                }
588            }
589        }
590
591        let mut deps = Vec::new();
592        for (resolved, is_require) in resolved_deps {
593            let dep_canon = canonicalize_via(sources, &resolved)?;
594            // "a `@require:`-resolved target … that RESOLVES UNDER
595            // `lib-rustyfi/dist/packages/`" — the FROZEN 0.0.6 corpus path
596            // specifically, NOT every `@require:` edge. This is the
597            // load-bearing narrowing: a `V0_1` package `@require:`d out of
598            // `dist-v01/packages/` (the 0.1 corpus — reached via
599            // `resolve_require`'s `lib_root/name` fallback, so its
600            // canonical path is NOT under a `dist/packages` segment) must
601            // stay `V0_1`, or it would be mis-parsed with the
602            // 0.0.6 grammar. Only a target physically under a `dist/packages`
603            // directory is the frozen 0.0.6 corpus and eligible for the
604            // provenance-based downgrade (a genuinely-0.1 package dropped
605            // there still wins via its own `Some(V0_1)` sniff, per this rule).
606            let is_corpus_target = is_require && is_dist_packages_target(&dep_canon);
607            // The same narrowing, mirrored for the 0.1 corpus —
608            // `is_require && is_dist_v01_packages_target(&dep_canon)`.
609            // Deliberately checked independently of `is_corpus_target`
610            // (`dist` and `dist-v01` never both match the same path), so a
611            // `@require:` edge lands in at most one of `require_targets`/
612            // `require_v01_targets`.
613            let is_v01_corpus_target = is_require && is_dist_v01_packages_target(&dep_canon);
614            let dep_id = alloc_id(dep_canon, &mut next_id, &mut id_of, &mut path_of);
615            if is_corpus_target {
616                require_targets.insert(dep_id);
617            }
618            if is_v01_corpus_target {
619                require_v01_targets.insert(dep_id);
620            }
621            // An `@import:` edge carries THIS file's version to its target —
622            // see `import_parent_version`'s declaration. Recorded before the
623            // target is pushed, so it is always in place by the time the
624            // target is popped and version-tagged.
625            if !is_require {
626                import_parent_version.entry(dep_id).or_insert(file_version);
627            }
628            deps.push(dep_id);
629            worklist.push(dep_id);
630        }
631
632        adjacency.insert(id, deps);
633        cst_of.insert(id, cst);
634    }
635
636    // SATySFi's own deterministic header-order post-order DFS (from the entry
637    // document), NOT a generic topological sort: the global-merge module model
638    // lets a library reference a module it never `@require:`s itself, so the
639    // order must match the one the sources were written against — a file that
640    // `@require:`s `option` before `fss/fss` must have `Option` in scope for
641    // `fss`'s internals. See `graph::header_order_toposort`.
642    let order = graph::header_order_toposort(&adjacency, entry_id).map_err(|chain_ids| {
643        LoadError::Cycle {
644            chain: graph::chain_to_paths(&chain_ids, &path_of),
645        }
646    })?;
647
648    let files = order
649        .into_iter()
650        .map(|id| LoadedFile {
651            path: path_of[&id].clone(),
652            cst: cst_of
653                .remove(&id)
654                .expect("every graph node id was parsed before toposort"),
655            // Legacy-mode files are all plain local files.
656            origin: FileOrigin::Local,
657            version: version_of
658                .remove(&id)
659                .expect("every graph node id was version-tagged before toposort"),
660        })
661        .collect();
662
663    Ok(LoadedProgram { files })
664}
665
666/// The filesystem's own canonicalization — what the Envelopes backend uses,
667/// which has no [`SourceProvider`] seam (see [`LoadOptions::sources`]).
668pub(crate) fn canonicalize(path: &Path) -> Result<PathBuf, LoadError> {
669    canonicalize_via(&FS_SOURCES, path)
670}
671
672/// [`SourceProvider::canonicalize`] with the loader's error mapping.
673fn canonicalize_via(sources: &dyn SourceProvider, path: &Path) -> Result<PathBuf, LoadError> {
674    sources.canonicalize(path).map_err(|source| LoadError::Io {
675        path: path.to_path_buf(),
676        source,
677    })
678}
679
680/// Whether `path` lives under a `dist/packages/` directory — the frozen 0.0.6
681/// corpus layout, the `@require:`-provenance signal the per-file version
682/// detector uses to downgrade a sniff-`None` corpus dependency to `V0_0`.
683/// Matches ANY two consecutive components `dist` then `packages` anywhere in
684/// the path, so it recognizes both this port's own
685/// `lib-rustyfi/dist/packages/` and a Satyrographos-style
686/// `<root>/dist/packages/` install — but deliberately NOT the 0.1 corpus
687/// `dist-v01/packages/` (`dist-v01` != `dist`), whose `V0_1` packages must
688/// keep the load's `opts.version`.
689fn is_dist_packages_target(path: &Path) -> bool {
690    let comps: Vec<_> = path.components().collect();
691    comps
692        .windows(2)
693        .any(|w| w[0].as_os_str() == "dist" && w[1].as_os_str() == "packages")
694}
695
696/// Whether `path` lives under a `dist-v01/packages/` directory — the 0.1
697/// corpus layout, the MIRROR of
698/// [`is_dist_packages_target`] used by the symmetric per-file version
699/// detector to default a sniff-`None` 0.1-corpus dependency (e.g. a `module
700/// … :> sig …`-headed package like `v01-sealed.satyh`) to `V0_1` under a
701/// `V0_0`-rooted load. Matches ANY two consecutive components `dist-v01`
702/// then `packages` — deliberately NOT `dist` then `packages` (the inverse of
703/// `is_dist_packages_target`'s own care to exclude `dist-v01`), so the two
704/// helpers are mutually exclusive on every real path.
705fn is_dist_v01_packages_target(path: &Path) -> bool {
706    let comps: Vec<_> = path.components().collect();
707    comps
708        .windows(2)
709        .any(|w| w[0].as_os_str() == "dist-v01" && w[1].as_os_str() == "packages")
710}
711
712pub(crate) fn alloc_id(
713    path: PathBuf,
714    next_id: &mut u32,
715    id_of: &mut HashMap<PathBuf, u32>,
716    path_of: &mut HashMap<u32, PathBuf>,
717) -> u32 {
718    if let Some(&id) = id_of.get(&path) {
719        return id;
720    }
721    let id = *next_id;
722    *next_id += 1;
723    id_of.insert(path.clone(), id);
724    path_of.insert(id, path);
725    id
726}