rustyfi-loader 0.1.2

Multi-file loading layer (@require:/@import: resolution and dependency ordering) for SATySFi documents
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
//! Multi-file loading layer for SATySFi documents: resolves `@require:` /
//! `@import:` headers to files on disk, recursively parses the whole
//! dependency graph, and returns it in dependency-first (topological) load
//! order.
//!
//! Transcribed from v0.0.6's `src/frontend/main.ml` (lines ~95-140):
//!
//! - `@import: name` resolves relative to the directory of the file
//!   *containing* the header (not the entry document's directory).
//! - `@require: name` resolves against the package/library root
//!   (`LoadOptions::lib_root`).
//! - Candidate extensions, tried in order: `.satyh`, then `.satyg` (the
//!   mode-specific `.satyh-<mode>` extensions from `main.ml` are out of
//!   scope here).
//! - The same file reached through two different headers is one graph node
//!   (deduplicated by canonical path).
//! - Every dependency must be a library file (`body: None`); the entry must
//!   be a document (`body: Some(..)`).
//! - A cycle in the dependency graph is an error naming the files involved.

mod error;
mod graph;
pub mod v006;
mod v01x;

use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};

pub use error::LoadError;
pub use rustyfi_syntax::RustyfiVersion;

/// Header resolution on its own, without loading anything.
///
/// [`load`] is the whole pipeline — resolve, read, parse, order — and a
/// language server answering "where does this `@require:` point?" wants only
/// the first step, against a buffer that may not compile and dependencies it
/// has no reason to read. These are the very functions [`load`] itself calls,
/// exported rather than reimplemented so the editor and the compiler can never
/// disagree about which file a header names.
pub use v006::resolve::{resolve_import, resolve_require};

/// Where the Legacy loader gets source text from.
///
/// Three operations, which is the entire filesystem surface of the
/// `@require:`/`@import:` path: read a file, probe a resolution candidate,
/// and turn a path into the identity its dependency-graph node is keyed by.
///
/// The seam exists because `wasm32-unknown-unknown` has no filesystem at all,
/// and a SATySFi document that cannot `@require:` anything is barely a
/// document — the browser playground serves the bundled 0.0.6 corpus straight
/// out of the binary through this trait. It is also the honest way to write a
/// loader test that needs a dependency graph but not a temp directory.
///
/// `LoadOptions::sources` is `None` by default, which means [`FsSources`] —
/// the real filesystem, byte-for-byte the behaviour that predates this trait.
pub trait SourceProvider {
    /// The file's text. `Err` is surfaced as [`LoadError::Io`].
    fn read(&self, path: &Path) -> std::io::Result<String>;

    /// Whether `path` names a readable file. Drives candidate selection in
    /// `@require:`/`@import:` resolution, so a `false` here is an ordinary
    /// "try the next candidate", not an error.
    fn is_file(&self, path: &Path) -> bool;

    /// `path`'s canonical form — the key two headers naming the same file
    /// must agree on, or it would be loaded (and its bindings emitted) twice.
    ///
    /// A provider whose paths are already unique and absolute by construction
    /// may return `path` unchanged; the loader only requires that the same
    /// file always maps to the same key. Note that the per-file version
    /// detector reads the RESULT, so a provider serving the frozen 0.0.6
    /// corpus must keep the `dist/packages` components in it.
    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf>;
}

/// The real filesystem — [`SourceProvider`]'s default.
#[derive(Debug, Default, Clone, Copy)]
pub struct FsSources;

impl SourceProvider for FsSources {
    fn read(&self, path: &Path) -> std::io::Result<String> {
        std::fs::read_to_string(path)
    }

    fn is_file(&self, path: &Path) -> bool {
        path.is_file()
    }

    fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
        std::fs::canonicalize(path)
    }
}

/// The `None` case of [`LoadOptions::sources`], as a borrowable value.
static FS_SOURCES: FsSources = FsSources;

