Skip to main content

elly_core/
module.rs

1//! Modules: compiling module source into a chunk, and resolving and
2//! instantiating a module's import graph.
3//!
4//! Imports are top-level declarations; the reachable module set is fixed by source.
5//! The [`ModuleResolver`] provides a module's canonical name and source, which is then
6//! compiled and instantiated into the importing module's frame. Instantiated modules
7//! contain unevaluated bodies (see [`crate::instantiate`]).
8//!
9//! ## Loading Process
10//!
11//! Traversal is post-order depth-first; a module is instantiated only after its imports.
12//! This ensures frames contain only finished values and enforces acyclicity.
13//! Cycles are detected during traversal and reported as [`LoadError::Cycle`].
14//!
15//! Modules are deduplicated by canonical name. All specs resolving to the same name
16//! share a single instance, ensuring consistent identity across the import graph.
17//!
18//! [`Instances`] maps names to instances. A [`Ctx`] holds one for its lifetime,
19//! together with its resolver, so loads through [`Ctx::load`] share a cache. The
20//! free functions [`load_module`] and [`load_module_source`] take both explicitly.
21
22use alloc::string::String;
23use alloc::vec::Vec;
24use core::cell::Ref;
25
26use crate::code::{ChunkId, Text};
27use crate::eval::instantiate;
28use crate::source::DocSource;
29use crate::{Ctx, Error, Value};
30
31/// What a module's source writes to name an import — a path, a package-qualified
32/// name. Its grammar belongs to the host resolver, not to Elly.
33pub type ImportSpec = Text;
34
35/// What a resolver answers a spec with: one name per module, however many specs
36/// reach it. Module instances are deduplicated by this name, so a resolver that
37/// returns the spec unchanged would hand back two instances of one module for
38/// `"./foo"` and `"foo"` (see [`crate::load_module`]).
39pub type ModuleName = Text;
40
41impl Ctx {
42    /// Compile Elly module source into a new chunk, whose top module is the
43    /// module (see `docs/done/2026-08-07_elly-modules-v0.md`).
44    ///
45    /// This is purely syntactic: bodies are resolved against the module's
46    /// members and imports are mapped to leading frame slots. No bodies are
47    /// evaluated; [`instantiate`](crate::instantiate) makes an instance.
48    pub fn compile_module(&self, src: &str) -> Result<ChunkId, Error> {
49        self.compile_module_framed(src, &[])
50    }
51
52    /// Compile Elly module source against a frame of ordered names.
53    ///
54    /// References to frame names resolve to de Bruijn locals. Lookup order:
55    /// enclosing binders $\to$ imports $\to$ module items $\to$ frame.
56    /// Module imports occupy the leading frame slots.
57    pub fn compile_module_framed(&self, src: &str, frame: &[Text]) -> Result<ChunkId, Error> {
58        self.compile_module_named(src, frame, None)
59    }
60
61    /// Same as [`compile_module_framed`](Self::compile_module_framed), but records
62    /// the canonical source name used by `__Mod.name` and for instance
63    /// deduplication. The chunk takes the name, or `__module_N` if there is none.
64    pub fn compile_module_named(
65        &self,
66        src: &str,
67        frame: &[Text],
68        name: Option<ModuleName>,
69    ) -> Result<ChunkId, Error> {
70        self.compile_source(src, frame, name, None)
71    }
72}
73
74/// A module's canonical name and source.
75///
76/// The name defines the module's identity; all specs resolving to one module must
77/// return the same canonical name.
78pub struct Resolved {
79    /// Unique name for the module.
80    pub name: String,
81    /// Module source.
82    pub source: String,
83    /// The file the source was read from, if any: where an error's location
84    /// points (see [`DocSource::Path`]).
85    pub path: Option<String>,
86}
87
88/// Host-provided module loader. Maps an import spec to its canonical name and source.
89///
90/// Because `elly-core` is `no_std`, file I/O is handled by the host via this trait.
91pub trait ModuleResolver {
92    fn resolve(&self, spec: &str) -> Option<Resolved>;
93}
94
95/// Map from canonical name to instantiated module.
96///
97/// Deduplicates the import graph within a single [`load_module`] and acts as the
98/// cache a [`Ctx`] keeps across loads. Since a name fixes the frame and imports,
99/// cached instances remain valid unless dropped.
100#[derive(Debug, Default)]
101pub struct Instances {
102    entries: Vec<(ModuleName, Value)>,
103}
104
105impl Instances {
106    /// Create an empty instance map.
107    pub fn new() -> Instances {
108        Instances {
109            entries: Vec::new(),
110        }
111    }
112
113    /// Get the instance for `name`.
114    pub fn get(&self, name: &str) -> Option<&Value> {
115        self.entries
116            .iter()
117            .find(|(n, _)| n.as_str() == name)
118            .map(|(_, v)| v)
119    }
120
121    /// Insert or replace the instance for `name`.
122    pub fn insert(&mut self, name: ModuleName, value: Value) {
123        match self.entries.iter_mut().find(|(n, _)| *n == name) {
124            Some(entry) => entry.1 = value,
125            None => self.entries.push((name, value)),
126        }
127    }
128
129    /// Number of modules in the map.
130    pub fn len(&self) -> usize {
131        self.entries.len()
132    }
133
134    /// Whether the map is empty.
135    pub fn is_empty(&self) -> bool {
136        self.entries.is_empty()
137    }
138
139    /// Canonical names in insertion order.
140    pub fn names(&self) -> impl Iterator<Item = &ModuleName> {
141        self.entries.iter().map(|(n, _)| n)
142    }
143}
144
145/// Compile-time module loading errors.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum LoadError {
148    /// Spec could not be resolved.
149    NotFound { spec: String },
150    /// Import cycle detected; `path` is the closing chain of names.
151    Cycle { path: Vec<ModuleName> },
152    /// Module failed to compile. `source` is its text, and `path` the file it
153    /// was read from, if any: what [`Error::report`] needs to show the error
154    /// at its line.
155    Compile {
156        name: ModuleName,
157        error: Error,
158        path: Option<String>,
159        source: String,
160    },
161}
162
163impl Ctx {
164    /// Loads a module and its import graph through this context's resolver,
165    /// returning the instance for `spec`. A module already in the cache is
166    /// reused, so two loads that reach one module share its instance.
167    ///
168    /// Without a resolver, every spec is [`LoadError::NotFound`].
169    ///
170    /// Loading is not re-entrant: nothing evaluates while a graph is
171    /// instantiated, so no host callback can run a nested load.
172    pub fn load(&self, spec: &str) -> Result<Value, LoadError> {
173        let mut seen = self.instances.borrow_mut();
174        load_module(spec, self.resolver(), self, &mut seen)
175    }
176
177    /// Loads a module from `src` under `name`, resolving its imports through this
178    /// context's resolver. The caller supplied the text, so it replaces whatever
179    /// `name` held in the cache — the explicit reload.
180    pub fn load_source(&self, name: &str, src: &str) -> Result<Value, LoadError> {
181        let mut seen = self.instances.borrow_mut();
182        load_module_source(name, src, self.resolver(), self, &mut seen)
183    }
184
185    /// The modules loaded so far, by canonical name.
186    pub fn instances(&self) -> Ref<'_, Instances> {
187        self.instances.borrow()
188    }
189
190    fn resolver(&self) -> &dyn ModuleResolver {
191        match &self.resolver {
192            Some(resolver) => resolver.as_ref(),
193            None => &NoResolver,
194        }
195    }
196}
197
198/// The resolver of a [`Ctx`] given none: nothing resolves.
199struct NoResolver;
200
201impl ModuleResolver for NoResolver {
202    fn resolve(&self, _spec: &str) -> Option<Resolved> {
203        None
204    }
205}
206
207/// Loads a module and its import graph: resolves, compiles, and instantiates
208/// modules leaves-first, returning the instance for `spec`.
209///
210/// `seen` deduplicates instances and may be used as a cache. `ctx` handles
211/// item ID allocation.
212pub fn load_module(
213    spec: &str,
214    resolver: &dyn ModuleResolver,
215    ctx: &Ctx,
216    seen: &mut Instances,
217) -> Result<Value, LoadError> {
218    let mut path = Vec::new();
219    load_spec(spec, resolver, ctx, seen, &mut path)
220}
221
222/// Loads a module from provided source under `name`.
223///
224/// Ignores and replaces any existing instance of `name` in `seen`, allowing
225/// explicit reloads.
226pub fn load_module_source(
227    name: &str,
228    src: &str,
229    resolver: &dyn ModuleResolver,
230    ctx: &Ctx,
231    seen: &mut Instances,
232) -> Result<Value, LoadError> {
233    let mut path = Vec::new();
234    let name = ModuleName::from(name);
235    instantiate_source(name, src, None, resolver, ctx, seen, &mut path)
236}
237
238/// Resolves a spec to an instance, using `seen` for deduplication.
239/// Detects cycles via `path`.
240fn load_spec(
241    spec: &str,
242    resolver: &dyn ModuleResolver,
243    ctx: &Ctx,
244    seen: &mut Instances,
245    path: &mut Vec<ModuleName>,
246) -> Result<Value, LoadError> {
247    let resolved = resolver.resolve(spec).ok_or_else(|| LoadError::NotFound {
248        spec: String::from(spec),
249    })?;
250    let name = ModuleName::from(resolved.name.as_str());
251    if let Some(instance) = seen.get(name.as_str()) {
252        return Ok(instance.clone());
253    }
254    if path.contains(&name) {
255        let mut cycle = path.clone();
256        cycle.push(name);
257        return Err(LoadError::Cycle { path: cycle });
258    }
259    let doc = resolved
260        .path
261        .map(|path| DocSource::Path(path.as_str().into()));
262    instantiate_source(name, &resolved.source, doc, resolver, ctx, seen, path)
263}
264
265/// Compiles source as `name`, from `doc` (`None` for an anonymous source),
266/// loads imports, and instantiates the module.
267///
268/// Traversal is post-order; imports are loaded before the module is instantiated,
269/// ensuring frame slots contain finished modules.
270fn instantiate_source(
271    name: ModuleName,
272    src: &str,
273    doc: Option<DocSource>,
274    resolver: &dyn ModuleResolver,
275    ctx: &Ctx,
276    seen: &mut Instances,
277    path: &mut Vec<ModuleName>,
278) -> Result<Value, LoadError> {
279    let doc_path = doc.clone();
280    let chunk = ctx
281        .compile_source(src, &[], Some(name.clone()), doc)
282        .map_err(|error| LoadError::Compile {
283            name: name.clone(),
284            error,
285            path: match doc_path {
286                Some(DocSource::Path(path)) => Some(String::from(path.as_str())),
287                _ => None,
288            },
289            source: String::from(src),
290        })?;
291    path.push(name.clone());
292    let specs: Vec<ImportSpec> = {
293        let code = ctx.chunk(chunk);
294        code.imports(code.module(code.top())).to_vec()
295    };
296    let mut frame: Vec<Value> = Vec::with_capacity(specs.len());
297    for spec in &specs {
298        frame.push(load_spec(spec.as_str(), resolver, ctx, seen, path)?);
299    }
300    path.pop();
301    // The module was compiled against no host frame, so its slots are exactly its
302    // imports and the arity check cannot fail.
303    let instance =
304        instantiate(chunk, &frame, ctx).expect("a module's frame is exactly its own imports");
305    seen.insert(name, instance.clone());
306    Ok(instance)
307}