Skip to main content

rustyfi_loader/v006/
resolve.rs

1//! `@require:`/`@import:` name resolution.
2//!
3//! Transcribed from v0.0.6's `src/frontend/main.ml` (lines ~95-140): a header
4//! name is turned into a list of candidate file paths, tried in order; the
5//! first candidate that exists on disk wins. We do NOT implement the
6//! mode-specific `.satyh-<mode>` extension SATySFi also tries — out of scope
7//! here, matching the task's transcription instructions.
8
9use crate::SourceProvider;
10use rustyfi_syntax::RustyfiVersion;
11use std::path::{Path, PathBuf};
12
13/// Extensions tried, in preference order, when a header name has none of its
14/// own. `.satyh` (the "normal" library extension) beats `.satyg` (the
15/// "governed"/restricted-grammar library extension) — same order as
16/// `main.ml`'s candidate list.
17pub(crate) const CANDIDATE_EXTS: [&str; 2] = [".satyh", ".satyg"];
18
19fn has_candidate_ext(name: &str) -> bool {
20    CANDIDATE_EXTS.iter().any(|ext| name.ends_with(ext))
21}
22
23/// All paths `base/name` could resolve to, without checking existence.
24fn candidates_in(base: &Path, name: &str) -> Vec<PathBuf> {
25    if has_candidate_ext(name) {
26        vec![base.join(name)]
27    } else {
28        CANDIDATE_EXTS
29            .iter()
30            .map(|ext| base.join(format!("{name}{ext}")))
31            .collect()
32    }
33}
34
35/// Resolve `@import: name` relative to `dir`, the directory of the file that
36/// contains the header (NOT the entry document's directory — see main.ml,
37/// where imports are resolved relative to the current file being processed,
38/// which matters once library files import each other from different
39/// subdirectories).
40///
41/// Returns the first candidate that exists, or `Err` with the full list of
42/// paths tried (for `UnresolvedImport::searched`).
43pub fn resolve_import(
44    sources: &dyn SourceProvider,
45    dir: &Path,
46    name: &str,
47) -> Result<PathBuf, Vec<PathBuf>> {
48    let candidates = candidates_in(dir, name);
49    for candidate in &candidates {
50        if sources.is_file(candidate) {
51            return Ok(candidate.clone());
52        }
53    }
54    Err(candidates)
55}
56
57/// Resolve `@require: name` against the package/library root.
58///
59/// v0.0.6's `Config.resolve_package` searches a configurable list of library
60/// directories; we approximate that with five fixed candidates under
61/// `lib_root`, in order:
62///   1. `<lib_root>/dist/packages/<name>` (the standard SATySFi package
63///      layout used by `rustyfi-dist`/opam installs, and this port's own
64///      no-manifest flat-copy fallback).
65///   2. `<lib_root>/<name>` (a plain fallback, for a `lib_root` that already
66///      points directly at a package tree, e.g. in tests).
67///   3. `<lib_root>/dist/packages/<name>/<name>` (the *nested* per-library
68///      layout real Satyrographos produces and this port's manifest-driven
69///      installer materialises).
70///   4. `<lib_root>/dist-v01/packages/<name>` (the 0.1
71///      corpus, mirroring candidate 1). This is what lets a `V0_0`-rooted
72///      load's `@require:` reach a 0.1 package under
73///      `lib-rustyfi/dist-v01/packages/` from the SAME `lib_root` a 0.0.6
74///      document also `@require:`s the 0.0.6 corpus from. Ordered LAST for a
75///      0.0.6 load, so it only ever adds resolutions and never changes which
76///      candidate wins for a name candidates 1-3 already resolve.
77///   5. `<lib_root>/dist-v01/packages/<name>/<name>` — candidate 3's analogue
78///      for the 0.1 corpus. `install --lang 0.1` of a package whose manifest
79///      declares `(packageDir ...)`, which is what real Satyrographos packages
80///      declare, materialises exactly this nested layout; without this
81///      candidate such a package installs successfully and is then
82///      unreachable from any `@require:`.
83///
84/// If `lib_root` is `None`, there is nowhere to search: returns `Err(vec![])`
85/// immediately (surfaced by `UnresolvedRequire` as "no candidates").
86pub fn resolve_require(
87    sources: &dyn SourceProvider,
88    roots: &[&Path],
89    name: &str,
90    version: RustyfiVersion,
91) -> Result<PathBuf, Vec<PathBuf>> {
92    // Every root in turn, nearest first: a project-local root that carries one
93    // package must not hide the development tree or the system install that
94    // carry the rest.
95    let mut searched = Vec::new();
96    for root in roots {
97        match resolve_require_in(sources, root, name, version) {
98            Ok(found) => return Ok(found),
99            Err(tried) => searched.extend(tried),
100        }
101    }
102    Err(searched)
103}
104
105fn resolve_require_in(
106    sources: &dyn SourceProvider,
107    root: &Path,
108    name: &str,
109    version: RustyfiVersion,
110) -> Result<PathBuf, Vec<PathBuf>> {
111    let dist_packages = root.join("dist").join("packages");
112    let dist_v01_packages = root.join("dist-v01").join("packages");
113    // Search the load's OWN generation first. Both corpora are bundled side by
114    // side and many names exist in both (`itemize`, `list`, `code`, `deco`, …)
115    // with genuinely different APIs — 0.1's `itemize` has `+listing`'s
116    // `?(break : bool)` label and 0.1's `list` has `fold`, neither of which
117    // their 0.0.6 counterparts ever had. Resolving a 0.1 document's
118    // `@require:` to the 0.0.6 package therefore fails at the USE site with a
119    // missing label or an unbound member, which reads like a compiler gap and
120    // is really just the wrong file.
121    //
122    // Cross-generation resolution stays available as a FALLBACK in both
123    // directions — that is what makes cross-version import reachable at
124    // all — so this only reorders which candidate wins for a name present
125    // in both, and never removes a resolution.
126    let bases: Vec<PathBuf> = if version == RustyfiVersion::V0_1 {
127        vec![
128            dist_v01_packages.clone(),
129            dist_v01_packages.join(name),
130            dist_packages.clone(),
131            root.to_path_buf(),
132            dist_packages.join(name),
133        ]
134    } else {
135        vec![
136            dist_packages.clone(),
137            root.to_path_buf(),
138            dist_packages.join(name),
139            dist_v01_packages.clone(),
140            dist_v01_packages.join(name),
141        ]
142    };
143    let mut candidates = Vec::new();
144    for base in &bases {
145        candidates.extend(candidates_in(base, name));
146    }
147    for candidate in &candidates {
148        if sources.is_file(candidate) {
149            return Ok(candidate.clone());
150        }
151    }
152    Err(candidates)
153}