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}