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    interp.register_fn(
253        "tmp-dir",
254        Arity::Exact(0),
255        |_args: &[Value], _ctx: &mut ScriptCtx, sp| {
256            let base = std::env::temp_dir();
257            let now = std::time::SystemTime::now()
258                .duration_since(std::time::UNIX_EPOCH)
259                .map(|d| d.as_nanos())
260                .unwrap_or(0);
261            let dir = base.join(format!("tatara-script-{now:x}"));
262            std::fs::create_dir_all(&dir)
263                .map_err(|e| EvalError::native_fn("tmp-dir", e.to_string(), sp))?;
264            Ok(Value::Str(Arc::from(dir.to_string_lossy().into_owned())))
265        },
266    );
267
268    interp.register_fn(
269        "tmp-file",
270        Arity::Exact(0),
271        |_args: &[Value], _ctx: &mut ScriptCtx, sp| {
272            let base = std::env::temp_dir();
273            let now = std::time::SystemTime::now()
274                .duration_since(std::time::UNIX_EPOCH)
275                .map(|d| d.as_nanos())
276                .unwrap_or(0);
277            let path = base.join(format!("tatara-script-{now:x}.tmp"));
278            std::fs::write(&path, b"")
279                .map_err(|e| EvalError::native_fn("tmp-file", e.to_string(), sp))?;
280            Ok(Value::Str(Arc::from(path.to_string_lossy().into_owned())))
281        },
282    );
283}
284
285/// Walk a directory tree, collecting every file (not directories).
286fn walk_collect(root: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
287    let mut stack = vec![root.to_path_buf()];
288    while let Some(cur) = stack.pop() {
289        if cur.is_file() {
290            out.push(cur);
291            continue;
292        }
293        if !cur.is_dir() {
294            continue;
295        }
296        for entry in std::fs::read_dir(&cur)? {
297            let entry = entry?;
298            stack.push(entry.path());
299        }
300    }
301    Ok(())
302}
303
304/// Minimal glob engine supporting `*` (non-slash) and `**` (recursive).
305/// Patterns are absolute or relative; relative patterns resolve against
306/// the current directory.
307fn simple_glob(pattern: &str) -> Result<Vec<PathBuf>, String> {
308    let (prefix, remainder) = split_glob_prefix(pattern);
309    let base = if prefix.is_empty() {
310        PathBuf::from(".")
311    } else {
312        PathBuf::from(&prefix)
313    };
314    let parts: Vec<&str> = remainder.split('/').filter(|s| !s.is_empty()).collect();
315    if parts.is_empty() {
316        return Ok(vec![base]);
317    }
318    let mut out = Vec::new();
319    walk_glob(&base, &parts, 0, &mut out);
320    Ok(out)
321}
322
323fn split_glob_prefix(pattern: &str) -> (String, String) {
324    // Return (literal_prefix, glob_tail). Literal prefix is the path up
325    // to the first component containing `*`.
326    let mut literal = String::new();
327    let mut rest = String::new();
328    let mut found_glob = false;
329    for (i, component) in pattern.split('/').enumerate() {
330        if !found_glob && !component.contains('*') {
331            if i > 0 && !literal.is_empty() {
332                literal.push('/');
333            }
334            literal.push_str(component);
335        } else {
336            found_glob = true;
337            if !rest.is_empty() {
338                rest.push('/');
339            }
340            rest.push_str(component);
341        }
342    }
343    if literal.is_empty() && !found_glob {
344        literal = pattern.to_string();
345    }
346    (literal, rest)
347}
348
349fn walk_glob(dir: &Path, parts: &[&str], idx: usize, out: &mut Vec<PathBuf>) {
350    if idx >= parts.len() {
351        out.push(dir.to_path_buf());
352        return;
353    }
354    let pat = parts[idx];
355    let entries = match std::fs::read_dir(dir) {
356        Ok(e) => e,
357        Err(_) => return,
358    };
359    if pat == "**" {
360        // Match zero or more directory levels.
361        walk_glob(dir, parts, idx + 1, out);
362        for entry in entries.flatten() {
363            let p = entry.path();
364            if p.is_dir() {
365                walk_glob(&p, parts, idx, out);
366            }
367        }
368        return;
369    }
370    for entry in entries.flatten() {
371        let name = entry.file_name();
372        let name_s = name.to_string_lossy();
373        if glob_match(pat, &name_s) {
374            let p = entry.path();
375            if idx + 1 == parts.len() {
376                out.push(p);
377            } else if p.is_dir() {
378                walk_glob(&p, parts, idx + 1, out);
379            }
380        }
381    }
382}
383
384fn glob_match(pattern: &str, name: &str) -> bool {
385    // Simple `*` semantics: matches any sequence of characters except /.
386    let mut pi = 0;
387    let mut ni = 0;
388    let pbytes = pattern.as_bytes();
389    let nbytes = name.as_bytes();
390    let mut star: Option<(usize, usize)> = None;
391    while ni < nbytes.len() {
392        if pi < pbytes.len() && (pbytes[pi] == b'?' || pbytes[pi] == nbytes[ni]) {
393            pi += 1;
394            ni += 1;
395        } else if pi < pbytes.len() && pbytes[pi] == b'*' {
396            star = Some((pi, ni));
397            pi += 1;
398        } else if let Some((sp, sn)) = star {
399            pi = sp + 1;
400            ni = sn + 1;
401            star = Some((sp, ni));
402        } else {
403            return false;
404        }
405    }
406    while pi < pbytes.len() && pbytes[pi] == b'*' {
407        pi += 1;
408    }
409    pi == pbytes.len()
410}