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/// Capability-**denying** loader: refuses every resolution and says so.
115///
116/// # Why this is not `NoLoader`
117///
118/// `NoLoader` also resolves nothing, but it is a *default*, not a *gate*, and
119/// the difference shows up in three places:
120///
121/// 1. **The error lies.** `NoLoader` reports `NotFound`, so a denied
122///    `(require "lib/auth")` is indistinguishable from a typo'd path. An
123///    operator reading `module not found: lib/auth` goes looking for the file.
124///    A denial has to name itself, or the gate is invisible in its own
125///    diagnostics.
126/// 2. **It carries no reason.** A `DenyingLoader` is constructed with the
127///    context that installed it, so the message says *which* gate refused and
128///    *why* — the thing a caller needs to decide whether to re-run outside the
129///    gate or fix the program.
130/// 3. **It is what you get by accident.** `NoLoader` is what an embedder that
131///    never thought about I/O ends up with. Selecting `DenyingLoader` is a
132///    statement that I/O was considered and refused.
133///
134/// # What it does and does not bound
135///
136/// It bounds exactly one capability: **module source resolution**. That is the
137/// whole of `tatara-lisp-eval`'s own reach outside its process — audited
138/// 2026-08-05, the crate's only `std::fs` / `std::env` / `std::process` call
139/// outside `#[cfg(test)]` is `FilesystemLoader::load`, and it is behind this
140/// trait. Two residues stay open inside the crate and are not I/O:
141/// `primitive.rs`'s `print` / `println` / `display` write to stdout, and
142/// `install_lisp_stdlib_with` **panics** (rather than returning) if the
143/// embedded stdlib fails to parse or evaluate.
144///
145/// It does **not** bound native functions an embedder registered on the
146/// interpreter. `tatara-lisp-script` installs 56 of them across `fs` (19),
147/// `kube` (7), `process` (6), `io` (6), `env` (5), `os` (5), `http` (3),
148/// `dns` (3), `http_server` (1) and `sops` (1) — files, environment, sockets
149/// and subprocesses, none of which consults a `Loader`. **A build-time entry
150/// point that wants a total denial must select this loader *and* decline to
151/// install those primitives**; selecting the loader alone is a partial gate,
152/// and calling it total would be a false claim.
153///
154/// # Ordering, because `fork` inherits the loader
155///
156/// `Interpreter::fork` clones the parent's `Arc<dyn Loader>`. Forking a
157/// filesystem-enabled interpreter therefore yields a filesystem-enabled child.
158/// Call `set_loader` on the child **after** forking, never before.
159///
160/// ```
161/// use std::sync::Arc;
162/// use tatara_lisp_eval::{DenyingLoader, Interpreter};
163///
164/// let mut interp: Interpreter<()> = Interpreter::new();
165/// interp.set_loader(Arc::new(DenyingLoader::new(
166///     "build-time macro expansion must not read the filesystem",
167/// )));
168/// ```
169#[derive(Debug, Clone)]
170pub struct DenyingLoader {
171    reason: Arc<str>,
172}
173
174impl DenyingLoader {
175    /// Reason used by [`DenyingLoader::default`]. Deliberately generic: a
176    /// caller that knows its own context should pass it to
177    /// [`DenyingLoader::new`] instead, so the denial names the gate.
178    pub const DEFAULT_REASON: &'static str =
179        "the embedder installed a capability-denying loader; no module source is reachable \
180         from this evaluation";
181
182    /// Build a denier whose refusals cite `reason`. Write the reason from the
183    /// operator's point of view — it is the whole diagnostic they get.
184    #[must_use]
185    pub fn new(reason: impl Into<Arc<str>>) -> Self {
186        Self {
187            reason: reason.into(),
188        }
189    }
190
191    /// The reason every refusal cites.
192    #[must_use]
193    pub fn reason(&self) -> &str {
194        &self.reason
195    }
196}
197
198impl Default for DenyingLoader {
199    fn default() -> Self {
200        Self::new(Self::DEFAULT_REASON)
201    }
202}
203
204impl Loader for DenyingLoader {
205    fn load(&self, path: &str) -> Result<String, ModuleError> {
206        Err(ModuleError::Denied {
207            path: path.to_string(),
208            reason: self.reason.to_string(),
209        })
210    }
211}
212
213/// Filesystem-backed loader. Reads a module path string by walking a
214/// base directory (or filesystem-absolute paths). Path-resolution rules
215/// match the documented design:
216///
217/// 1. `path` ending in `.tlisp` or `.lisp` is read as-is.
218/// 2. `path` without an extension tries `<path>.tlisp`, then
219///    `<path>.lisp`, then `<path>/init.tlisp`, then `<path>/init.lisp`.
220/// 3. Relative paths resolve against `base_dir`. Absolute paths are
221///    passed through. The optional `extra_search_paths` list (e.g.
222///    a `$TATARA_PATH`-equivalent) is consulted in order if the
223///    primary lookup fails.
224///
225/// The loader is `Send + Sync` so it can live behind the `Arc<dyn Loader>`
226/// the Interpreter expects.
227#[derive(Debug, Clone)]
228pub struct FilesystemLoader {
229    pub base_dir: std::path::PathBuf,
230    pub extra_search_paths: Vec<std::path::PathBuf>,
231}
232
233impl FilesystemLoader {
234    pub fn new(base_dir: impl Into<std::path::PathBuf>) -> Self {
235        Self {
236            base_dir: base_dir.into(),
237            extra_search_paths: Vec::new(),
238        }
239    }
240
241    pub fn with_search_paths(
242        mut self,
243        paths: impl IntoIterator<Item = std::path::PathBuf>,
244    ) -> Self {
245        self.extra_search_paths.extend(paths);
246        self
247    }
248
249    fn candidates(&self, path: &str) -> Vec<std::path::PathBuf> {
250        let p = std::path::Path::new(path);
251        let has_ext = p
252            .extension()
253            .is_some_and(|e| matches!(e.to_str(), Some("tlisp" | "lisp")));
254        let mut bases: Vec<std::path::PathBuf> = Vec::new();
255        if p.is_absolute() {
256            bases.push(p.to_path_buf());
257        } else {
258            bases.push(self.base_dir.join(p));
259            for extra in &self.extra_search_paths {
260                bases.push(extra.join(p));
261            }
262        }
263        let mut out = Vec::with_capacity(bases.len() * 4);
264        for base in bases {
265            if has_ext {
266                out.push(base);
267            } else {
268                out.push(base.with_extension("tlisp"));
269                out.push(base.with_extension("lisp"));
270                out.push(base.join("init.tlisp"));
271                out.push(base.join("init.lisp"));
272            }
273        }
274        out
275    }
276}
277
278impl Loader for FilesystemLoader {
279    fn load(&self, path: &str) -> Result<String, ModuleError> {
280        for candidate in self.candidates(path) {
281            if let Ok(s) = std::fs::read_to_string(&candidate) {
282                return Ok(s);
283            }
284        }
285        Err(ModuleError::NotFound(path.to_string()))
286    }
287}
288
289/// Errors specific to the module pipeline. Embedders convert these
290/// to user-facing `EvalError::User { value: Value::Error(...) }`.
291#[derive(Debug, Error, Clone)]
292pub enum ModuleError {
293    #[error("module not found: {0}")]
294    NotFound(String),
295    #[error("circular require: {path} (load stack: {stack})")]
296    Circular { path: String, stack: String },
297    #[error("name not exported: {1} from module {0}")]
298    NotExported(String, String),
299    /// A loader **refused** to resolve `path` as a matter of policy — the
300    /// source may well exist. Distinct from [`ModuleError::NotFound`] on
301    /// purpose: rounding a denial down to "not found" sends the reader
302    /// hunting for a missing file instead of showing them the gate.
303    #[error("module load denied: {path} — {reason}")]
304    Denied { path: String, reason: String },
305}
306
307/// Process-global module registry. Holds every module that's been
308/// loaded so far, keyed by path. Two `(require "lib/auth")` calls
309/// from different sites share one Module instance — the file is
310/// loaded + evaluated exactly once.
311#[derive(Debug, Default, Clone)]
312pub struct ModuleRegistry {
313    inner: Arc<Mutex<RegistryInner>>,
314}
315
316#[derive(Debug, Default)]
317pub(crate) struct RegistryInner {
318    pub(crate) modules: HashMap<Arc<str>, Module>,
319    /// Currently-loading paths (for cycle detection).
320    pub(crate) loading: Vec<String>,
321    /// Exports declared via `(provide ...)` inside a still-loading
322    /// module. Drained on `finish_load` and merged into the Module.
323    /// Keyed by module path; value is the set of names provided.
324    pub(crate) exports_staging: HashMap<String, HashSet<Arc<str>>>,
325}
326
327impl ModuleRegistry {
328    pub fn new() -> Self {
329        Self::default()
330    }
331
332    /// Has this path already been fully loaded?
333    pub fn has(&self, path: &str) -> bool {
334        let g = self.inner.lock().unwrap();
335        g.modules.contains_key(path)
336    }
337
338    /// Snapshot a loaded module. Returns `None` if not yet loaded.
339    pub fn get(&self, path: &str) -> Option<Module> {
340        let g = self.inner.lock().unwrap();
341        g.modules.get(path).cloned()
342    }
343
344    /// Begin loading `path`. Pushes onto the load stack and returns
345    /// `Err(Circular)` if the path is already on the stack.
346    pub fn begin_load(&self, path: &str) -> Result<(), ModuleError> {
347        let mut g = self.inner.lock().unwrap();
348        if g.loading.iter().any(|p| p == path) {
349            return Err(ModuleError::Circular {
350                path: path.to_string(),
351                stack: g.loading.join(" → "),
352            });
353        }
354        g.loading.push(path.to_string());
355        Ok(())
356    }
357
358    /// Finish loading `path` — remove from load stack, store final
359    /// module bindings.
360    pub fn finish_load(&self, module: Module) {
361        let mut g = self.inner.lock().unwrap();
362        g.loading.retain(|p| **p != *module.path);
363        g.modules.insert(module.path.clone(), module);
364    }
365
366    /// Abort a load (e.g., after an error during eval). Drops the
367    /// path from the load stack so retries can succeed.
368    pub fn abort_load(&self, path: &str) {
369        let mut g = self.inner.lock().unwrap();
370        g.loading.retain(|p| p != path);
371    }
372
373    /// Number of fully-loaded modules. Useful for tests + tooling.
374    pub fn len(&self) -> usize {
375        self.inner.lock().unwrap().modules.len()
376    }
377
378    pub fn is_empty(&self) -> bool {
379        self.len() == 0
380    }
381
382    /// Internal access to the lock — used by the eval loop to stage
383    /// exports during a module load.
384    pub(crate) fn inner_lock(&self) -> std::sync::MutexGuard<'_, RegistryInner> {
385        self.inner.lock().unwrap()
386    }
387}
388
389/// Split a qualified name `foo/bar` into `(module-alias, member)`.
390/// Returns `None` if there's no `/` separator (caller treats as a
391/// plain unqualified name).
392///
393/// Multi-segment aliases like `lib/auth/validate-token` resolve to
394/// alias = `lib/auth` and member = `validate-token` — i.e., the LAST
395/// `/` is the separator. This matches Clojure semantics where
396/// `lib.auth/validate-token` (using `.` for the alias and `/` for
397/// the boundary) splits at the FINAL `/`.
398pub fn split_qualified(name: &str) -> Option<(&str, &str)> {
399    let idx = name.rfind('/')?;
400    // A bare leading `/` (e.g. `/foo`) or trailing `/` (e.g. `foo/`)
401    // isn't a qualified name.
402    if idx == 0 || idx == name.len() - 1 {
403        return None;
404    }
405    Some((&name[..idx], &name[idx + 1..]))
406}
407
408#[cfg(test)]
409mod tests {
410    use super::*;
411
412    #[test]
413    fn split_qualified_works() {
414        assert_eq!(split_qualified("foo/bar"), Some(("foo", "bar")));
415        assert_eq!(
416            split_qualified("lib/auth/validate"),
417            Some(("lib/auth", "validate"))
418        );
419        assert_eq!(split_qualified("plain"), None);
420        assert_eq!(split_qualified("/leading"), None);
421        assert_eq!(split_qualified("trailing/"), None);
422    }
423
424    #[test]
425    fn map_loader_round_trips() {
426        let mut l = MapLoader::new();
427        l.insert("lib/auth", "(define x 42)");
428        assert_eq!(l.load("lib/auth").unwrap(), "(define x 42)");
429        assert!(matches!(l.load("missing"), Err(ModuleError::NotFound(_))));
430    }
431
432    #[test]
433    fn denying_loader_refuses_every_path_with_its_reason() {
434        let l = DenyingLoader::new("typecheck runs with no filesystem");
435        // Absolute, relative, extensioned, empty — the denier has no
436        // resolution rules to route around, which is the point.
437        for path in ["lib/auth", "/etc/passwd", "./x.tlisp", ""] {
438            match l.load(path) {
439                Err(ModuleError::Denied { path: p, reason }) => {
440                    assert_eq!(p, path, "the refusal names what was denied");
441                    assert_eq!(reason, "typecheck runs with no filesystem");
442                }
443                other => panic!("expected a denial for {path:?}, got {other:?}"),
444            }
445        }
446    }
447
448    #[test]
449    fn the_default_denier_still_carries_a_reason() {
450        // A `Default` that denied with an empty reason would produce
451        // "module load denied: x — " and teach the reader nothing.
452        let l = DenyingLoader::default();
453        assert_eq!(l.reason(), DenyingLoader::DEFAULT_REASON);
454        assert!(!DenyingLoader::DEFAULT_REASON.is_empty());
455        let rendered = l.load("anything").unwrap_err().to_string();
456        assert!(rendered.contains("denied"), "got {rendered:?}");
457        assert!(
458            rendered.contains(DenyingLoader::DEFAULT_REASON),
459            "got {rendered:?}"
460        );
461    }
462
463    #[test]
464    fn registry_cycle_detection() {
465        let r = ModuleRegistry::new();
466        r.begin_load("a").unwrap();
467        r.begin_load("b").unwrap();
468        let err = r.begin_load("a").unwrap_err();
469        assert!(matches!(err, ModuleError::Circular { .. }));
470    }
471
472    #[test]
473    fn registry_finish_load_makes_module_visible() {
474        let r = ModuleRegistry::new();
475        r.begin_load("foo").unwrap();
476        let mut m = Module::new("foo");
477        m.define("x", Value::Int(42));
478        m.add_export("x");
479        r.finish_load(m);
480        assert!(r.has("foo"));
481        let exported = r.get("foo").unwrap().get_export("x");
482        assert!(matches!(exported, Some(Value::Int(42))));
483    }
484
485    #[test]
486    fn registry_finish_load_removes_from_loading() {
487        let r = ModuleRegistry::new();
488        r.begin_load("foo").unwrap();
489        r.finish_load(Module::new("foo"));
490        // Re-loading the same path should now succeed (not cyclic).
491        r.begin_load("foo").unwrap();
492        r.abort_load("foo");
493    }
494
495    #[test]
496    fn filesystem_loader_resolves_with_extensions() {
497        use std::io::Write;
498        let dir = tempfile_dir();
499        // Drop a "lib/util.tlisp" file.
500        let lib = dir.join("lib");
501        std::fs::create_dir_all(&lib).unwrap();
502        let mut f = std::fs::File::create(lib.join("util.tlisp")).unwrap();
503        writeln!(f, "(define x 42)").unwrap();
504
505        let loader = FilesystemLoader::new(&dir);
506        // Bare name → tries `<base>/lib/util.tlisp`.
507        let src = loader.load("lib/util").unwrap();
508        assert!(src.contains("define x 42"));
509
510        // Explicit extension also works.
511        let src2 = loader.load("lib/util.tlisp").unwrap();
512        assert_eq!(src, src2);
513
514        // Missing path errors clearly.
515        assert!(matches!(
516            loader.load("missing/whatever"),
517            Err(ModuleError::NotFound(_))
518        ));
519
520        let _ = std::fs::remove_dir_all(&dir);
521    }
522
523    fn tempfile_dir() -> std::path::PathBuf {
524        use std::time::{SystemTime, UNIX_EPOCH};
525        let nanos = SystemTime::now()
526            .duration_since(UNIX_EPOCH)
527            .unwrap()
528            .as_nanos();
529        let mut tmp = std::env::temp_dir();
530        tmp.push(format!("tatara-loader-test-{nanos}"));
531        std::fs::create_dir_all(&tmp).unwrap();
532        tmp
533    }
534
535    #[test]
536    fn module_get_export_respects_export_set() {
537        let mut m = Module::new("test");
538        m.define("public", Value::Int(1));
539        m.define("private", Value::Int(2));
540        m.add_export("public");
541        assert!(matches!(m.get_export("public"), Some(Value::Int(1))));
542        // private is bound but not exported.
543        assert!(matches!(m.get_export("private"), None));
544    }
545}