Skip to main content

elly_core/
load.rs

1//! The **const stage**: turning a module's written `import`s into instances.
2//!
3//! A module's imports are declarations at its top level, not calls its bodies
4//! make, so the set of modules a program can reach is fixed by its source. This
5//! is where that set is walked: each spec is handed to the host
6//! [`ModuleResolver`], which answers with the module's canonical name and source;
7//! the source is compiled and instantiated; and the instance is written into the
8//! importing module's frame. Nothing is evaluated — an instantiated module is
9//! still a table of unevaluated bodies (see [`crate::instantiate`]).
10//!
11//! ## Leaves first, one instance per name
12//!
13//! The walk is a post-order depth-first traversal, so a module is instantiated
14//! only once every module it imports already is: its frame is filled with
15//! finished values and never with a placeholder to patch. That ordering is also
16//! the acyclicity the whole design rests on — a module frame holds only values
17//! older than the frame itself (`docs/done/2026-08-13_elly-modules-env.md`) — read as a
18//! schedule rather than checked afterwards. A graph with no such order has a
19//! cycle, and the traversal reports it ([`LoadError::Cycle`]) with the path that
20//! closes it, at compile time rather than as a surprise mid-run.
21//!
22//! Instances are deduplicated by **canonical name**, which is why the resolver
23//! returns one rather than the caller keying on the spec: `"./foo"` and `"foo"`
24//! are two specs for one module, and two instances of it would compare unequal
25//! and mint two sets of identities. With the dedup, a diamond — `A` and `B` both
26//! importing `Shared` — puts the very same object in both frames.
27//!
28//! The dedup map is [`Instances`], and it is an argument rather than something
29//! this stage allocates for itself: giving it a longer life than one compile is
30//! how a host caches loaded modules, which is a change of owner and not of
31//! design. See `docs/done/2026-08-11_elly-modules-import.md`.
32
33use alloc::string::String;
34use alloc::vec::Vec;
35
36use crate::eval::{instantiate, Ctx, ModuleData, Value};
37use crate::parse::ModuleName;
38use crate::Error;
39
40/// What a host answers a module spec with: the module's **canonical name** and
41/// its source.
42///
43/// The name is the identity of the module, and the const stage keys instances on
44/// it, so a resolver has to canonicalize: every spec that reaches one module must
45/// come back with one name. For a path resolver that is the resolved real path —
46/// the same thing it compares against its search root.
47pub struct Resolved {
48    /// One name per module, however many specs reach it.
49    pub name: String,
50    /// The module's Elly source.
51    pub source: String,
52}
53
54/// A host-provided module loader, consulted at **compile time** by the const
55/// stage. Maps a spec — whatever a source writes in `import "…"` — to the
56/// module's canonical name and source, or `None` if it cannot be found.
57///
58/// The `elly` core is `no_std`, so file I/O lives in the host behind this
59/// callback. A host that confines what a program may load (`elly-py`'s
60/// `PathResolver` searches a fixed directory list) enforces it here, and because
61/// the specs are literals in the source, refusing one is a compile-time refusal
62/// rather than a run-time one.
63pub trait ModuleResolver {
64    fn resolve(&self, spec: &str) -> Option<Resolved>;
65}
66
67/// The const stage's map from canonical name to instantiated module: what makes
68/// one name yield one instance.
69///
70/// A caller passes one in and may keep it: within a single [`load_module`] it
71/// deduplicates the import graph (a diamond shares its instance), and across
72/// calls it becomes a host's module cache, so a module loaded once is not
73/// compiled again. Reuse is safe because a name fixes a frame — one name has one
74/// set of imports, instantiated leaves-first, so name equality implies frame
75/// equality. The cost of keeping it is that a cached name is never recompiled:
76/// changed source on disk is not noticed until the entry is dropped.
77#[derive(Debug, Default)]
78pub struct Instances {
79    entries: Vec<(ModuleName, Value)>,
80}
81
82impl Instances {
83    /// An empty map — one compile's worth of dedup, nothing carried in.
84    pub fn new() -> Instances {
85        Instances {
86            entries: Vec::new(),
87        }
88    }
89
90    /// The instance recorded for `name`, if any.
91    pub fn get(&self, name: &str) -> Option<&Value> {
92        self.entries
93            .iter()
94            .find(|(n, _)| n.as_str() == name)
95            .map(|(_, v)| v)
96    }
97
98    /// Record `value` as the instance for `name`, replacing any earlier one.
99    pub fn insert(&mut self, name: ModuleName, value: Value) {
100        match self.entries.iter_mut().find(|(n, _)| *n == name) {
101            Some(entry) => entry.1 = value,
102            None => self.entries.push((name, value)),
103        }
104    }
105
106    /// How many modules the map holds.
107    pub fn len(&self) -> usize {
108        self.entries.len()
109    }
110
111    /// Whether the map holds no modules.
112    pub fn is_empty(&self) -> bool {
113        self.entries.is_empty()
114    }
115
116    /// The canonical names the map holds, in insertion order.
117    pub fn names(&self) -> impl Iterator<Item = &ModuleName> {
118        self.entries.iter().map(|(n, _)| n)
119    }
120}
121
122/// Why a module graph could not be loaded. Every one of these is a **compile-time**
123/// failure: the imports are literals in the source, so they are resolved, compiled
124/// and instantiated before anything runs, and none of it can raise mid-evaluation.
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum LoadError {
127    /// The resolver did not answer this spec. Carries the spec as written, since
128    /// the module it names has no canonical name to report.
129    NotFound { spec: String },
130    /// The imports form a cycle. `path` is the chain of canonical names that
131    /// closes it, importer before importee, ending with the name that repeats —
132    /// so `[A, B, A]` reads as "`A` imports `B` imports `A`".
133    Cycle { path: Vec<ModuleName> },
134    /// A module's source did not compile, named by the module it came from.
135    Compile { name: ModuleName, error: Error },
136}
137
138/// Load the module `spec` names, with its whole import graph: resolve, compile and
139/// instantiate every module it reaches, leaves first, one instance per canonical
140/// name, and hand back the instance for `spec` itself.
141///
142/// `seen` deduplicates and may be reused across calls as a cache (see
143/// [`Instances`]); pass a fresh one for an independent load. Identities come from
144/// `ctx`, which reserves each module's block of item ids as it is instantiated, so
145/// a host that hands values out between contexts should seed successors with
146/// [`Ctx::ids_used`](crate::Ctx::ids_used).
147pub fn load_module(
148    spec: &str,
149    resolver: &dyn ModuleResolver,
150    ctx: &Ctx,
151    seen: &mut Instances,
152) -> Result<Value, LoadError> {
153    let mut path = Vec::new();
154    load_spec(spec, resolver, ctx, seen, &mut path)
155}
156
157/// [`load_module`] for source the caller already has, compiled under the canonical
158/// `name` it should be known by. Its own imports still go through `resolver`.
159///
160/// Unlike [`load_module`] this **ignores** any instance `seen` already holds for
161/// `name` and replaces it: the caller supplied the source, so it means this text
162/// rather than whatever was loaded under that name before. That is what makes an
163/// explicit reload possible while a plain by-name load stays cached.
164pub fn load_module_source(
165    name: &str,
166    src: &str,
167    resolver: &dyn ModuleResolver,
168    ctx: &Ctx,
169    seen: &mut Instances,
170) -> Result<Value, LoadError> {
171    let mut path = Vec::new();
172    instantiate_source(ModuleName::from(name), src, resolver, ctx, seen, &mut path)
173}
174
175/// Resolve one spec and give back its instance: the cached one, or a freshly
176/// compiled and instantiated one. Reaching a name that is already being compiled
177/// means the graph has a cycle.
178fn load_spec(
179    spec: &str,
180    resolver: &dyn ModuleResolver,
181    ctx: &Ctx,
182    seen: &mut Instances,
183    path: &mut Vec<ModuleName>,
184) -> Result<Value, LoadError> {
185    let resolved = resolver.resolve(spec).ok_or_else(|| LoadError::NotFound {
186        spec: String::from(spec),
187    })?;
188    let name = ModuleName::from(resolved.name.as_str());
189    if let Some(instance) = seen.get(name.as_str()) {
190        return Ok(instance.clone());
191    }
192    if path.contains(&name) {
193        let mut cycle = path.clone();
194        cycle.push(name);
195        return Err(LoadError::Cycle { path: cycle });
196    }
197    instantiate_source(name, &resolved.source, resolver, ctx, seen, path)
198}
199
200/// Compile `src` as `name`, load everything it imports, then instantiate it
201/// against those instances and record it.
202///
203/// The imports are loaded *after* this module is on `path` and *before* it is
204/// instantiated, which is what makes the traversal post-order: every frame slot
205/// holds a finished module by the time the frame is built.
206fn instantiate_source(
207    name: ModuleName,
208    src: &str,
209    resolver: &dyn ModuleResolver,
210    ctx: &Ctx,
211    seen: &mut Instances,
212    path: &mut Vec<ModuleName>,
213) -> Result<Value, LoadError> {
214    let data: alloc::rc::Rc<ModuleData> = crate::compile_module_named(src, &[], Some(name.clone()))
215        .map_err(|error| LoadError::Compile {
216            name: name.clone(),
217            error,
218        })?;
219    path.push(name.clone());
220    let mut frame: Vec<Value> = Vec::with_capacity(data.imports().len());
221    for spec in data.imports() {
222        frame.push(load_spec(spec.as_str(), resolver, ctx, seen, path)?);
223    }
224    path.pop();
225    // The module was compiled against no host frame, so its slots are exactly its
226    // imports and the arity check cannot fail.
227    let instance =
228        instantiate(data, &frame, ctx).expect("a module's frame is exactly its own imports");
229    seen.insert(name, instance.clone());
230    Ok(instance)
231}