Skip to main content

tatara_eval/
system.rs

1//! Opt-in system builtins for the interpreter.
2//!
3//! Kept separate from the pure `builtins` module so a `Terreiro` used as a
4//! sandbox stays I/O-free by default. `tatara-init --eval` and any other
5//! host-side embedder calls `Interpreter::new_with_system()` to enable
6//! these side-effecting primitives:
7//!
8//!   - `(print v)`         — write stringified v to stdout
9//!   - `(println v)`       — print + newline
10//!   - `(eprint v)`        — stderr
11//!   - `(eprintln v)`      — stderr + newline
12//!   - `(sleep seconds)`   — block for `seconds` (integer or float)
13//!   - `(exit code)`       — std::process::exit
14//!   - `(env name)`        — std::env::var
15//!   - `(env-or name def)` — env with fallback
16//!   - `(read-file path)`  — returns string
17//!   - `(write-file path content)` — returns nil
18//!   - `(shell cmd)`       — run `/bin/sh -c cmd`, return exit code as int
19//!   - `(forever body…)`   — loop the body forever (no natural exit; SIGINT)
20//!
21//! `forever` is a special form: it doesn't eagerly evaluate its arguments
22//! (otherwise the first invocation would wedge at eval time). Handled
23//! inside the Interpreter's list dispatch, not here.
24
25use std::collections::BTreeMap;
26use std::sync::Arc;
27
28use crate::error::{EvalError, Result};
29use crate::value::{Arity, Builtin, BuiltinFn, Value};
30
31pub fn system_builtin_table() -> BTreeMap<String, Value> {
32    let mut m: BTreeMap<String, Value> = BTreeMap::new();
33    for b in all_system_builtins() {
34        let name = b.name.clone();
35        m.insert(name, Value::Builtin(Arc::new(b)));
36    }
37    m
38}
39
40fn all_system_builtins() -> Vec<Builtin> {
41    vec![
42        mk("print", Arity::Exact(1), Arc::new(print_)),
43        mk("println", Arity::Exact(1), Arc::new(println_)),
44        mk("eprint", Arity::Exact(1), Arc::new(eprint_)),
45        mk("eprintln", Arity::Exact(1), Arc::new(eprintln_)),
46        mk("sleep", Arity::Exact(1), Arc::new(sleep_)),
47        mk("exit", Arity::Exact(1), Arc::new(exit_)),
48        mk("env", Arity::Exact(1), Arc::new(env_)),
49        mk("env-or", Arity::Exact(2), Arc::new(env_or_)),
50        mk("read-file", Arity::Exact(1), Arc::new(read_file_)),
51        mk("write-file", Arity::Exact(2), Arc::new(write_file_)),
52        mk("shell", Arity::Exact(1), Arc::new(shell_)),
53    ]
54}
55
56fn mk(name: &str, arity: Arity, f: Arc<BuiltinFn>) -> Builtin {
57    Builtin {
58        name: name.into(),
59        arity,
60        func: f,
61    }
62}
63
64fn to_display_string(v: &Value) -> String {
65    v.coerce_to_string().unwrap_or_else(|| format!("{v:?}"))
66}
67
68fn print_(args: &[Value]) -> Result<Value> {
69    print!("{}", to_display_string(&args[0]));
70    use std::io::Write;
71    let _ = std::io::stdout().flush();
72    Ok(Value::Nil)
73}
74
75fn println_(args: &[Value]) -> Result<Value> {
76    println!("{}", to_display_string(&args[0]));
77    Ok(Value::Nil)
78}
79
80fn eprint_(args: &[Value]) -> Result<Value> {
81    eprint!("{}", to_display_string(&args[0]));
82    use std::io::Write;
83    let _ = std::io::stderr().flush();
84    Ok(Value::Nil)
85}
86
87fn eprintln_(args: &[Value]) -> Result<Value> {
88    eprintln!("{}", to_display_string(&args[0]));
89    Ok(Value::Nil)
90}
91
92fn sleep_(args: &[Value]) -> Result<Value> {
93    let seconds = match &args[0] {
94        Value::Int(n) if *n >= 0 => *n as f64,
95        Value::Float(f) if *f >= 0.0 => *f,
96        v => {
97            return Err(EvalError::Type {
98                expected: "non-negative int or float (seconds)".into(),
99                found: v.type_name().into(),
100            })
101        }
102    };
103    let duration = std::time::Duration::from_secs_f64(seconds);
104    std::thread::sleep(duration);
105    Ok(Value::Nil)
106}
107
108fn exit_(args: &[Value]) -> Result<Value> {
109    let code = args[0].as_int().unwrap_or(0) as i32;
110    std::process::exit(code);
111}
112
113fn env_(args: &[Value]) -> Result<Value> {
114    let name = args[0].as_str().ok_or_else(|| EvalError::Type {
115        expected: "string".into(),
116        found: args[0].type_name().into(),
117    })?;
118    match std::env::var(name) {
119        Ok(v) => Ok(Value::Str(v)),
120        Err(_) => Ok(Value::Nil),
121    }
122}
123
124fn env_or_(args: &[Value]) -> Result<Value> {
125    let name = args[0].as_str().ok_or_else(|| EvalError::Type {
126        expected: "string (env var name)".into(),
127        found: args[0].type_name().into(),
128    })?;
129    let default = args[1].clone();
130    match std::env::var(name) {
131        Ok(v) => Ok(Value::Str(v)),
132        Err(_) => Ok(default),
133    }
134}
135
136fn read_file_(args: &[Value]) -> Result<Value> {
137    let path = args[0]
138        .as_path()
139        .map(|p| p.clone())
140        .or_else(|| args[0].as_str().map(std::path::PathBuf::from))
141        .ok_or_else(|| EvalError::Type {
142            expected: "string or path".into(),
143            found: args[0].type_name().into(),
144        })?;
145    let s = std::fs::read_to_string(&path)?;
146    Ok(Value::Str(s))
147}
148
149fn write_file_(args: &[Value]) -> Result<Value> {
150    let path = args[0]
151        .as_path()
152        .map(|p| p.clone())
153        .or_else(|| args[0].as_str().map(std::path::PathBuf::from))
154        .ok_or_else(|| EvalError::Type {
155            expected: "string or path (write-file first arg)".into(),
156            found: args[0].type_name().into(),
157        })?;
158    let content = args[1].coerce_to_string().ok_or_else(|| EvalError::Type {
159        expected: "string-coercible".into(),
160        found: args[1].type_name().into(),
161    })?;
162    if let Some(parent) = path.parent() {
163        std::fs::create_dir_all(parent)?;
164    }
165    std::fs::write(&path, content)?;
166    Ok(Value::Nil)
167}
168
169fn shell_(args: &[Value]) -> Result<Value> {
170    let cmd = args[0].as_str().ok_or_else(|| EvalError::Type {
171        expected: "string".into(),
172        found: args[0].type_name().into(),
173    })?;
174    let status = std::process::Command::new("/bin/sh")
175        .arg("-c")
176        .arg(cmd)
177        .status()?;
178    Ok(Value::Int(status.code().unwrap_or(-1) as i64))
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn env_returns_nil_for_missing() {
187        let r = env_(&[Value::Str(
188            "TATARA_THIS_VAR_DEFINITELY_DOES_NOT_EXIST_12345".into(),
189        )])
190        .unwrap();
191        assert!(matches!(r, Value::Nil));
192    }
193
194    #[test]
195    fn env_or_returns_default_for_missing() {
196        let r = env_or_(&[
197            Value::Str("TATARA_MISSING_VAR_54321".into()),
198            Value::Str("fallback".into()),
199        ])
200        .unwrap();
201        assert!(matches!(r, Value::Str(s) if s == "fallback"));
202    }
203
204    #[test]
205    fn read_file_and_write_file_round_trip() {
206        let td = tempfile::tempdir().unwrap();
207        let path = td.path().join("hi.txt");
208        write_file_(&[
209            Value::Str(path.to_string_lossy().into_owned()),
210            Value::Str("hello lisp".into()),
211        ])
212        .unwrap();
213        let r = read_file_(&[Value::Str(path.to_string_lossy().into_owned())]).unwrap();
214        assert!(matches!(r, Value::Str(s) if s == "hello lisp"));
215    }
216
217    #[test]
218    fn shell_returns_exit_code() {
219        let r = shell_(&[Value::Str("true".into())]).unwrap();
220        assert!(matches!(r, Value::Int(0)));
221        let r = shell_(&[Value::Str("false".into())]).unwrap();
222        assert!(matches!(r, Value::Int(n) if n != 0));
223    }
224
225    #[test]
226    fn sleep_rejects_negative_seconds() {
227        let r = sleep_(&[Value::Int(-5)]);
228        assert!(r.is_err());
229    }
230}