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}