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}