Skip to main content

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}