Skip to main content

tatara_lisp_eval/
module.rs

1//! Module system — file-as-module + qualified names + alias imports.
2//!
3//! Design rationale (researched, see commit history): file = module.
4//! No explicit `(namespace foo)` declaration; the file's path IS the
5//! module's identifier. Exports are explicit via `(provide ...)`;
6//! imports through `(require "path" :as alias)` or `(require "path"
7//! :refer (a b c))`. Qualified names like `foo/bar` resolve via the
8//! loaded module table at eval time.
9//!
10//! Loader injection: the eval crate is filesystem-free. Embedders pass
11//! a `Loader` trait object that resolves a module path string into
12//! source. `tatara-script` provides a `FilesystemLoader`; tests use an
13//! in-memory `MapLoader`.
14//!
15//! Cycle detection: each `require` push the path onto a load stack;
16//! re-entering the same path raises `EvalError::User`. This is the
17//! simplest sound approach — no need for two-phase resolution.
18
19use std::collections::{HashMap, HashSet};
20use std::sync::{Arc, Mutex};
21
22use thiserror::Error;
23
24use crate::value::Value;
25
26/// One module's contribution to the global symbol table:
27/// every binding it defines, plus the subset that's been
28/// `(provide)`-d as exported.
29#[derive(Debug, Clone, Default)]
30pub struct Module {
31    pub path: Arc<str>,
32    pub exports: HashSet<Arc<str>>,
33    pub bindings: HashMap<Arc<str>, Value>,
34}
35
36impl Module {
37    pub fn new(path: impl Into<Arc<str>>) -> Self {
38        Self {
39            path: path.into(),
40            exports: HashSet::new(),
41            bindings: HashMap::new(),
42        }
43    }
44
45    /// Look up an exported binding. `None` if the name isn't defined
46    /// or isn't in the export set.
47    pub fn get_export(&self, name: &str) -> Option<Value> {
48        if self.exports.contains(name) {
49            self.bindings.get(name).cloned()
50        } else {
51            None
52        }
53    }
54
55    /// Add to the export set. Idempotent.
56    pub fn add_export(&mut self, name: impl Into<Arc<str>>) {
57        self.exports.insert(name.into());
58    }
59
60    /// Bind a value (either from a `define` while loading or from
61    /// embedder pre-population).
62    pub fn define(&mut self, name: impl Into<Arc<str>>, value: Value) {
63        self.bindings.insert(name.into(), value);
64    }
65}
66
67/// Source-loading hook. Resolves a `module path` (the string the user
68/// wrote in `(require "path")`) into its source text. Embedders own
69/// the path semantics — relative-to-cwd, relative-to-caller, search
70/// path with `$TATARA_PATH`, in-memory map for tests, etc.
71pub trait Loader: Send + Sync {
72    fn load(&self, path: &str) -> Result<String, ModuleError>;
73}
74
75/// In-memory loader — useful for tests and bundled-stdlib loading.
76/// Path strings map directly to source strings; missing path → error.
77#[derive(Default, Debug, Clone)]
78pub struct MapLoader {
79    pub modules: HashMap<String, String>,
80}
81
82impl MapLoader {
83    pub fn new() -> Self {
84        Self::default()
85    }
86
87    pub fn insert(&mut self, path: impl Into<String>, source: impl Into<String>) -> &mut Self {
88        self.modules.insert(path.into(), source.into());
89        self
90    }
91}
92
93impl Loader for MapLoader {
94    fn load(&self, path: &str) -> Result<String, ModuleError> {
95        self.modules
96            .get(path)
97            .cloned()
98            .ok_or_else(|| ModuleError::NotFound(path.to_string()))
99    }
100}
101
102/// Default no-op loader for embedders that haven't wired one up yet.
103/// Returns `NotFound` for every path; modules calling `(require ...)`
104/// will surface that error to the user.
105#[derive(Debug, Default, Clone)]
106pub struct NoLoader;
107
108impl Loader for NoLoader {
109    fn load(&self, path: &str) -> Result<String, ModuleError> {
110        Err(ModuleError::NotFound(path.to_string()))
111    }
112}
113
114/// Filesystem-backed loader. Reads a module path string by walking a
115/// base directory (or filesystem-absolute paths). Path-resolution rules
116/// match the documented design:
117///
118/// 1. `path` ending in `.tlisp` or `.lisp` is read as-is.
119/// 2. `path` without an extension tries `<path>.tlisp`, then
120///    `<path>.lisp`, then `<path>/init.tlisp`, then `<path>/init.lisp`.
121/// 3. Relative paths resolve against `base_dir`. Absolute paths are
122///    passed through. The optional `extra_search_paths` list (e.g.
123///    a `$TATARA_PATH`-equivalent) is consulted in order if the
124///    primary lookup fails.
125///
126/// The loader is `Send + Sync` so it can live behind the `Arc<dyn Loader>`
127/// the Interpreter expects.
128#[derive(Debug, Clone)]
129pub struct FilesystemLoader {
130    pub base_dir: std::path::PathBuf,
131    pub extra_search_paths: Vec<std::path::PathBuf>,
132}
133
134impl FilesystemLoader {
135    pub fn new(base_dir: impl Into<std::path::PathBuf>) -> Self {
136        Self {
137            base_dir: base_dir.into(),
138            extra_search_paths: Vec::new(),
139        }
140    }
141
142    pub fn with_search_paths(
143        mut self,
144        paths: impl IntoIterator<Item = std::path::PathBuf>,
145    ) -> Self {
146        self.extra_search_paths.extend(paths);
147        self
148    }
149
150    fn candidates(&self, path: &str) -> Vec<std::path::PathBuf> {
151        let p = std::path::Path::new(path);
152        let has_ext = p
153            .extension()
154            .is_some_and(|e| matches!(e.to_str(), Some("tlisp" | "lisp")));
155        let mut bases: Vec<std::path::PathBuf> = Vec::new();
156        if p.is_absolute() {
157            bases.push(p.to_path_buf());
158        } else {
159            bases.push(self.base_dir.join(p));
160            for extra in &self.extra_search_paths {
161                bases.push(extra.join(p));
162            }
163        }
164        let mut out = Vec::with_capacity(bases.len() * 4);
165        for base in bases {
166            if has_ext {
167                out.push(base);
168            } else {
169                out.push(base.with_extension("tlisp"));
170                out.push(base.with_extension("lisp"));
171                out.push(base.join("init.tlisp"));
172                out.push(base.join("init.lisp"));
173            }
174        }
175        out
176    }
177}
178
179impl Loader for FilesystemLoader {
180    fn load(&self, path: &str) -> Result<String, ModuleError> {
181        for candidate in self.candidates(path) {
182            if let Ok(s) = std::fs::read_to_string(&candidate) {
183                return Ok(s);
184            }
185        }
186        Err(ModuleError::NotFound(path.to_string()))
187    }
188}
189
190/// Errors specific to the module pipeline. Embedders convert these
191/// to user-facing `EvalError::User { value: Value::Error(...) }`.
192#[derive(Debug, Error, Clone)]
193pub enum ModuleError {
194    #[error("module not found: {0}")]
195    NotFound(String),
196    #[error("circular require: {path} (load stack: {stack})")]
197    Circular { path: String, stack: String },
198    #[error("name not exported: {1} from module {0}")]
199    NotExported(String, String),
200}
201
202/// Process-global module registry. Holds every module that's been
203/// loaded so far, keyed by path. Two `(require "lib/auth")` calls
204/// from different sites share one Module instance — the file is
205/// loaded + evaluated exactly once.
206#[derive(Debug, Default, Clone)]
207pub struct ModuleRegistry {
208    inner: Arc<Mutex<RegistryInner>>,
209}
210
211#[derive(Debug, Default)]
212pub(crate) struct RegistryInner {
213    pub(crate) modules: HashMap<Arc<str>, Module>,
214    /// Currently-loading paths (for cycle detection).
215    pub(crate) loading: Vec<String>,
216    /// Exports declared via `(provide ...)` inside a still-loading
217    /// module. Drained on `finish_load` and merged into the Module.
218    /// Keyed by module path; value is the set of names provided.
219    pub(crate) exports_staging: HashMap<String, HashSet<Arc<str>>>,
220}
221
222impl ModuleRegistry {
223    pub fn new() -> Self {
224        Self::default()
225    }
226
227    /// Has this path already been fully loaded?
228    pub fn has(&self, path: &str) -> bool {
229        let g = self.inner.lock().unwrap();
230        g.modules.contains_key(path)
231    }
232
233    /// Snapshot a loaded module. Returns `None` if not yet loaded.
234    pub fn get(&self, path: &str) -> Option<Module> {
235        let g = self.inner.lock().unwrap();
236        g.modules.get(path).cloned()
237    }
238
239    /// Begin loading `path`. Pushes onto the load stack and returns
240    /// `Err(Circular)` if the path is already on the stack.
241    pub fn begin_load(&self, path: &str) -> Result<(), ModuleError> {
242        let mut g = self.inner.lock().unwrap();
243        if g.loading.iter().any(|p| p == path) {
244            return Err(ModuleError::Circular {
245                path: path.to_string(),
246                stack: g.loading.join(" → "),
247            });
248        }
249        g.loading.push(path.to_string());
250        Ok(())
251    }
252
253    /// Finish loading `path` — remove from load stack, store final
254    /// module bindings.
255    pub fn finish_load(&self, module: Module) {
256        let mut g = self.inner.lock().unwrap();
257        g.loading.retain(|p| **p != *module.path);
258        g.modules.insert(module.path.clone(), module);
259    }
260
261    /// Abort a load (e.g., after an error during eval). Drops the
262    /// path from the load stack so retries can succeed.
263    pub fn abort_load(&self, path: &str) {
264        let mut g = self.inner.lock().unwrap();
265        g.loading.retain(|p| p != path);
266    }
267
268    /// Number of fully-loaded modules. Useful for tests + tooling.
269    pub fn len(&self) -> usize {
270        self.inner.lock().unwrap().modules.len()
271    }
272
273    pub fn is_empty(&self) -> bool {
274        self.len() == 0
275    }
276
277    /// Internal access to the lock — used by the eval loop to stage
278    /// exports during a module load.
279    pub(crate) fn inner_lock(&self) -> std::sync::MutexGuard<'_, RegistryInner> {
280        self.inner.lock().unwrap()
281    }
282}
283
284/// Split a qualified name `foo/bar` into `(module-alias, member)`.
285/// Returns `None` if there's no `/` separator (caller treats as a
286/// plain unqualified name).
287///
288/// Multi-segment aliases like `lib/auth/validate-token` resolve to
289/// alias = `lib/auth` and member = `validate-token` — i.e., the LAST
290/// `/` is the separator. This matches Clojure semantics where
291/// `lib.auth/validate-token` (using `.` for the alias and `/` for
292/// the boundary) splits at the FINAL `/`.
293pub fn split_qualified(name: &str) -> Option<(&str, &str)> {
294    let idx = name.rfind('/')?;
295    // A bare leading `/` (e.g. `/foo`) or trailing `/` (e.g. `foo/`)
296    // isn't a qualified name.
297    if idx == 0 || idx == name.len() - 1 {
298        return None;
299    }
300    Some((&name[..idx], &name[idx + 1..]))
301}
302
303#[cfg(test)]
304mod tests {
305    use super::*;
306
307    #[test]
308    fn split_qualified_works() {
309        assert_eq!(split_qualified("foo/bar"), Some(("foo", "bar")));
310        assert_eq!(
311            split_qualified("lib/auth/validate"),
312            Some(("lib/auth", "validate"))
313        );
314        assert_eq!(split_qualified("plain"), None);
315        assert_eq!(split_qualified("/leading"), None);
316        assert_eq!(split_qualified("trailing/"), None);
317    }
318
319    #[test]
320    fn map_loader_round_trips() {
321        let mut l = MapLoader::new();
322        l.insert("lib/auth", "(define x 42)");
323        assert_eq!(l.load("lib/auth").unwrap(), "(define x 42)");
324        assert!(matches!(l.load("missing"), Err(ModuleError::NotFound(_))));
325    }
326
327    #[test]
328    fn registry_cycle_detection() {
329        let r = ModuleRegistry::new();
330        r.begin_load("a").unwrap();
331        r.begin_load("b").unwrap();
332        let err = r.begin_load("a").unwrap_err();
333        assert!(matches!(err, ModuleError::Circular { .. }));
334    }
335
336    #[test]
337    fn registry_finish_load_makes_module_visible() {
338        let r = ModuleRegistry::new();
339        r.begin_load("foo").unwrap();
340        let mut m = Module::new("foo");
341        m.define("x", Value::Int(42));
342        m.add_export("x");
343        r.finish_load(m);
344        assert!(r.has("foo"));
345        let exported = r.get("foo").unwrap().get_export("x");
346        assert!(matches!(exported, Some(Value::Int(42))));
347    }
348
349    #[test]
350    fn registry_finish_load_removes_from_loading() {
351        let r = ModuleRegistry::new();
352        r.begin_load("foo").unwrap();
353        r.finish_load(Module::new("foo"));
354        // Re-loading the same path should now succeed (not cyclic).
355        r.begin_load("foo").unwrap();
356        r.abort_load("foo");
357    }
358
359    #[test]
360    fn filesystem_loader_resolves_with_extensions() {
361        use std::io::Write;
362        let dir = tempfile_dir();
363        // Drop a "lib/util.tlisp" file.
364        let lib = dir.join("lib");
365        std::fs::create_dir_all(&lib).unwrap();
366        let mut f = std::fs::File::create(lib.join("util.tlisp")).unwrap();
367        writeln!(f, "(define x 42)").unwrap();
368
369        let loader = FilesystemLoader::new(&dir);
370        // Bare name → tries `<base>/lib/util.tlisp`.
371        let src = loader.load("lib/util").unwrap();
372        assert!(src.contains("define x 42"));
373
374        // Explicit extension also works.
375        let src2 = loader.load("lib/util.tlisp").unwrap();
376        assert_eq!(src, src2);
377
378        // Missing path errors clearly.
379        assert!(matches!(
380            loader.load("missing/whatever"),
381            Err(ModuleError::NotFound(_))
382        ));
383
384        let _ = std::fs::remove_dir_all(&dir);
385    }
386
387    fn tempfile_dir() -> std::path::PathBuf {
388        use std::time::{SystemTime, UNIX_EPOCH};
389        let nanos = SystemTime::now()
390            .duration_since(UNIX_EPOCH)
391            .unwrap()
392            .as_nanos();
393        let mut tmp = std::env::temp_dir();
394        tmp.push(format!("tatara-loader-test-{nanos}"));
395        std::fs::create_dir_all(&tmp).unwrap();
396        tmp
397    }
398
399    #[test]
400    fn module_get_export_respects_export_set() {
401        let mut m = Module::new("test");
402        m.define("public", Value::Int(1));
403        m.define("private", Value::Int(2));
404        m.add_export("public");
405        assert!(matches!(m.get_export("public"), Some(Value::Int(1))));
406        // private is bound but not exported.
407        assert!(matches!(m.get_export("private"), None));
408    }
409}