/// How multi-file dependencies are declared and resolved — Axis B,
/// orthogonal to [`RustyfiVersion`] (Axis A, the grammar generation),
/// except that the one combination with no upstream analogue — `V0_0` +
/// `Envelopes` — is rejected by [`load`] up front.
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum LoadMode {
    /// `@require:`/`@import:` header search against [`LoadOptions::lib_root`],
    /// and `dev-0-1-0`'s *only* mode too (its headers are byte-identical to
    /// 0.0.6's, minus `@stage:`). The [`Default`].
    #[default]
    Legacy,
    /// `use package` / `use … of` headers resolved the `saphe-split` way
    /// (upstream ≈ "0.1.0-alpha.1"): local files by relative path, packages
    /// from a pre-solved envelope graph. Requires `version == V0_1`.
    Envelopes {
        /// Path to a pre-resolved `rustyfi-deps.yaml` (upstream's mandatory
        /// `--deps` flag on `rustyfi build`, `saphe-split:bin/rustyfi.ml`,
        /// `flag_deps`). `None` = no package dependencies available: any `use
        /// package` header is a [`LoadError::PackageDependencyUnresolved`].
        /// `Some(path)` is decoded, its envelopes are read + topo-
        /// sorted, and each `use package M` header is validated against the
        /// config's `used_as` aliases; the envelope source files are prepended
        /// to the loaded program (dependency-first, before any local file).
        deps: Option<PathBuf>,
    },
}

/// Options controlling header resolution.
///
/// Implements [`Default`] so a call site names only the fields it cares
/// about: `LoadOptions { lib_root: ..., ..Default::default() }`. Adding a
/// field here must keep that spelling working.
#[derive(Default)]
pub struct LoadOptions {
    /// Root used to resolve `@require: name` (searched as
    /// `<lib_root>/dist/packages/name.{satyh,satyg}`, then
    /// `<lib_root>/name.{satyh,satyg}`, then the nested
    /// `<lib_root>/dist/packages/name/name.{satyh,satyg}` layout the
    /// Satyrographos installer produces — see `resolve::resolve_require`).
    /// `None` means there is no package root configured, so any `@require:`
    /// header fails to resolve.
    pub lib_root: Option<PathBuf>,
    /// The REST of the library-root search path, searched in order after
    /// [`Self::lib_root`] — a project-local root does not hide the wider ones,
    /// so a document can `@require:` one package a project installed for
    /// itself and the next from the development tree or the system install.
    /// Empty by default: one named root and nothing behind it.
    pub fallback_roots: Vec<PathBuf>,
    /// The SATySFi language version the input is expected to conform to.
    /// Defaults to [`RustyfiVersion::DEFAULT`] (0.0.6). [`load`] rejects any
    /// version for which [`RustyfiVersion::is_implemented`] is false before
    /// doing any work.
    pub version: RustyfiVersion,
    /// How dependencies are resolved. Defaults to [`LoadMode::Legacy`].
    /// `lib_root`/`fallback_roots` are ignored by the Envelopes backend,
    /// which resolves `use … of` relative paths and a `rustyfi-deps.yaml`
    /// envelope graph instead.
    pub mode: LoadMode,
    /// Where source text comes from. `None` (the default) is the real
    /// filesystem, [`FsSources`].
    ///
    /// Honoured by [`LoadMode::Legacy`] only. The Envelopes backend reads
    /// deps/envelope configs through `std::fs` directly and would silently
    /// ignore this, so [`load`] refuses that combination up front rather than
    /// half-applying it.
    pub sources: Option<Box<dyn SourceProvider>>,
}

impl LoadOptions {
    /// The library-root search path in order: [`Self::lib_root`], then
    /// [`Self::fallback_roots`]. Empty when no root is configured at all, in
    /// which case every `@require:` fails to resolve.
    fn roots(&self) -> Vec<&Path> {
        self.lib_root
            .as_deref()
            .into_iter()
            .chain(self.fallback_roots.iter().map(|p| p.as_path()))
            .collect()
    }

    /// [`Self::sources`], defaulting to the real filesystem.
    fn sources(&self) -> &dyn SourceProvider {
        match &self.sources {
            Some(provider) => provider.as_ref(),
            None => &FS_SOURCES,
        }
    }
}

