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