elly_core/module.rs
1use alloc::{boxed::Box, rc::Rc, vec::Vec};
2
3use crate::{Error, Expr, Text};
4
5/// A loaded module containing an immutable, name-sorted table of unevaluated
6/// item bodies. Built by [`crate::compile_module`] and stored in an `Rc`.
7/// Sibling references are compiled to [`Expr::ModItem`] and resolved via the
8/// home module in the environment ([`crate::eval_env::EnvNode::Module`]), ensuring the value
9/// heap remains acyclic. See `docs/done/2026-08-07_elly-modules-v0.md`.
10///
11/// Sibling references use a direct slice index; outside `M.name` accesses use binary search.
12#[derive(Debug, PartialEq, Eq)]
13pub struct ModuleData {
14 items: Box<[(Text, Expr)]>,
15 /// Name-sorted private bindings declared with `local`. These are members addressed
16 /// in the same index space as items, starting at `items.len()`.
17 ///
18 /// Privacy is enforced by excluding these from the `index_of` binary search used
19 /// for outside `M.name` access. Locals precede iotas so that id-bearing members
20 /// (including lambdas) fall within the block reserved by [`instantiate`].
21 /// See `docs/done/2026-08-13_elly-modules-local.md`.
22 locals: Box<[(Text, Expr)]>,
23 /// Name-sorted iotas declared with `const`. They share an address space with
24 /// items and locals, starting at [`len`](Self::len).
25 ///
26 /// Iotas reserve no frame slot or ID; their value is derived on reference based
27 /// on the instance and index. Only the name is stored for rendering.
28 iotas: Box<[Text]>,
29 /// The module's import signature: names of frame slots in index order.
30 /// Used for arity checks in [`instantiate`] and host introspection. Empty if no
31 /// frame exists. Imports precede any provided frame names.
32 frame_names: Box<[Text]>,
33 /// Import specifications for the leading frame slots in slot order.
34 /// Resolved during the const stage and filled with instances (see [`crate::load_module`]).
35 imports: Box<[ImportSpec]>,
36 /// The module body, evaluated in the module's environment by the holder.
37 /// This may be a [`recur`](Expr::Recur) group's body or a source's `__main`
38 /// (evaluated by a runner and skipped during import).
39 ///
40 /// It is stored separately from the item tables to maintain name-sorting
41 /// invariants and ensure the body is unnameable via `M.__main`.
42 /// See `docs/done/2026-08-14_elly-run.md`.
43 body: Option<Expr>,
44 /// The canonical name assigned by the host. This is a property of the code
45 /// rather than the instance. `None` for unnamed modules or [`recur`](Expr::Recur) groups.
46 name: Option<ModuleName>,
47}
48
49impl ModuleData {
50 /// Freezes resolved bindings into the item table. Bindings are name-sorted
51 /// by [`crate::resolve::resolve_module_bodies`] to maintain `ModItem` index consistency.
52 pub(crate) fn from_bindings(
53 bindings: alloc::vec::Vec<(Text, Expr)>,
54 locals: alloc::vec::Vec<(Text, Expr)>,
55 iotas: Box<[Text]>,
56 frame_names: Box<[Text]>,
57 imports: Box<[ImportSpec]>,
58 body: Option<Expr>,
59 name: Option<ModuleName>,
60 ) -> ModuleData {
61 debug_assert!(
62 bindings.is_sorted_by(|a, b| a.0.as_str() < b.0.as_str()),
63 "module bindings must be sorted by name and distinct"
64 );
65 debug_assert!(
66 locals.is_sorted_by(|a, b| a.0.as_str() < b.0.as_str()),
67 "module locals must be sorted by name and distinct"
68 );
69 debug_assert!(
70 iotas.is_sorted_by(|a, b| a.as_str() < b.as_str()),
71 "module iotas must be sorted by name and distinct"
72 );
73 debug_assert!(
74 imports.len() <= frame_names.len(),
75 "every import is a frame slot"
76 );
77 ModuleData {
78 items: bindings.into_boxed_slice(),
79 locals: locals.into_boxed_slice(),
80 iotas,
81 frame_names,
82 imports,
83 body,
84 name,
85 }
86 }
87
88 /// Name-sorted item table used for [`recur`](Expr::Recur) rendering and resolution.
89 /// Accessed via position ([`item`](Self::item)) or binary search ([`index_of`](Self::index_of)).
90 pub(crate) fn items(&self) -> &[(Text, Expr)] {
91 &self.items
92 }
93
94 /// Item bodies for in-place resolution. For [`recur`](Expr::Recur) groups,
95 /// bodies remain mutable after `ModuleData` creation, unlike file modules
96 /// which are frozen by [`from_bindings`](Self::from_bindings).
97 pub(crate) fn items_mut(&mut self) -> &mut [(Text, Expr)] {
98 &mut self.items
99 }
100
101 /// Returns the body at `index` (from [`Expr::ModItem`]), covering both items
102 /// and [locals](Self::locals). Returns `None` if the index is an iota.
103 pub(crate) fn item(&self, index: u32) -> Option<&Expr> {
104 let index = index as usize;
105 match self.items.get(index) {
106 Some((_, body)) => Some(body),
107 None => self
108 .locals
109 .get(index - self.items.len())
110 .map(|(_, body)| body),
111 }
112 }
113
114 /// Finds the index of `name` via binary search. Used by outside `M.name`
115 /// accesses before calling [`item`](Self::item).
116 pub(crate) fn index_of(&self, name: &str) -> Option<u32> {
117 self.items
118 .binary_search_by(|(n, _)| n.as_str().cmp(name))
119 .ok()
120 .map(|i| i as u32)
121 }
122
123 /// Total count of ID-bearing members (items and [locals](Self::locals)).
124 /// This defines the start of the iota address space and the ID block
125 /// size reserved by [`instantiate`].
126 pub(crate) fn len(&self) -> usize {
127 self.items.len() + self.locals.len()
128 }
129
130 /// Returns the iota name at `index` (calculated as `member index - len()`).
131 /// Returns `None` if the index is out of bounds, triggering a `.missing_property` error.
132 pub(crate) fn iota(&self, index: u32) -> Option<&Text> {
133 self.iotas.get(index as usize)
134 }
135
136 /// Sorted list of public item names accessible via outside `M.name` access.
137 pub fn item_names(&self) -> impl Iterator<Item = &Text> {
138 self.items.iter().map(|(n, _)| n)
139 }
140
141 /// Sorted list of names declared with `local`. Locals are unreachable
142 /// via `M.name` from outside; this method is provided for host
143 /// introspection (e.g., debuggers).
144 pub fn local_names(&self) -> Vec<&Text> {
145 self.locals.iter().map(|(n, _)| n).collect()
146 }
147
148 /// Sorted list of names declared with `const`. Private iotas are unreachable
149 /// via `M.name` from outside.
150 pub fn iota_names(&self) -> &[Text] {
151 &self.iotas
152 }
153
154 /// Names of frame slots in order. Defines the requirements for
155 /// `instantiate`. Empty if no frame exists.
156 pub fn frame_names(&self) -> &[Text] {
157 &self.frame_names
158 }
159
160 /// Import specifications in slot order. Provides a static, auditable record
161 /// of reachable modules without requiring execution.
162 pub fn imports(&self) -> &[ImportSpec] {
163 &self.imports
164 }
165
166 /// Returns the module's body (a `recur` group's body or a source's `__main`).
167 /// Evaluated by runners in the instance environment (see [`crate::eval_main`]);
168 /// remains inert during imports.
169 pub fn body(&self) -> Option<&Expr> {
170 self.body.as_ref()
171 }
172
173 /// Mutable reference to the body for in-place resolution. Required for
174 /// [`recur`](Expr::Recur) groups, which are built at parse.
175 pub(crate) fn body_mut(&mut self) -> Option<&mut Expr> {
176 self.body.as_mut()
177 }
178
179 /// The canonical name assigned by the host. Used by `__Mod.name` and the
180 /// const stage for instance deduplication. `None` if unnamed.
181 pub fn name(&self) -> Option<&ModuleName> {
182 self.name.as_ref()
183 }
184}
185
186/// What a module's source writes to name an import — a path, a package-qualified
187/// name. Its grammar belongs to the host resolver, not to Elly.
188pub type ImportSpec = Text;
189
190/// What a resolver answers a spec with: one name per module, however many specs
191/// reach it. Module instances are deduplicated by this name, so a resolver that
192/// returns the spec unchanged would hand back two instances of one module for
193/// `"./foo"` and `"foo"` (see [`crate::load_module`]).
194pub type ModuleName = Text;
195
196/// One `import` declaration: the name the importing module reaches the instance
197/// by, and the spec its host resolver is handed.
198///
199/// The two written forms differ in whether the module also **re-exports** the
200/// instance. `import "spec" as Foo` is a private frame slot; `Foo = import "spec"`
201/// is the same slot plus an ordinary item `Foo` that forwards it. Both arrive here
202/// as one `ImportDecl` — the re-exporting form has additionally pushed its
203/// forwarding item into `ModuleSyntax::items` (see
204/// `docs/done/2026-08-11_elly-modules-import.md`).
205#[derive(Debug, Clone, PartialEq, Eq)]
206pub struct ImportDecl {
207 /// The name the importing module's bodies read the instance by.
208 pub name: Text,
209 /// What the host resolver is handed.
210 pub spec: ImportSpec,
211}
212
213/// A module source's top-level declarations: its imports and its `const`
214/// declarations, in source order, and its items. The imports are the module's
215/// first frame slots — the const stage resolves, compiles and instantiates each
216/// one, then instantiates this module against the results (see
217/// [`crate::load_module`]). The iotas take no slot at all: an iota's identity is
218/// its instance plus its position, derived on reference (see
219/// `docs/done/2026-08-13_elly-modules-iotas.md`).
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ModuleSyntax {
222 /// The `import` declarations, in source order, which is frame-slot order.
223 pub imports: Vec<ImportDecl>,
224 /// The names declared by `const`, in source order — the module's **iotas**.
225 /// Sorted before they reach [`ModuleData`](crate::ModuleData), where the
226 /// order is the address space a member reference counts against.
227 pub iotas: Vec<Text>,
228 /// The `name = body` items, in source order, bodies unresolved.
229 pub items: Vec<(Text, Expr)>,
230 /// The `local name = body` declarations, in source order, bodies unresolved
231 /// — the module's **private** bindings. They are members like the items, and
232 /// differ only in not being searched by name from outside (see
233 /// `docs/done/2026-08-13_elly-modules-local.md`).
234 pub locals: Vec<(Text, Expr)>,
235 /// What the module declared as `__main`, if anything — the **reserved
236 /// moditem** a runner evaluates and applies to the root capability.
237 ///
238 /// It is a slot rather than an entry in [`items`](Self::items), and that is
239 /// what makes it unnameable: the member index space it stays out of is the
240 /// only thing a body's reference or an outside `M.name` access can address.
241 /// See `docs/done/2026-08-14_elly-run.md`.
242 pub main: Option<Expr>,
243}
244
245/// Compile Elly module source against a frame of ordered names.
246///
247/// References to frame names resolve to de Bruijn locals. Lookup order:
248/// enclosing binders $\to$ imports $\to$ module items $\to$ frame.
249/// Module imports occupy the leading frame slots.
250pub fn compile_module_framed(src: &str, frame: &[Text]) -> Result<Rc<ModuleData>, Error> {
251 compile_module_named(src, frame, None)
252}
253
254/// Same as [`compile_module_framed`], but records the canonical source name
255/// used by `__Mod.name` and for instance deduplication.
256pub fn compile_module_named(
257 src: &str,
258 frame: &[Text],
259 name: Option<ModuleName>,
260) -> Result<Rc<ModuleData>, Error> {
261 let seq = muon::parse(src).map_err(Error::Syntax)?;
262 let syntax = crate::parse_module(&seq).map_err(Error::Parse)?;
263 let mut bindings = syntax.items;
264 let mut locals = syntax.locals;
265 let import_names: Vec<Text> = syntax.imports.iter().map(|i| i.name.clone()).collect();
266 // The iota table is name-sorted, as the item and local tables are: the three
267 // orders concatenated are the member index space a `ModItem` counts against.
268 let mut iotas = syntax.iotas;
269 iotas.sort_by(|a, b| a.as_str().cmp(b.as_str()));
270 // `__main` is resolved with the item bodies and against the same tiers — it
271 // is written in the module's own scope, so it reaches every member and every
272 // frame slot. It takes no member index of its own (see `ModuleData::body`).
273 let mut main = syntax.main;
274 crate::resolve_module_bodies(
275 &mut bindings,
276 &mut locals,
277 main.as_mut(),
278 &import_names,
279 &iotas,
280 frame,
281 )
282 .map_err(Error::Parse)?;
283 let mut frame_names = import_names;
284 frame_names.extend_from_slice(frame);
285 Ok(Rc::new(ModuleData::from_bindings(
286 bindings,
287 locals,
288 iotas.into_boxed_slice(),
289 frame_names.into_boxed_slice(),
290 syntax
291 .imports
292 .into_iter()
293 .map(|i| i.spec)
294 .collect::<Vec<_>>()
295 .into_boxed_slice(),
296 main,
297 name,
298 )))
299}