/// A parsed file's CST, tagged by which grammar generation produced it.
/// `load()` picks the variant per file, from that file's own
/// [`LoadedFile::version`] — which a cross-version Legacy load can vary
/// WITHIN one program (see that field). The enum exists so `LoadedFile` has a
/// single field type rather than forcing every consumer of `LoadedProgram` to
/// be generic over the CST type.
#[derive(Debug)]
pub enum LoadedCst {
    V0_0(rustyfi_syntax::cst::File),
    V0_1(rustyfi_syntax::cst_v1::FileV1),
}

impl LoadedCst {
    /// Whether this file is a document (has a body) rather than a library.
    /// Used by `load()`'s entry/dependency-shape validation
    /// (`DocumentAsDependency`/`LibraryAsEntry`) uniformly across both
    /// generations, so that validation logic itself needs no `match` at its
    /// call sites.
    pub fn is_document(&self) -> bool {
        match self {
            Self::V0_0(f) => f.body.is_some(),
            Self::V0_1(f) => matches!(f, rustyfi_syntax::cst_v1::FileV1::Document { .. }),
        }
    }

    /// This `V0_0` file's `@require:`/`@import:`/`@stage:` headers, or
    /// `None` for a `V0_1` file. Each generation's header list has a distinct
    /// element type (`V0_1` carries `HeaderV1`, the union grammar), so the
    /// shared facade offers one total accessor per generation rather than one
    /// `Header`-typed accessor for both.
    fn headers_v006(&self) -> Option<&[rustyfi_syntax::cst::Header]> {
        match self {
            Self::V0_0(f) => Some(&f.headers),
            Self::V0_1(_) => None,
        }
    }

    /// This `V0_1` file's headers (the `HeaderV1` union — Legacy `@`-headers
    /// plus the three `use` forms), or `None` for a `V0_0` file.
    fn headers_v1(&self) -> Option<&[rustyfi_syntax::cst_v1::HeaderV1]> {
        match self {
            Self::V0_0(_) => None,
            Self::V0_1(f) => Some(match f {
                rustyfi_syntax::cst_v1::FileV1::Document { headers, .. }
                | rustyfi_syntax::cst_v1::FileV1::Library { headers, .. } => headers,
            }),
        }
    }
}

/// Where a loaded file came from — metadata for diagnostics and for a
/// future `used_as` → module binding. Nothing in `rustyfi-lang` reads
/// it yet.
///
/// Only two variants: a Legacy-mode file and an Envelopes-mode *local*
/// (`use … of`) file / the entry document are both just "a plain local file"
/// ([`FileOrigin::Local`], the [`Default`]); a distinct `Legacy` variant
/// would be a distinction without a consumer (revisit if a future need
/// requires the split). [`FileOrigin::Envelope`] tags a source file that
/// came out of a deps-config envelope (`rustyfi-envelope.yaml`).
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum FileOrigin {
    /// A Legacy-mode file, an Envelopes-mode local (`use … of`) dependency,
    /// or the entry document. The [`Default`].
    #[default]
    Local,
    /// A source file of a deps-config envelope: `envelope` is the envelope's
    /// (deps-config) name, `module` the declared module name of this file.
    Envelope { envelope: String, module: String },
}

/// One parsed file in a loaded program.
#[derive(Debug)]
pub struct LoadedFile {
    /// Canonicalized path to the file on disk.
    pub path: PathBuf,
    /// The file's parsed concrete syntax tree, tagged by grammar generation;
    /// every consumer matches on the variant. See [`LoadedCst`].
    pub cst: LoadedCst,
    /// Where this file came from ([`FileOrigin::Local`] for Legacy files and
    /// Envelopes-mode locals; [`FileOrigin::Envelope`] for deps-config
    /// envelope sources). Additive metadata — no consumer reads it yet.
    pub origin: FileOrigin,
    /// The `RustyfiVersion` grammar this SPECIFIC file was parsed under —
    /// always matches `cst`'s variant (`V0_0` <-> `LoadedCst::V0_0`, `V0_1`
    /// <-> `LoadedCst::V0_1`). Cross-version import: under `LoadMode::
    /// Envelopes` and under a `LoadOptions { version: V0_0, .. }` Legacy
    /// load, every file in one `LoadedProgram` shares one version (the
    /// load's `opts.version`). Only a `LoadOptions { version: V0_1, mode:
    /// Legacy, .. }` load can produce a MIXED-version `files` list:
    /// `load_legacy`'s worklist (see its doc comment) per-file-detects a
    /// `V0_0` dependency via the per-file version-detection rule below, so a
    /// `V0_1` document can `@require:` a frozen `V0_0` package.
    pub version: RustyfiVersion,
}

