elly_host/resolve.rs
1//! The official file-system [`ModuleResolver`]. It takes today's two rule sets
2//! over unchanged until packages replace them (decision 3 of
3//! `docs/done/2026-10-02_elly-host.md`):
4//!
5//! - `./…` or `../…` resolves against a **base directory**, the entry script's
6//! for a program;
7//! - anything else searches a list of directories, `ELLY_PATH` for a program,
8//! nested specs included.
9//!
10//! Relative specs answer to the *base* rather than to the importing module
11//! because the seam has no room for anything else: `resolve` is handed a spec
12//! and nothing about who asked. So a library's own `./sibling` means a sibling
13//! of the program that loaded it — a sharp edge worth knowing, and one the
14//! packages follow-up owns.
15//!
16//! The search tier is **confined** to its directories: a spec there names a file
17//! *below* one of them and nothing else. [`is_confined_spec`] is the spec-shape
18//! rule, and a containment check after resolving symlinks backs it up.
19//!
20//! Each candidate is tried as `<spec>.ly` then `<spec>`, and the **canonical name
21//! is the resolved real path**. That is what satisfies the const stage's one
22//! requirement: every spec reaching one file must come back with one name, or a
23//! diamond quietly becomes two instances with distinct iotas and closure
24//! identities.
25
26use std::path::{Component, Path, PathBuf};
27use std::rc::Rc;
28
29use elly_core::{ModuleResolver, Resolved};
30
31use crate::Sys;
32
33/// The extension an Elly module file carries. A spec is written without it.
34const EXT: &str = "ly";
35
36/// The environment variable holding the search path for a non-relative spec,
37/// separated as `PATH` is on the platform.
38pub const PATH_VAR: &str = "ELLY_PATH";
39
40pub struct FsResolver {
41 sys: Rc<dyn Sys>,
42 /// The directory a `./` or `../` spec is relative to. `None` rejects
43 /// relative specs.
44 base: Option<PathBuf>,
45 /// The canonical search directories, in order, for everything else —
46 /// canonical so a resolved candidate can be tested for lying inside one.
47 search: Vec<PathBuf>,
48}
49
50impl FsResolver {
51 /// A resolver with relative specs answered from `base` and the rest from
52 /// `search`. A search directory that does not exist is dropped.
53 pub fn new(sys: Rc<dyn Sys>, base: Option<PathBuf>, search: Vec<PathBuf>) -> FsResolver {
54 let search = search
55 .iter()
56 .filter(|dir| !dir.as_os_str().is_empty())
57 .filter_map(|dir| sys.real_path(dir))
58 .collect();
59 FsResolver { sys, base, search }
60 }
61
62 /// A resolver for a program whose entry script is `script`: relative specs
63 /// against its directory, the rest from `ELLY_PATH`.
64 pub fn for_script(sys: Rc<dyn Sys>, script: &Path) -> FsResolver {
65 let base = script
66 .parent()
67 .map(Path::to_path_buf)
68 .unwrap_or_else(|| PathBuf::from("."));
69 let search = search_path(&*sys);
70 FsResolver::new(sys, Some(base), search)
71 }
72
73 /// Read the module `spec` names below `dir`, trying `<spec>.ly` then
74 /// `<spec>`. With `confined`, a candidate whose real path leaves `dir` is a
75 /// miss. An unreadable candidate is a miss for that candidate only, so the
76 /// search goes on.
77 fn read_below(&self, dir: &Path, spec: &str, confined: bool) -> Option<Resolved> {
78 for candidate in [format!("{spec}.{EXT}"), spec.to_string()] {
79 let Some(real) = self.sys.real_path(&dir.join(candidate)) else {
80 continue;
81 };
82 // `dir/link.ly` passes the spec-shape rule but may point anywhere.
83 if confined && !real.starts_with(dir) {
84 continue;
85 }
86 if let Some(source) = self.sys.read_source(&real) {
87 return Some(Resolved {
88 name: real.to_string_lossy().into_owned(),
89 source,
90 });
91 }
92 }
93 None
94 }
95}
96
97impl ModuleResolver for FsResolver {
98 fn resolve(&self, spec: &str) -> Option<Resolved> {
99 if is_relative_spec(spec) {
100 let base = self.base.as_ref()?;
101 return self.read_below(base, spec, false);
102 }
103 if !is_confined_spec(spec) {
104 return None;
105 }
106 self.search
107 .iter()
108 .find_map(|dir| self.read_below(dir, spec, true))
109 }
110}
111
112/// The directories in `ELLY_PATH`, in order.
113pub fn search_path(sys: &dyn Sys) -> Vec<PathBuf> {
114 sys.var(PATH_VAR)
115 .map(|paths| std::env::split_paths(&paths).collect())
116 .unwrap_or_default()
117}
118
119/// Does `spec` ask to be resolved against the base directory? Only the two
120/// explicit prefixes do; a bare `foo/bar` is a search-path spec, so which tier
121/// answers is visible in the source rather than depending on what happens to
122/// exist on disk.
123pub fn is_relative_spec(spec: &str) -> bool {
124 spec.starts_with("./") || spec.starts_with("../")
125}
126
127/// Can `spec` name a module below a search directory? Only a non-empty relative
128/// path of plain components qualifies. This rejects an absolute spec — which
129/// `Path::join` would otherwise let replace the search directory outright — and
130/// any `.`/`..` component, which would climb out of the directory. A nested spec
131/// (`sub/tag`) is fine.
132fn is_confined_spec(spec: &str) -> bool {
133 !spec.is_empty()
134 && Path::new(spec)
135 .components()
136 .all(|c| matches!(c, Component::Normal(_)))
137}