Skip to main content

tatara_lisp_script/stdlib/
fs.rs

1//! Filesystem primitives — directories, globs, metadata, temp files.
2//!
3//!   (glob PATTERN)              → list of paths matching (e.g. "src/**/*.rs")
4//!   (walk-dir PATH)             → flat list of every file under PATH
5//!   (ls PATH)                   → list of entries (files + dirs) directly inside
6//!   (mkdir PATH)                → nil (fails silently if exists)
7//!   (mkdir-p PATH)              → nil; creates all intermediate dirs
8//!   (rm PATH)                   → nil; deletes file
9//!   (rm-rf PATH)                → nil; deletes recursively
10//!   (cwd)                       → current working directory
11//!   (chdir PATH)                → nil; changes cwd
12//!   (path-join A B …)           → joined path string
13//!   (path-basename PATH)        → last path component
14//!   (path-dirname PATH)         → parent dir
15//!   (path-extension PATH)       → extension without leading dot
16//!   (path-absolute PATH)        → absolute canonical path
17//!   (file-size PATH)            → bytes
18//!   (is-dir? PATH)              → bool
19//!   (is-file? PATH)             → bool
20//!   (tmp-dir)                   → fresh temp dir (auto-cleaned by OS)
21//!   (tmp-file)                  → fresh temp file path
22
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25
26use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
27
28use crate::script_ctx::ScriptCtx;
29use crate::stdlib::env::str_arg;
30
31pub fn install(interp: &mut Interpreter<ScriptCtx>) {
32    // ── glob / walk ──────────────────────────────────────────────
33    interp.register_fn(
34        "glob",
35        Arity::Exact(1),
36        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
37            let pattern = str_arg(&args[0], "glob", sp)?;
38            let entries = simple_glob(&pattern).map_err(|e| EvalError::native_fn("glob", e, sp))?;
39            Ok(Value::list(
40                entries
41                    .into_iter()
42                    .map(|p| Value::Str(Arc::from(p.to_string_lossy().into_owned())))
43                    .collect::<Vec<_>>(),
44            ))
45        },
46    );
47
48    interp.register_fn(
49        "walk-dir",
50        Arity::Exact(1),
51        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
52            let root = str_arg(&args[0], "walk-dir", sp)?;
53            let mut out = Vec::new();
54            walk_collect(Path::new(&*root), &mut out)
55                .map_err(|e| EvalError::native_fn("walk-dir", e.to_string(), sp))?;
56            Ok(Value::list(
57                out.into_iter()
58                    .map(|p| Value::Str(Arc::from(p.to_string_lossy().into_owned())))
59                    .collect::<Vec<_>>(),
60            ))
61        },
62    );
63
64    interp.register_fn(
65        "ls",
66        Arity::Exact(1),
67        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
68            let dir = str_arg(&args[0], "ls", sp)?;
69            let mut entries: Vec<PathBuf> = std::fs::read_dir(&*dir)
70                .map_err(|e| EvalError::native_fn("ls", format!("{dir}: {e}"), sp))?
71                .filter_map(|r| r.ok().map(|e| e.path()))
72                .collect();
73            entries.sort();
74            Ok(Value::list(
75                entries
76                    .into_iter()
77                    .map(|p| Value::Str(Arc::from(p.to_string_lossy().into_owned())))
78                    .collect::<Vec<_>>(),
79            ))
80        },
81    );
82
83    // ── create / delete ──────────────────────────────────────────
84    interp.register_fn(
85        "mkdir",
86        Arity::Exact(1),
87        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
88            let path = str_arg(&args[0], "mkdir", sp)?;
89            match std::fs::create_dir(&*path) {
90                Ok(()) => Ok(Value::Nil),
91                Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => Ok(Value::Nil),
92                Err(e) => Err(EvalError::native_fn("mkdir", format!("{path}: {e}"), sp)),
93            }
94        },
95    );
96
97    interp.register_fn(
98        "mkdir-p",
99        Arity::Exact(1),
100        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
101            let path = str_arg(&args[0], "mkdir-p", sp)?;
102            std::fs::create_dir_all(&*path)
103                .map_err(|e| EvalError::native_fn("mkdir-p", format!("{path}: {e}"), sp))?;
104            Ok(Value::Nil)
105        },
106    );
107
108    interp.register_fn(
109        "rm",
110        Arity::Exact(1),
111        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
112            let path = str_arg(&args[0], "rm", sp)?;
113            std::fs::remove_file(&*path)
114                .map_err(|e| EvalError::native_fn("rm", format!("{path}: {e}"), sp))?;
115            Ok(Value::Nil)
116        },
117    );
118
119    interp.register_fn(
120        "rm-rf",
121        Arity::Exact(1),
122        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
123            let path = str_arg(&args[0], "rm-rf", sp)?;
124            if Path::new(&*path).is_dir() {
125                std::fs::remove_dir_all(&*path)
126                    .map_err(|e| EvalError::native_fn("rm-rf", format!("{path}: {e}"), sp))?;
127            } else if Path::new(&*path).exists() {
128                std::fs::remove_file(&*path)
129                    .map_err(|e| EvalError::native_fn("rm-rf", format!("{path}: {e}"), sp))?;
130            }
131            Ok(Value::Nil)
132        },
133    );
134
135    // ── cwd / chdir ──────────────────────────────────────────────
136    interp.register_fn(
137        "cwd",
138        Arity::Exact(0),
139        |_args: &[Value], _ctx: &mut ScriptCtx, sp| {
140            let d = std::env::current_dir()
141                .map_err(|e| EvalError::native_fn("cwd", e.to_string(), sp))?;
142            Ok(Value::Str(Arc::from(d.to_string_lossy().into_owned())))
143        },
144    );
145
146    interp.register_fn(
147        "chdir",
148        Arity::Exact(1),
149        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
150            let path = str_arg(&args[0], "chdir", sp)?;
151            std::env::set_current_dir(&*path)
152                .map_err(|e| EvalError::native_fn("chdir", format!("{path}: {e}"), sp))?;
153            Ok(Value::Nil)
154        },
155    );
156
157    // ── path ops ─────────────────────────────────────────────────
158    interp.register_fn(
159        "path-join",
160        Arity::AtLeast(1),
161        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
162            let mut buf = PathBuf::new();
163            for v in args {
164                let s = str_arg(v, "path-join", sp)?;
165                buf.push(&*s);
166            }
167            Ok(Value::Str(Arc::from(buf.to_string_lossy().into_owned())))
168        },
169    );
170
171    interp.register_fn(
172        "path-basename",
173        Arity::Exact(1),
174        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
175            let p = str_arg(&args[0], "path-basename", sp)?;
176            let base = Path::new(&*p)
177                .file_name()
178                .map(|s| s.to_string_lossy().into_owned())
179                .unwrap_or_default();
180            Ok(Value::Str(Arc::from(base)))
181        },
182    );
183
184    interp.register_fn(
185        "path-dirname",
186        Arity::Exact(1),
187        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
188            let p = str_arg(&args[0], "path-dirname", sp)?;
189            let dir = Path::new(&*p)
190                .parent()
191                .map(|s| s.to_string_lossy().into_owned())
192                .unwrap_or_default();
193            Ok(Value::Str(Arc::from(dir)))
194        },
195    );
196
197    interp.register_fn(
198        "path-extension",
199        Arity::Exact(1),
200        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
201            let p = str_arg(&args[0], "path-extension", sp)?;
202            let ext = Path::new(&*p)
203                .extension()
204                .map(|s| s.to_string_lossy().into_owned())
205                .unwrap_or_default();
206            Ok(Value::Str(Arc::from(ext)))
207        },
208    );
209
210    interp.register_fn(
211        "path-absolute",
212        Arity::Exact(1),
213        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
214            let p = str_arg(&args[0], "path-absolute", sp)?;
215            let abs = std::fs::canonicalize(&*p)
216                .map_err(|e| EvalError::native_fn("path-absolute", format!("{p}: {e}"), sp))?;
217            Ok(Value::Str(Arc::from(abs.to_string_lossy().into_owned())))
218        },
219    );
220
221    // ── metadata ─────────────────────────────────────────────────
222    interp.register_fn(
223        "file-size",
224        Arity::Exact(1),
225        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
226            let p = str_arg(&args[0], "file-size", sp)?;
227            let meta = std::fs::metadata(&*p)
228                .map_err(|e| EvalError::native_fn("file-size", format!("{p}: {e}"), sp))?;
229            Ok(Value::Int(meta.len() as i64))
230        },
231    );
232
233    interp.register_fn(
234        "is-dir?",
235        Arity::Exact(1),
236        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
237            let p = str_arg(&args[0], "is-dir?", sp)?;
238            Ok(Value::Bool(Path::new(&*p).is_dir()))
239        },
240    );
241
242    interp.register_fn(
243        "is-file?",
244        Arity::Exact(1),
245        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
246            let p = str_arg(&args[0], "is-file?", sp)?;
247            Ok(Value::Bool(Path::new(&*p).is_file()))
248        },
249    );
250
251    // ── temp ─────────────────────────────────────────────────────
252    // Both mints go through the context's ScratchRegistry, which OWNS the path
253    // and removes it when the interpreter drops.
254    //
255    // These used to build the path inline and return a bare string, so nothing
256    // owned it and nothing ever removed it. Measured on rio 2026-07-31:
257    // 21,608 leaked dirs, 13 GB — into a 48 GiB tmpfs on a 29 GiB host, i.e.
258    // into RAM. That filled memory and all 31.9 GiB of swap, drove PSI
259    // memory.full to 92%, and left the OOM killer as the only reclaim path
260    // (it killed comin's git mid-deploy). ~1,250 dirs/hour, every hour since
261    // boot.
262    //
263    // The registry is private to ScriptCtx and these are the only callers, so
264    // a leaking mint no longer has a code path — cleanup is what you get by
265    // doing nothing, instead of something a script had to remember on every
266    // exit path including error.
267    interp.register_fn(
268        "tmp-dir",
269        Arity::Exact(0),
270        |_args: &[Value], ctx: &mut ScriptCtx, sp| {
271            let dir = ctx
272                .scratch_dir()
273                .map_err(|e| EvalError::native_fn("tmp-dir", e.to_string(), sp))?;
274            Ok(Value::Str(Arc::from(dir.to_string_lossy().into_owned())))
275        },
276    );
277
278    interp.register_fn(
279        "tmp-file",
280        Arity::Exact(0),
281        |_args: &[Value], ctx: &mut ScriptCtx, sp| {
282            let path = ctx
283                .scratch_file()
284                .map_err(|e| EvalError::native_fn("tmp-file", e.to_string(), sp))?;
285            Ok(Value::Str(Arc::from(path.to_string_lossy().into_owned())))
286        },
287    );
288}
289
290/// Walk a directory tree, collecting every file (not directories).
291fn walk_collect(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
292    let mut stack = vec![root.to_path_buf()];
293    while let Some(cur) = stack.pop() {
294        if cur.is_file() {
295            out.push(cur);
296            continue;
297        }
298        if !cur.is_dir() {
299            continue;
300        }
301        for entry in std::fs::read_dir(&cur)? {
302            let entry = entry?;
303            stack.push(entry.path());
304        }
305    }
306    Ok(())
307}
308
309/// Minimal glob engine supporting `*` (non-slash) and `**` (recursive).
310/// Patterns are absolute or relative; relative patterns resolve against
311/// the current directory.
312fn simple_glob(pattern: &str) -> Result<Vec<PathBuf>, String> {
313    let (prefix, remainder) = split_glob_prefix(pattern);
314    let base = if prefix.is_empty() {
315        PathBuf::from(".")
316    } else {
317        PathBuf::from(&prefix)
318    };
319    let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
320    if parts.is_empty() {
321        return Ok(vec![base]);
322    }
323    let mut out = Vec::new();
324    walk_glob(&base, &parts, 0, &mut out);
325    Ok(out)
326}
327
328fn split_glob_prefix(pattern: &str) -> (String, String) {
329    // Return (literal_prefix, glob_tail). Literal prefix is the path up
330    // to the first component containing `*`.
331    let mut literal = String::new();
332    let mut rest = String::new();
333    let mut found_glob = false;
334    for (i, component) in pattern.split('/').enumerate() {
335        if !found_glob && !component.contains('*') {
336            if i > 0 && !literal.is_empty() {
337                literal.push('/');
338            }
339            literal.push_str(component);
340        } else {
341            found_glob = true;
342            if !rest.is_empty() {
343                rest.push('/');
344            }
345            rest.push_str(component);
346        }
347    }
348    if literal.is_empty() && !found_glob {
349        literal = pattern.to_string();
350    }
351    (literal, rest)
352}
353
354fn walk_glob(dir: &Path, parts: &[&str], idx: usize, out: &mut Vec<PathBuf>) {
355    if idx >= parts.len() {
356        out.push(dir.to_path_buf());
357        return;
358    }
359    let pat = parts[idx];
360    let entries = match std::fs::read_dir(dir) {
361        Ok(e) => e,
362        Err(_) => return,
363    };
364    if pat == "**" {
365        // Match zero or more directory levels.
366        walk_glob(dir, parts, idx + 1, out);
367        for entry in entries.flatten() {
368            let p = entry.path();
369            if p.is_dir() {
370                walk_glob(&p, parts, idx, out);
371            }
372        }
373        return;
374    }
375    for entry in entries.flatten() {
376        let name = entry.file_name();
377        let name_s = name.to_string_lossy();
378        if glob_match(pat, &name_s) {
379            let p = entry.path();
380            if idx + 1 == parts.len() {
381                out.push(p);
382            } else if p.is_dir() {
383                walk_glob(&p, parts, idx + 1, out);
384            }
385        }
386    }
387}
388
389fn glob_match(pattern: &str, name: &str) -> bool {
390    // Simple `*` semantics: matches any sequence of characters except /.
391    let mut pi = 0;
392    let mut ni = 0;
393    let pbytes = pattern.as_bytes();
394    let nbytes = name.as_bytes();
395    let mut star: Option<(usize, usize)> = None;
396    while ni < nbytes.len() {
397        if pi < pbytes.len() && (pbytes[pi] == b'?' || pbytes[pi] == nbytes[ni]) {
398            pi += 1;
399            ni += 1;
400        } else if pi < pbytes.len() && pbytes[pi] == b'*' {
401            star = Some((pi, ni));
402            pi += 1;
403        } else if let Some((sp, sn)) = star {
404            pi = sp + 1;
405            ni = sn + 1;
406            star = Some((sp, ni));
407        } else {
408            return false;
409        }
410    }
411    while pi < pbytes.len() && pbytes[pi] == b'*' {
412        pi += 1;
413    }
414    pi == pbytes.len()
415}