/// A fully loaded, dependency-resolved program.
#[derive(Debug)]
pub struct LoadedProgram {
    /// Dependency-first order: every file appears after all the files it
    /// depends on. Under Legacy mode that is the `@require:`/`@import:`
    /// order; under Envelopes mode the ordering contract is: all deps-config
    /// envelope sources first (dependency-first among themselves, each
    /// envelope's modules closed-sorted), then the local `use … of` files
    /// (dependency-first), then the entry document last.
    pub files: Vec<LoadedFile>,
}

/// Load `entry` (a `.saty` document) and its full transitive dependency
/// graph, dispatching on [`LoadOptions::mode`] (Axis B). [`LoadMode::Legacy`]
/// resolves `@require:`/`@import:` headers (`load_legacy`);
/// [`LoadMode::Envelopes`] resolves `use package`/`use … of` headers
/// (`v01x::open_doc`).
pub fn load(entry: &Path, opts: &LoadOptions) -> Result<LoadedProgram, LoadError> {
    if !opts.version.is_implemented() {
        return Err(LoadError::UnsupportedVersion {
            requested: opts.version,
            supported: RustyfiVersion::supported().to_vec(),
        });
    }

    match &opts.mode {
        LoadMode::Legacy => load_legacy(entry, opts),
        LoadMode::Envelopes { deps } => {
            // The Envelopes backend reads its deps/envelope configs through
            // `std::fs` directly, so a caller-supplied provider would be
            // half-honoured at best. Refuse loudly rather than serve a
            // dependency graph half out of memory and half off the disk.
            if opts.sources.is_some() {
                return Err(LoadError::SourceProviderUnderEnvelopes);
            }
            // The one combination with no upstream analogue: 0.0.6 has no
            // `use` headers to resolve against an envelope graph at all.
            // Reject before touching the filesystem,
            // like the version guard above. `!matches!(.., V0_1)` rather than
            // `== V0_0`: `RustyfiVersion` is `#[non_exhaustive]`, so any
            // hypothetical future third variant defaults to *rejected* under
            // Envelopes until someone decides otherwise.
            if !matches!(opts.version, RustyfiVersion::V0_1) {
                return Err(LoadError::InvalidModeVersion {
                    version: opts.version,
                });
            }
            v01x::open_doc::load(entry, deps.as_deref(), opts)
        }
    }
}

/// Resolve one Legacy (`@require:`/`@import:`/`@stage:`) header to a file
/// path, or `None` for `@stage:` (which drives no dependency edge). Shared by
/// the `V0_0` and `V0_1`-Legacy header loops in [`load_legacy`].
fn resolve_legacy_header(
    header: &rustyfi_syntax::cst::Header,
    dir: &Path,
    from: &Path,
    opts: &LoadOptions,
) -> Result<Option<PathBuf>, LoadError> {
    Ok(Some(match header {
        rustyfi_syntax::cst::Header::Import(tok) => {
            let sources = opts.sources();
            v006::resolve::resolve_import(sources, dir, &tok.content).map_err(|searched| {
                LoadError::UnresolvedImport {
                    name: tok.content.clone(),
                    from: from.to_path_buf(),
                    searched,
                }
            })?
        }
        rustyfi_syntax::cst::Header::Require(tok) => {
            let sources = opts.sources();
            let roots = opts.roots();
            v006::resolve::resolve_require(sources, &roots, &tok.content, opts.version).map_err(
                |searched| LoadError::UnresolvedRequire {
                    name: tok.content.clone(),
                    searched,
                },
            )?
        }
        // `@stage: persistent` / `@stage: 0` / `@stage: 1` — a property of the
        // file's BINDINGS, read by the compiler (`declared_stage`), not by
        // header resolution; it drives no dependency edge.
        rustyfi_syntax::cst::Header::Stage(_) => return Ok(None),
    }))
}

