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}