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}