/// The `LoadMode::Legacy` backend: `@require:`/`@import:` header resolution
/// against `lib_root`, recursive parse, and dependency-first ordering. A
/// shared worklist/validation shell around the `v006::` calls, with the
/// header loop dispatching per grammar generation so a `V0_1`-under-Legacy
/// file with a `use` header gets a typed
/// [`LoadError::EnvelopeHeaderUnderLegacy`] rather than a parse error.
fn load_legacy(entry: &Path, opts: &LoadOptions) -> Result<LoadedProgram, LoadError> {
    let sources = opts.sources();
    let entry_canon = canonicalize_via(sources, entry)?;

    let mut next_id: u32 = 0;
    let mut id_of: HashMap<PathBuf, u32> = HashMap::new();
    let mut path_of: HashMap<u32, PathBuf> = HashMap::new();
    let mut cst_of: HashMap<u32, LoadedCst> = HashMap::new();
    // The per-file version each graph node was actually parsed
    // under — see `LoadedFile::version`'s doc comment. Populated in
    // lockstep with `cst_of` below; a `V0_0` load inserts `V0_0` for every
    // node.
    let mut version_of: HashMap<u32, RustyfiVersion> = HashMap::new();
    // Node ids reached via at least one `@require:` header edge (as
    // opposed to only `@import:` edges) — the "resolves under `lib_root`'s
    // package tree" half of the per-file detection rule. Populated as
    // dependency edges are discovered, below; irrelevant (never consulted)
    // for a `V0_0` load.
    let mut require_targets: HashSet<u32> = HashSet::new();
    // The mirror: node ids reached via at least one `@require:` edge
    // that resolved PHYSICALLY under `dist-v01/packages/` — the "resolves
    // under the 0.1 corpus" half of the mirrored per-file detection rule.
    // Populated in lockstep with `require_targets`, below; irrelevant
    // (never consulted) for a `V0_1` load (that load uses
    // `require_targets`/`is_dist_packages_target` instead).
    let mut require_v01_targets: HashSet<u32> = HashSet::new();
    // The version of the file that first reached this id over an `@import:`
    // edge. An `@import:` is a SAME-PACKAGE, path-relative include — it can
    // never name another package, let alone another generation — so an
    // `@import:`ed file belongs to whatever generation its importer was
    // written in, and inherits its version. Only `@require:` crosses a
    // package (and therefore possibly a generation) boundary; that edge keeps
    // the physical-provenance rule (`require_targets` /
    // `require_v01_targets`) below, which is checked FIRST.
    //
    // Without this, every intra-package `@import:` of a real published 0.0.6
    // package (`azmath/azmath.satyh`'s `@import: parens`, `base/bool.satyg`'s
    // `@import: ord`, `easytable`, `arrows`, `derive`, `lipsum`, `railway`,
    // `enumitem`, `fss`, …) fell through to `opts.version` under a `V0_1`
    // load and was parsed with the 0.1 grammar — a parse error on the
    // package's own `module M : sig` head, which is exactly the shape a
    // 0.0.6 package is written in. Only files reached by `@require:` were
    // ever downgraded, and multi-file packages are the norm, not the
    // exception, in the published corpus.
    //
    // First writer wins (`or_insert`): a file `@import:`ed by two importers
    // of DIFFERENT generations would be ambiguous anyway, and the worklist is
    // deterministic. The entry's own `@import:`ed siblings still take the
    // entry's version, since the entry is processed first.
    let mut import_parent_version: HashMap<u32, RustyfiVersion> = HashMap::new();
    let mut adjacency: HashMap<u32, Vec<u32>> = HashMap::new();
    let mut processed: HashSet<u32> = HashSet::new();

    let entry_id = alloc_id(entry_canon, &mut next_id, &mut id_of, &mut path_of);

    let mut worklist = vec![entry_id];
    while let Some(id) = worklist.pop() {
        if processed.contains(&id) {
            continue;
        }
        processed.insert(id);

        let path = path_of[&id].clone();
        let src = sources.read(&path).map_err(|source| LoadError::Io {
            path: path.clone(),
            source,
        })?;
        // Under a `V0_1` load, every NON-entry file gets its own
        // per-file version — `sniff_version` first (a `use`/`val`-shaped
        // file sniffs `Some(V0_1)` even inside the frozen corpus), else
        // `V0_0` if this id was reached via at least one `@require:` edge
        // (the corpus IS `dist/packages/`), else `opts.version`
        // (`@import:`-relative siblings of the entry, and the entry itself,
        // stay `V0_1`). A `V0_0` load is untouched: `file_version` is
        // always `opts.version` there, exactly the old unconditional match.
        let file_version = match opts.version {
            RustyfiVersion::V0_1 if id != entry_id => rustyfi_syntax::sniff_version(&src)
                .unwrap_or(
                    // A non-sniffable `@require:` target defaults to V0_0
                    // ONLY when it is physically under the frozen 0.0.6 corpus
                    // `dist/packages/`. This must EXCLUDE `dist-v01/
                    // packages/` — those are V0_1 packages and the substring
                    // `/dist/packages/` does not match `/dist-v01/packages/`.
                    // Everything else (dist-v01 requires, @import: siblings)
                    // stays `opts.version` = V0_1.
                    if require_targets.contains(&id)
                        && path.to_string_lossy().contains("/dist/packages/")
                    {
                        RustyfiVersion::V0_0
                    } else {
                        // …else inherit the version of whoever `@import:`ed
                        // it (a same-package sibling of a spliced 0.0.6
                        // package is itself 0.0.6), falling back to the
                        // load's own version for the entry's siblings and
                        // anything reached no other way. See
                        // `import_parent_version`'s declaration.
                        import_parent_version
                            .get(&id)
                            .copied()
                            .unwrap_or(RustyfiVersion::V0_1)
                    },
                ),
            // The mirror: a `V0_0`-rooted load's NON-entry file defaults
            // to `opts.version` (`V0_0`) unless `sniff_version` returns
            // `Some(V0_1)`, in which case it MUST default to `V0_1` when
            // this id was reached via at least one `@require:` edge that
            // resolved physically under the 0.1 corpus `dist-v01/packages/`
            // (the mirror of `require_targets` + `is_dist_packages_target`
            // above) — a `module … :> sig …`-headed
            // 0.1 package (e.g. `v01-sealed.satyh`) sniffs `None` just like a
            // 0.0.6 `module`-headed corpus file does (`version.rs`'s own doc
            // comment: a bare `module` head is deliberately no signal), so
            // this provenance fallback is what actually resolves it. It is a
            // PURE WIDENING: nothing resolves `V0_1` here unless it is BOTH
            // under `dist-v01/packages/` AND reached via `@require:`, and
            // `require_v01_targets` is empty for a load that never resolves a
            // `dist-v01/packages/` target, so the `unwrap_or` falls through
            // to `V0_0`.
            RustyfiVersion::V0_0 if id != entry_id => rustyfi_syntax::sniff_version(&src)
                .unwrap_or(if require_v01_targets.contains(&id) {
                    RustyfiVersion::V0_1
                } else {
                    // The mirror of the `V0_1` arm's `@import:` inheritance:
                    // a 0.1 package spliced into a 0.0.6-rooted load may
                    // `@import:` its own siblings too, and they are 0.1.
                    // Everything reached only from 0.0.6 files stays `V0_0`.
                    import_parent_version
                        .get(&id)
                        .copied()
                        .unwrap_or(RustyfiVersion::V0_0)
                }),
            other => other,
        };
        let cst: LoadedCst = match file_version {
            RustyfiVersion::V0_0 => {
                LoadedCst::V0_0(rustyfi_syntax::parse_file(&src).map_err(|source| {
                    LoadError::Parse {
                        path: path.clone(),
                        source,
                    }
                })?)
            }
            RustyfiVersion::V0_1 => {
                LoadedCst::V0_1(rustyfi_syntax::parse_file_v1(&src).map_err(|source| {
                    LoadError::Parse {
                        path: path.clone(),
                        source,
                    }
                })?)
            }
            // `RustyfiVersion` is `#[non_exhaustive]` — a catch-all is
            // required even though `load`'s `is_implemented()` guard above
            // already rejects every version this crate doesn't handle
            // before the loop starts. Unreachable in practice; a clear
            // message rather than a silent wrong-parse if `is_implemented()`
            // and this match ever drift apart.
            other => unreachable!(
                "RustyfiVersion::is_implemented() admitted {other} but load()'s \
                 parse dispatch has no arm for it"
            ),
        };
        version_of.insert(id, file_version);

        if id == entry_id {
            if !cst.is_document() {
                return Err(LoadError::LibraryAsEntry { path });
            }
        } else if cst.is_document() {
            return Err(LoadError::DocumentAsDependency { path });
        }

        let dir = path
            .parent()
            .map(Path::to_path_buf)
            .unwrap_or_else(|| PathBuf::from("."));

        // Collect this file's resolved dependency paths (per grammar
        // generation), then allocate ids for them uniformly below — so the
        // id/worklist bookkeeping is written exactly once. The `bool` is
        // whether the header that resolved this path was `@require:` (feeds
        // `require_targets`, below) as opposed to `@import:`.
        let mut resolved_deps: Vec<(PathBuf, bool)> = Vec::new();
        if let Some(headers) = cst.headers_v006() {
            for header in headers {
                let is_require = matches!(header, rustyfi_syntax::cst::Header::Require(_));
                if let Some(resolved) = resolve_legacy_header(header, &dir, &path, opts)? {
                    resolved_deps.push((resolved, is_require));
                }
            }
        } else if let Some(headers) = cst.headers_v1() {
            use rustyfi_syntax::cst_v1::HeaderV1;
            for header in headers {
                match header {
                    // dev-0-1-0 semantics under Legacy: an `@`-header on
                    // a 0.1 file resolves exactly like a 0.0.6 one.
                    HeaderV1::Legacy(h) => {
                        let is_require = matches!(h, rustyfi_syntax::cst::Header::Require(_));
                        if let Some(resolved) = resolve_legacy_header(h, &dir, &path, opts)? {
                            resolved_deps.push((resolved, is_require));
                        }
                    }
                    // A `use`-family header under Legacy mode: a typed *mode*
                    // error naming the fix, rather than the parse error a
                    // grammar-level rejection would give.
                    HeaderV1::UsePackage { .. } | HeaderV1::UseOf { .. } | HeaderV1::Use { .. } => {
                        return Err(LoadError::EnvelopeHeaderUnderLegacy {
                            header: header.display_name(),
                            from: path.clone(),
                        });
                    }
                }
            }
        }

        let mut deps = Vec::new();
        for (resolved, is_require) in resolved_deps {
            let dep_canon = canonicalize_via(sources, &resolved)?;
            // "a `@require:`-resolved target … that RESOLVES UNDER
            // `lib-rustyfi/dist/packages/`" — the FROZEN 0.0.6 corpus path
            // specifically, NOT every `@require:` edge. This is the
            // load-bearing narrowing: a `V0_1` package `@require:`d out of
            // `dist-v01/packages/` (the 0.1 corpus — reached via
            // `resolve_require`'s `lib_root/name` fallback, so its
            // canonical path is NOT under a `dist/packages` segment) must
            // stay `V0_1`, or it would be mis-parsed with the
            // 0.0.6 grammar. Only a target physically under a `dist/packages`
            // directory is the frozen 0.0.6 corpus and eligible for the
            // provenance-based downgrade (a genuinely-0.1 package dropped
            // there still wins via its own `Some(V0_1)` sniff, per this rule).
            let is_corpus_target = is_require && is_dist_packages_target(&dep_canon);
            // The same narrowing, mirrored for the 0.1 corpus —
            // `is_require && is_dist_v01_packages_target(&dep_canon)`.
            // Deliberately checked independently of `is_corpus_target`
            // (`dist` and `dist-v01` never both match the same path), so a
            // `@require:` edge lands in at most one of `require_targets`/
            // `require_v01_targets`.
            let is_v01_corpus_target = is_require && is_dist_v01_packages_target(&dep_canon);
            let dep_id = alloc_id(dep_canon, &mut next_id, &mut id_of, &mut path_of);
            if is_corpus_target {
                require_targets.insert(dep_id);
            }
            if is_v01_corpus_target {
                require_v01_targets.insert(dep_id);
            }
            // An `@import:` edge carries THIS file's version to its target —
            // see `import_parent_version`'s declaration. Recorded before the
            // target is pushed, so it is always in place by the time the
            // target is popped and version-tagged.
            if !is_require {
                import_parent_version.entry(dep_id).or_insert(file_version);
            }
            deps.push(dep_id);
            worklist.push(dep_id);
        }

        adjacency.insert(id, deps);
        cst_of.insert(id, cst);
    }

    // SATySFi's own deterministic header-order post-order DFS (from the entry
    // document), NOT a generic topological sort: the global-merge module model
    // lets a library reference a module it never `@require:`s itself, so the
    // order must match the one the sources were written against — a file that
    // `@require:`s `option` before `fss/fss` must have `Option` in scope for
    // `fss`'s internals. See `graph::header_order_toposort`.
    let order = graph::header_order_toposort(&adjacency, entry_id).map_err(|chain_ids| {
        LoadError::Cycle {
            chain: graph::chain_to_paths(&chain_ids, &path_of),
        }
    })?;

    let files = order
        .into_iter()
        .map(|id| LoadedFile {
            path: path_of[&id].clone(),
            cst: cst_of
                .remove(&id)
                .expect("every graph node id was parsed before toposort"),
            // Legacy-mode files are all plain local files.
            origin: FileOrigin::Local,
            version: version_of
                .remove(&id)
                .expect("every graph node id was version-tagged before toposort"),
        })
        .collect();

    Ok(LoadedProgram { files })
}

