Skip to main content

tatara_lisp_script/stdlib/
string.rs

1//! String helpers beyond what tatara-lisp-eval::primitive provides.
2//!
3//!   (string-replace STR FROM TO)  → new string
4//!   (string-split STR SEP)        → list of strings
5//!   (string-contains? STR NEEDLE) → bool
6//!   (string-lowercase STR)        → new string
7//!   (string-format TMPL &rest VS) → printf-ish, `{}` placeholders consumed left-to-right
8//!   (string-trim STR)             → new string with leading/trailing whitespace removed
9//!   (string->number STR)          → Int when the text is an integer, else Float;
10//!                                   a typed error when it is neither
11
12use std::sync::Arc;
13
14use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
15
16use crate::script_ctx::ScriptCtx;
17use crate::stdlib::env::str_arg;
18
19pub fn install(interp: &mut Interpreter<ScriptCtx>) {
20    interp.register_fn(
21        "string-replace",
22        Arity::Exact(3),
23        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
24            let s = str_arg(&args[0], "string-replace", sp)?;
25            let from = str_arg(&args[1], "string-replace", sp)?;
26            let to = str_arg(&args[2], "string-replace", sp)?;
27            Ok(Value::Str(Arc::from(s.replace(&*from, &to))))
28        },
29    );
30
31    interp.register_fn(
32        "string-split",
33        Arity::Exact(2),
34        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
35            let s = str_arg(&args[0], "string-split", sp)?;
36            let sep = str_arg(&args[1], "string-split", sp)?;
37            Ok(Value::list(
38                s.split(&*sep)
39                    .map(|p| Value::Str(Arc::from(p)))
40                    .collect::<Vec<_>>(),
41            ))
42        },
43    );
44
45    interp.register_fn(
46        "string-contains?",
47        Arity::Exact(2),
48        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
49            let s = str_arg(&args[0], "string-contains?", sp)?;
50            let needle = str_arg(&args[1], "string-contains?", sp)?;
51            Ok(Value::Bool(s.contains(&*needle)))
52        },
53    );
54
55    interp.register_fn(
56        "string-lowercase",
57        Arity::Exact(1),
58        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
59            let s = str_arg(&args[0], "string-lowercase", sp)?;
60            Ok(Value::Str(Arc::from(s.to_lowercase())))
61        },
62    );
63
64    interp.register_fn(
65        "string-trim",
66        Arity::Exact(1),
67        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
68            let s = str_arg(&args[0], "string-trim", sp)?;
69            Ok(Value::Str(Arc::from(s.trim())))
70        },
71    );
72
73    // (string->number STR)
74    //
75    // The gap this closes: every value that arrives from outside a script —
76    // a captured stdout, a matched group, a JSON string field — is text, and
77    // without this there is no way to do arithmetic on it. Scripts were
78    // working around it by folding over `string-chars`, which handles neither
79    // a sign nor a decimal point, or by pushing the arithmetic out into a
80    // shell one-liner, which is the thing the stack law exists to prevent.
81    //
82    // Integer-first, deliberately: `"60"` must come back as Int so it can index,
83    // compare and add against other Ints without a silent float contaminating
84    // the result. Only text that is not a valid integer is tried as a float.
85    // Failure is a typed error, never a silent 0 — a 0 returned for "abc" is
86    // the same shape of defect as an unreachable cluster reported as an empty
87    // one.
88    interp.register_fn(
89        "string->number",
90        Arity::Exact(1),
91        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
92            let s = str_arg(&args[0], "string->number", sp)?;
93            let t = s.trim();
94            if let Ok(n) = t.parse::<i64>() {
95                return Ok(Value::Int(n));
96            }
97            if let Ok(f) = t.parse::<f64>() {
98                if f.is_finite() {
99                    return Ok(Value::Float(f));
100                }
101            }
102            Err(EvalError::native_fn(
103                "string->number",
104                format!("not a number: {t:?}"),
105                sp,
106            ))
107        },
108    );
109
110    interp.register_fn(
111        "string-format",
112        Arity::AtLeast(1),
113        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
114            let tmpl = str_arg(&args[0], "string-format", sp)?;
115            let mut out = String::with_capacity(tmpl.len());
116            let mut remaining = &*tmpl;
117            let mut idx = 1usize;
118            while let Some(pos) = remaining.find("{}") {
119                out.push_str(&remaining[..pos]);
120                let Some(arg) = args.get(idx) else {
121                    return Err(EvalError::native_fn(
122                        "string-format",
123                        format!("template has more {{}} than args ({idx})"),
124                        sp,
125                    ));
126                };
127                out.push_str(&display_value(arg));
128                remaining = &remaining[pos + 2..];
129                idx += 1;
130            }
131            out.push_str(remaining);
132            Ok(Value::Str(Arc::from(out)))
133        },
134    );
135}
136
137fn display_value(v: &Value) -> String {
138    match v {
139        Value::Nil => "nil".to_string(),
140        Value::Bool(b) => b.to_string(),
141        Value::Int(n) => n.to_string(),
142        Value::Float(n) => n.to_string(),
143        Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => s.as_ref().to_string(),
144        other => format!("{other:?}"),
145    }
146}