/// The filesystem's own canonicalization — what the Envelopes backend uses,
/// which has no [`SourceProvider`] seam (see [`LoadOptions::sources`]).
pub(crate) fn canonicalize(path: &Path) -> Result<PathBuf, LoadError> {
    canonicalize_via(&FS_SOURCES, path)
}

/// [`SourceProvider::canonicalize`] with the loader's error mapping.
fn canonicalize_via(sources: &dyn SourceProvider, path: &Path) -> Result<PathBuf, LoadError> {
    sources.canonicalize(path).map_err(|source| LoadError::Io {
        path: path.to_path_buf(),
        source,
    })
}

/// Whether `path` lives under a `dist/packages/` directory — the frozen 0.0.6
/// corpus layout, the `@require:`-provenance signal the per-file version
/// detector uses to downgrade a sniff-`None` corpus dependency to `V0_0`.
/// Matches ANY two consecutive components `dist` then `packages` anywhere in
/// the path, so it recognizes both this port's own
/// `lib-rustyfi/dist/packages/` and a Satyrographos-style
/// `<root>/dist/packages/` install — but deliberately NOT the 0.1 corpus
/// `dist-v01/packages/` (`dist-v01` != `dist`), whose `V0_1` packages must
/// keep the load's `opts.version`.
fn is_dist_packages_target(path: &Path) -> bool {
    let comps: Vec<_> = path.components().collect();
    comps
        .windows(2)
        .any(|w| w[0].as_os_str() == "dist" && w[1].as_os_str() == "packages")
}

/// Whether `path` lives under a `dist-v01/packages/` directory — the 0.1
/// corpus layout, the MIRROR of
/// [`is_dist_packages_target`] used by the symmetric per-file version
/// detector to default a sniff-`None` 0.1-corpus dependency (e.g. a `module
/// … :> sig …`-headed package like `v01-sealed.satyh`) to `V0_1` under a
/// `V0_0`-rooted load. Matches ANY two consecutive components `dist-v01`
/// then `packages` — deliberately NOT `dist` then `packages` (the inverse of
/// `is_dist_packages_target`'s own care to exclude `dist-v01`), so the two
/// helpers are mutually exclusive on every real path.
fn is_dist_v01_packages_target(path: &Path) -> bool {
    let comps: Vec<_> = path.components().collect();
    comps
        .windows(2)
        .any(|w| w[0].as_os_str() == "dist-v01" && w[1].as_os_str() == "packages")
}

pub(crate) fn alloc_id(
    path: PathBuf,
    next_id: &mut u32,
    id_of: &mut HashMap<PathBuf, u32>,
    path_of: &mut HashMap<u32, PathBuf>,
) -> u32 {
    if let Some(&id) = id_of.get(&path) {
        return id;
    }
    let id = *next_id;
    *next_id += 1;
    id_of.insert(path.clone(), id);
    path_of.insert(id, path);
    id
}