Skip to main content

blue_lang_runtime/
stdlib.rs

1//! blue's string and number core.
2//!
3//! tatara-lisp ships arithmetic, comparison, list and trig primitives and
4//! **no string operations at all** — no length, no concatenation, no case
5//! conversion, no number parsing. For a language whose surface is Ruby's, that
6//! is the largest parity gap there is: `String` is the most-used type in Ruby
7//! by a wide margin.
8//!
9//! # Why these live in blue and not in tatara-lisp
10//!
11//! The fleet rule is to extend the substrate rather than re-implement, and
12//! generic helpers belong upstream. These are not generic: the *semantics* are
13//! blue's, and they are Ruby's semantics specifically.
14//!
15//! The clearest case is `length`. Ruby's `String#length` counts **characters**;
16//! Rust's `str::len` counts **bytes**; Elixir's `String.length/1` counts
17//! grapheme clusters. Three languages, three answers, all defensible. Blue owes
18//! its users Ruby's answer, and encoding that choice into tatara-lisp would push
19//! one language's convention onto every other consumer of the substrate.
20//!
21//! Promoting a genuinely encoding-neutral core upstream later stays open; the
22//! character-counting ones are blue's by right.
23//!
24//! # Character, not byte, not grapheme
25//!
26//! Every index and length here is in **Unicode scalar values** (Rust `char`).
27//! That matches Ruby for the overwhelming majority of text and is stated rather
28//! than left to be discovered — a `length` that silently returns bytes is the
29//! bug that only appears once a user types a non-ASCII character.
30//!
31//! Grapheme clusters (Elixir's choice) would need a segmentation table; where
32//! the two differ — a family emoji, a combining accent — blue reports scalar
33//! values. `a_combining_sequence_counts_scalars_not_graphemes` pins it.
34
35use tatara_lisp_eval::ffi::Arity;
36use tatara_lisp_eval::{EvalError, Interpreter, Value};
37
38const TRIM_OPTIONS: &[&str] = &["side", "chars"];
39
40fn trim_with(text: &str, opts: &Value, s: tatara_lisp::Span) -> Result<Value, EvalError> {
41    let bad = |message: String| crate::errors::raise_kind("bad_option", message, s);
42    let mut side = "both".to_string();
43    let mut chars: Option<Vec<char>> = None;
44    match opts {
45        Value::Nil => {}
46        Value::Map(m) => {
47            for (k, v) in m.iter() {
48                let key = match k {
49                    tatara_lisp_eval::MapKey::Keyword(n)
50                    | tatara_lisp_eval::MapKey::Symbol(n)
51                    | tatara_lisp_eval::MapKey::Str(n) => n.to_string(),
52                    _ => String::new(),
53                };
54                match (key.as_str(), v) {
55                    ("side", Value::Keyword(k)) if matches!(&**k, "both" | "start" | "end") => {
56                        side = k.to_string();
57                    }
58                    ("side", _) => return Err(bad("side must be :both, :start or :end".into())),
59                    ("chars", Value::Str(c)) => chars = Some(c.chars().collect()),
60                    ("chars", _) => return Err(bad("chars must be text".into())),
61                    _ => {
62                        return Err(bad(format!(
63                            "unknown option {key}; this verb takes {}",
64                            TRIM_OPTIONS.join(", ")
65                        )))
66                    }
67                }
68            }
69        }
70        other => {
71            return Err(bad(format!(
72                "options must be a map, got {}",
73                other.type_name()
74            )))
75        }
76    }
77    let out = match (&chars, side.as_str()) {
78        (None, "both") => text.trim(),
79        (None, "start") => text.trim_start(),
80        (None, _) => text.trim_end(),
81        (Some(set), "both") => text.trim_matches(|c| set.contains(&c)),
82        (Some(set), "start") => text.trim_start_matches(|c| set.contains(&c)),
83        (Some(set), _) => text.trim_end_matches(|c| set.contains(&c)),
84    };
85    Ok(Value::Str(out.into()))
86}
87
88fn as_str(v: &Value, span: tatara_lisp::Span) -> Result<String, EvalError> {
89    match v {
90        Value::Str(s) => Ok(s.to_string()),
91        // A symbol is text the author wrote; accepting it makes `upcase(:ok)`
92        // work the way a Ruby programmer expects of a symbol.
93        Value::Symbol(s) | Value::Keyword(s) => Ok(s.to_string()),
94        other => Err(EvalError::type_mismatch(
95            "a string",
96            other.type_name(),
97            span,
98        )),
99    }
100}
101
102/// blue's one equality — what `==`, `!=`, `contains`, `index_of`, `count_of`
103/// and `distinct` all mean (okite D0001–D0003).
104///
105/// Ruby's answer: numbers compare by value across Int and Float (`4 == 4.0`),
106/// strings, symbols and keywords by text (each only with its own kind), lists
107/// element by element, maps by their entries whatever order they were built in,
108/// nil only with nil (not with `[]`, D0004), functions by identity. Until
109/// 2026-09-27 `==` was tatara's Scheme `equal?`, under which `4 == 4.0` was
110/// false and two equal maps were unequal unless they were the same object.
111/// tatara keeps Scheme's `equal?` for its own programs; blue redefines the
112/// name in its interpreter, and tatara's own `member?`, `position` and
113/// `distinct` call `equal?` by name, so they follow.
114pub fn blue_equal(a: &Value, b: &Value) -> bool {
115    match (a, b) {
116        (Value::Nil, Value::Nil) => true,
117        (Value::Bool(x), Value::Bool(y)) => x == y,
118        (Value::Int(x), Value::Int(y)) => x == y,
119        (Value::Float(x), Value::Float(y)) => x == y,
120        (Value::Int(x), Value::Float(y)) | (Value::Float(y), Value::Int(x)) => (*x as f64) == *y,
121        (Value::Str(x), Value::Str(y))
122        | (Value::Symbol(x), Value::Symbol(y))
123        | (Value::Keyword(x), Value::Keyword(y)) => x == y,
124        (Value::List(x), Value::List(y)) => {
125            x.len() == y.len() && x.iter().zip(y.iter()).all(|(p, q)| blue_equal(p, q))
126        }
127        (Value::Map(x), Value::Map(y)) => {
128            x.len() == y.len()
129                && x.iter()
130                    .all(|(k, v)| y.get(k).is_some_and(|w| blue_equal(v, w)))
131        }
132        (Value::Closure(x), Value::Closure(y)) => std::sync::Arc::ptr_eq(x, y),
133        (Value::NativeFn(x), Value::NativeFn(y)) => x.name == y.name,
134        (Value::Sexp(x, _), Value::Sexp(y, _)) => x == y,
135        _ => false,
136    }
137}
138
139/// `to_s` (okite D0005, D0006): text for a person. A string, symbol or keyword
140/// is its own text and nil is empty, as in Ruby; every other value is its blue
141/// literal. Until 2026-09-27 a map rendered as the word `map`, a list lost its
142/// brackets (`[1, {a: 2}]` came out `1 map`) and `1.0` came out `1`.
143fn chain_rules_value() -> Value {
144    use blue_lang_check::chains::{cases, Case, Kind, RULES};
145    let sym = |s: &str| Value::Symbol(std::sync::Arc::from(s));
146    let text = |s: &str| Value::Str(std::sync::Arc::from(s));
147    let hole = |n: usize| (0..n).map(|_| sym("x")).collect::<Vec<_>>();
148    let lambda = |body: Value| Value::list([sym("lambda"), Value::list([sym("x")]), body]);
149    let call = |name: &str, args: Vec<Value>| Value::list(std::iter::once(sym(name)).chain(args));
150    Value::list(RULES.iter().map(|rule| {
151        let rows = cases(rule).into_iter().map(|case| match case {
152            Case::Call { inner, argc } => {
153                let inner_call = call(inner, hole(argc));
154                Value::list([
155                    text(&format!("{}({}(x))", rule.outer, inner)),
156                    Value::Nil,
157                    lambda(call(rule.outer, vec![inner_call.clone()])),
158                    lambda(inner_call),
159                ])
160            }
161            Case::Literal(kind) => Value::list([
162                text(&format!("{}(<literal>)", rule.outer)),
163                Value::Keyword(std::sync::Arc::from(match kind {
164                    Kind::Text => "text",
165                    Kind::Int => "int",
166                    Kind::Float => "float",
167                })),
168                lambda(call(rule.outer, vec![sym("x")])),
169                lambda(sym("x")),
170            ]),
171        });
172        Value::list([
173            text(rule.id),
174            text(rule.slug),
175            text(rule.outer),
176            text(rule.soundness),
177            Value::list(rows),
178        ])
179    }))
180}
181
182fn render(v: &Value) -> String {
183    match v {
184        Value::Nil => String::new(),
185        Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => s.to_string(),
186        Value::Error(e) => crate::errors::describe(e),
187        other => literal(other),
188    }
189}
190
191/// A value as blue source writes it: `[1, "a", nil]`, `{a: 1, b: 2.0}`.
192/// Map entries are sorted by key, since a map has no order of its own and the
193/// text must be the same for equal maps (D0006).
194fn literal(v: &Value) -> String {
195    match v {
196        Value::Nil => "nil".into(),
197        Value::Bool(b) => b.to_string(),
198        Value::Int(n) => n.to_string(),
199        Value::Float(x) => float_text(*x),
200        Value::Str(s) => quoted(s),
201        Value::Symbol(s) => s.to_string(),
202        Value::Keyword(s) => format!(":{s}"),
203        Value::List(items) => {
204            let parts: Vec<String> = items.iter().map(literal).collect();
205            format!("[{}]", parts.join(", "))
206        }
207        Value::Map(m) => {
208            let mut entries: Vec<String> = m
209                .iter()
210                .map(|(k, v)| format!("{} {}", key_text(k), literal(v)))
211                .collect();
212            entries.sort();
213            format!("{{{}}}", entries.join(", "))
214        }
215        other => other.type_name().to_string(),
216    }
217}
218
219/// A map key as it is written before its value: `a:` for a keyword or symbol,
220/// `"a" =>` / `1 =>` for anything else.
221fn key_text(k: &tatara_lisp_eval::value::MapKey) -> String {
222    use tatara_lisp_eval::value::MapKey;
223    match k {
224        MapKey::Keyword(s) | MapKey::Symbol(s) => format!("{s}:"),
225        MapKey::Str(s) => format!("{} =>", quoted(s)),
226        MapKey::Int(n) => format!("{n} =>"),
227        MapKey::Float(bits) => format!("{} =>", float_text(f64::from_bits(*bits))),
228        MapKey::Bool(b) => format!("{b} =>"),
229        MapKey::Nil => "nil =>".into(),
230    }
231}
232
233/// A float keeps its point, as in Ruby: `1.0`, `2.5`.
234fn float_text(x: f64) -> String {
235    if x.is_finite() && x.fract() == 0.0 && x.abs() < 1e16 {
236        format!("{x:.1}")
237    } else {
238        format!("{x}")
239    }
240}
241
242fn quoted(s: &str) -> String {
243    let mut out = String::with_capacity(s.len() + 2);
244    out.push('"');
245    for c in s.chars() {
246        match c {
247            '"' => out.push_str("\\\""),
248            '\\' => out.push_str("\\\\"),
249            '\n' => out.push_str("\\n"),
250            '\t' => out.push_str("\\t"),
251            c => out.push(c),
252        }
253    }
254    out.push('"');
255    out
256}
257
258fn list(items: Vec<Value>) -> Value {
259    Value::list(items)
260}
261
262/// Install blue's string and number core.
263pub fn install_blue_stdlib<H: 'static>(interp: &mut Interpreter<H>) {
264    crate::errors::install(interp);
265    crate::paths::install(interp);
266    crate::spread::install(interp);
267    crate::collection::install(interp);
268    // ── text ──────────────────────────────────────────────────────────
269
270    // `length` counts CHARACTERS, per Ruby. Also accepts a list, where it is
271    // the element count — Ruby's `Array#length`.
272    interp.register_fn(
273        "length",
274        Arity::Exact(1),
275        |a: &[Value], _h: &mut H, span| match &a[0] {
276            Value::List(items) => Ok(Value::Int(items.len() as i64)),
277            other => Ok(Value::Int(as_str(other, span)?.chars().count() as i64)),
278        },
279    );
280
281    // The documented builtins, for a program that reasons about names (the
282    // catalogue's "shares a builtin's name" column).
283    interp.register_fn(
284        "builtin_names",
285        Arity::Exact(0),
286        |_a: &[Value], _h: &mut H, _s| {
287            Ok(Value::list(
288                crate::docs::NAMES
289                    .iter()
290                    .map(|n| Value::Str(std::sync::Arc::from(n.name))),
291            ))
292        },
293    );
294    interp.register_fn(
295        "chain_rules",
296        Arity::Exact(0),
297        |_a: &[Value], _h: &mut H, _s| Ok(chain_rules_value()),
298    );
299    interp.register_fn("to_s", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
300        Ok(Value::Str(render(&a[0]).into()))
301    });
302
303    interp.register_fn("upcase", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
304        Ok(Value::Str(as_str(&a[0], s)?.to_uppercase().into()))
305    });
306
307    interp.register_fn("downcase", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
308        Ok(Value::Str(as_str(&a[0], s)?.to_lowercase().into()))
309    });
310
311    interp.register_fn("trim", Arity::Range(1, 2), |a: &[Value], _h: &mut H, s| {
312        let text = as_str(&a[0], s)?;
313        trim_with(&text, a.get(1).unwrap_or(&Value::Nil), s)
314    });
315
316    interp.register_fn(
317        blue_lang_syntax::LOWERED_OPTIONAL_ARITY,
318        Arity::Exact(4),
319        |a: &[Value], _h: &mut H, s| {
320            let name = as_str(&a[0], s)?;
321            let (Value::List(rest), Value::Int(required), Value::Int(most)) = (&a[1], &a[2], &a[3])
322            else {
323                return Err(EvalError::native_fn(
324                    blue_lang_syntax::LOWERED_OPTIONAL_ARITY,
325                    "expects a name, the rest list and two counts",
326                    s,
327                ));
328            };
329            if rest.len() as i64 > *most {
330                let want = Arity::Range(*required as usize, (*required + *most) as usize);
331                return Err(EvalError::native_fn(
332                    name.as_str(),
333                    format!(
334                        "takes {}, got {}",
335                        crate::messages::arguments(&want),
336                        *required as usize + rest.len()
337                    ),
338                    s,
339                ));
340            }
341            Ok(Value::Nil)
342        },
343    );
344
345    interp.register_fn(
346        blue_lang_syntax::LOWERED_OPTIONAL_ARG,
347        Arity::Exact(2),
348        |a: &[Value], _h: &mut H, s| match (&a[0], &a[1]) {
349            (Value::List(rest), Value::Int(i)) => {
350                Ok(rest.get(*i as usize).cloned().unwrap_or(Value::Nil))
351            }
352            _ => Err(EvalError::native_fn(
353                blue_lang_syntax::LOWERED_OPTIONAL_ARG,
354                "expects the rest list and an index",
355                s,
356            )),
357        },
358    );
359
360    // `concat(a, …)` joins the text of one or more arguments, left to right
361    // (okite D0007; Ruby's String#concat takes many). Two-only until
362    // 2026-09-27, which made `concat(a, b, c)` an arity error. `+` stays
363    // arithmetic: Ruby overloads `+` on String, but blue's `+` lowers to
364    // tatara's numeric `+`, and making it polymorphic would make a type error
365    // at a seam disappear into a string.
366    interp.register_fn(
367        "concat",
368        Arity::AtLeast(1),
369        |a: &[Value], _h: &mut H, _s| {
370            Ok(Value::Str(a.iter().map(render).collect::<String>().into()))
371        },
372    );
373
374    // ── equality and kinds (okite D0001–D0004, D0010) ─────────────────────
375    //
376    // `==` lowers to `equal?` and `!=` to `not=`; both are redefined here so
377    // blue's interpreter has one equality. See `blue_equal`.
378    interp.register_fn("equal?", Arity::Exact(2), |a: &[Value], _h: &mut H, _s| {
379        Ok(Value::Bool(blue_equal(&a[0], &a[1])))
380    });
381    interp.register_fn("not=", Arity::Exact(2), |a: &[Value], _h: &mut H, _s| {
382        Ok(Value::Bool(!blue_equal(&a[0], &a[1])))
383    });
384
385    // One predicate per kind, and exactly one holds of any value (D0010).
386    // tatara's `nil?` is its Scheme alias for `null?`, true of `[]` as well,
387    // and its `list?` is true of nil; blue's nil is absent and `[]` is an empty
388    // list (D0004), so blue redefines both. tatara's own library ends its
389    // recursion with `null?`, which is untouched.
390    interp.register_fn("nil?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
391        Ok(Value::Bool(matches!(a[0], Value::Nil)))
392    });
393    interp.register_fn("list?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
394        Ok(Value::Bool(matches!(a[0], Value::List(_))))
395    });
396    interp.register_fn("map?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
397        Ok(Value::Bool(matches!(a[0], Value::Map(_))))
398    });
399    interp.register_fn("float?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
400        Ok(Value::Bool(matches!(a[0], Value::Float(_))))
401    });
402    interp.register_fn("bool?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
403        Ok(Value::Bool(matches!(a[0], Value::Bool(_))))
404    });
405
406    // The empty list is always `[]`, never nil (D0011). tatara's `cdr` (and so
407    // `rest`, which is `cdr`) and `append` returned nil for an empty result,
408    // Scheme's convention, which under D0004 made `rest([1]) == []` false.
409    // Same contracts otherwise: `cdr` of an empty list is still an error.
410    interp.register_fn("cdr", Arity::Exact(1), |a: &[Value], _h: &mut H, span| {
411        match &a[0] {
412            // Shares the tail's structure: O(log n), not a copy of it.
413            Value::List(xs) if !xs.is_empty() => Ok(Value::List(std::sync::Arc::new(xs.skip(1)))),
414            Value::Nil | Value::List(_) => Err(EvalError::native_fn(
415                std::sync::Arc::<str>::from("cdr"),
416                "cdr of empty list",
417                span,
418            )),
419            other => Err(EvalError::type_mismatch("pair", other.type_name(), span)),
420        }
421    });
422    interp.register_owned_fn("append", Arity::Any, |a: Vec<Value>, _h: &mut H, span| {
423        // Persistent concatenation: each operand's structure is shared, so
424        // building a list by `append(acc, [x])` is linear overall, not
425        // quadratic (G7). The arguments arrive by value, so an accumulator
426        // the caller holds alone (tatara-lisp-eval runs a tail call to an
427        // owned primitive after releasing the caller's frame) is extended in
428        // its own allocation by `Arc::make_mut` instead of copied at the tail.
429        let mut lists = Vec::with_capacity(a.len());
430        for v in a {
431            match v {
432                Value::Nil => {}
433                Value::List(xs) => lists.push(xs),
434                other => return Err(EvalError::type_mismatch("list", other.type_name(), span)),
435            }
436        }
437        let mut lists = lists.into_iter();
438        let Some(mut out) = lists.next() else {
439            return Ok(Value::List(std::sync::Arc::new(
440                tatara_lisp_eval::List::new(),
441            )));
442        };
443        let acc = std::sync::Arc::make_mut(&mut out);
444        for xs in lists {
445            acc.append(
446                std::sync::Arc::try_unwrap(xs)
447                    .unwrap_or_else(|shared| tatara_lisp_eval::List::clone(&shared)),
448            );
449        }
450        Ok(Value::List(out))
451    });
452
453    interp.register_fn("split", Arity::Exact(2), |a: &[Value], _h: &mut H, s| {
454        let text = as_str(&a[0], s)?;
455        let sep = as_str(&a[1], s)?;
456        // An empty separator splits into characters, as Ruby's `split("")`
457        // does. Rust's `split("")` yields leading/trailing empties instead,
458        // which is the wrong answer here.
459        // Every field is kept: n separators give n + 1 fields, so `join` undoes
460        // `split` exactly (okite D0012). A deliberate deviation from Ruby, which
461        // drops trailing empty fields: tried on 2026-09-27, it broke nisshi's
462        // hash chain, kueri's scripts and moji's `lines`, because a parser whose
463        // split loses a field loses data and still reports success.
464        let parts: Vec<Value> = if sep.is_empty() {
465            text.chars()
466                .map(|c| Value::Str(c.to_string().into()))
467                .collect()
468        } else {
469            text.split(sep.as_str())
470                .map(|p| Value::Str(p.into()))
471                .collect()
472        };
473        Ok(list(parts))
474    });
475
476    interp.register_fn("join", Arity::Exact(2), |a: &[Value], _h: &mut H, s| {
477        let sep = as_str(&a[1], s)?;
478        match &a[0] {
479            Value::List(items) => Ok(Value::Str(
480                items
481                    .iter()
482                    .map(render)
483                    .collect::<Vec<_>>()
484                    .join(&sep)
485                    .into(),
486            )),
487            other => Err(EvalError::type_mismatch("a list", other.type_name(), s).into()),
488        }
489    });
490
491    interp.register_fn(
492        "contains?",
493        Arity::Exact(2),
494        |a: &[Value], _h: &mut H, s| {
495            Ok(Value::Bool(as_str(&a[0], s)?.contains(&as_str(&a[1], s)?)))
496        },
497    );
498
499    interp.register_fn(
500        "starts_with?",
501        Arity::Exact(2),
502        |a: &[Value], _h: &mut H, s| {
503            Ok(Value::Bool(
504                as_str(&a[0], s)?.starts_with(&as_str(&a[1], s)?),
505            ))
506        },
507    );
508
509    interp.register_fn(
510        "ends_with?",
511        Arity::Exact(2),
512        |a: &[Value], _h: &mut H, s| {
513            Ok(Value::Bool(as_str(&a[0], s)?.ends_with(&as_str(&a[1], s)?)))
514        },
515    );
516
517    interp.register_fn(
518        "text_index",
519        Arity::Exact(3),
520        |a: &[Value], _h: &mut H, s| {
521            Ok(Value::Int(text_index(
522                &as_str(&a[0], s)?,
523                &as_str(&a[1], s)?,
524                as_index(&a[2], s)?,
525            )))
526        },
527    );
528
529    interp.register_fn(
530        "text_last_index",
531        Arity::Exact(3),
532        |a: &[Value], _h: &mut H, s| {
533            Ok(Value::Int(text_last_index(
534                &as_str(&a[0], s)?,
535                &as_str(&a[1], s)?,
536                as_index(&a[2], s)?,
537            )))
538        },
539    );
540
541    interp.register_fn("replace", Arity::Exact(3), |a: &[Value], _h: &mut H, s| {
542        Ok(Value::Str(
543            as_str(&a[0], s)?
544                .replace(&as_str(&a[1], s)?, &as_str(&a[2], s)?)
545                .into(),
546        ))
547    });
548
549    interp.register_fn("reverse", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
550        match &a[0] {
551            Value::List(items) => Ok(Value::list(items.iter().rev().cloned())),
552            // Reversed by CHARACTER, so a multi-byte character survives. A
553            // byte-wise reverse produces invalid UTF-8.
554            other => Ok(Value::Str(
555                as_str(other, s)?.chars().rev().collect::<String>().into(),
556            )),
557        }
558    });
559
560    interp.register_fn("chars", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
561        Ok(list(
562            as_str(&a[0], s)?
563                .chars()
564                .map(|c| Value::Str(c.to_string().into()))
565                .collect(),
566        ))
567    });
568
569    // ── numbers ───────────────────────────────────────────────────────
570
571    // `to_int` RETURNS NIL on unparseable input rather than raising. Ruby's
572    // `String#to_i` answers 0 for garbage, which silently turns a parse failure
573    // into a plausible number; nil is falsy and cannot be mistaken for a
574    // result. `to_int!` is the raising form for callers who want the failure.
575    interp.register_fn(
576        "to_int",
577        Arity::Exact(1),
578        |a: &[Value], _h: &mut H, s| match &a[0] {
579            Value::Int(n) => Ok(Value::Int(*n)),
580            // A float with no Int (non-finite, or past i64) is nil like any
581            // other unconvertible input; `as i64` saturated it to a plausible
582            // wrong number.
583            Value::Float(x) => Ok(float_to_int(*x).map_or(Value::Nil, Value::Int)),
584            other => Ok(as_str(other, s)?
585                .trim()
586                .parse::<i64>()
587                .map_or(Value::Nil, Value::Int)),
588        },
589    );
590
591    interp.register_fn(
592        "to_int!",
593        Arity::Exact(1),
594        |a: &[Value], _h: &mut H, s| match &a[0] {
595            Value::Int(n) => Ok(Value::Int(*n)),
596            Value::Float(x) => float_to_int(*x)
597                .map(Value::Int)
598                .ok_or(EvalError::IntegerOverflow {
599                    op: "to_int!",
600                    at: s,
601                }),
602            other => {
603                let text = as_str(other, s)?;
604                text.trim().parse::<i64>().map(Value::Int).map_err(|_| {
605                    EvalError::native_fn(
606                        "to_int!",
607                        "`".to_string() + &text + "` is not an integer",
608                        s,
609                    )
610                    .into()
611                })
612            }
613        },
614    );
615
616    interp.register_fn(
617        "to_float",
618        Arity::Exact(1),
619        |a: &[Value], _h: &mut H, s| match &a[0] {
620            Value::Float(x) => Ok(Value::Float(*x)),
621            Value::Int(n) => Ok(Value::Float(*n as f64)),
622            other => Ok(as_str(other, s)?
623                .trim()
624                .parse::<f64>()
625                .map_or(Value::Nil, Value::Float)),
626        },
627    );
628
629    interp.register_fn(
630        "abs",
631        Arity::Exact(1),
632        |a: &[Value], _h: &mut H, s| match &a[0] {
633            // Checked, as tatara's `abs` is (G5): |i64::MIN| does not fit, and
634            // `n.abs()` wrapped in release and panicked in debug.
635            Value::Int(n) => n
636                .checked_abs()
637                .map(Value::Int)
638                .ok_or(EvalError::IntegerOverflow { op: "abs", at: s }),
639            Value::Float(x) => Ok(Value::Float(x.abs())),
640            other => Err(EvalError::type_mismatch("a number", other.type_name(), s).into()),
641        },
642    );
643
644    // ── ranges ────────────────────────────────────────────────────────
645
646    // `range` is a LOOP here, not a recursion. tatara-lisp's stdlib defines it
647    // in Lisp (`lisp_stdlib.tlisp`, where `range-impl` conses one frame per
648    // element), so `range(0, 5000)` overflowed the 8 MiB main stack and
649    // aborted the process. Everything built on it inherited the ceiling:
650    // retsu's `indexes`, `zip_with` and `enumerate`, and shomei's
651    // `chain_first_break`, which could not verify a 5,000-entry chain
652    // (measured 2026-09-24: 4,000 elements fine, 5,000 aborted).
653    //
654    // Same arities and answers as the Lisp definition: (end), (start, end),
655    // (start, end, step), ascending for a positive step, descending for a
656    // negative one, and each next element is the previous plus the step under
657    // the ordinary number rules (Int + Int is Int; a Float anywhere makes the
658    // rest Float). Two refusals the Lisp version lacked: a zero step, which
659    // recursed forever, and a wrong arity, which printed a message and
660    // returned nil.
661    //
662    // The destination is upstream: when tatara-lisp's own `range` is a loop,
663    // this registration is deleted.
664    interp.register_fn("range", Arity::Range(1, 3), |a: &[Value], _h: &mut H, s| {
665        let (start, end, step) = match a.len() {
666            1 => (Value::Int(0), a[0].clone(), Value::Int(1)),
667            2 => (a[0].clone(), a[1].clone(), Value::Int(1)),
668            _ => (a[0].clone(), a[1].clone(), a[2].clone()),
669        };
670        range_list(start, &end, &step, s)
671    });
672
673    // `write_stdout(s)` / `write_stderr(s)`: the text exactly, no quotes, no
674    // newline added. `print`, `println` and `display` all render a string
675    // with its quotes (a value printer, right for a REPL and wrong for a
676    // command's output), so until these a blue command-line tool could not
677    // print plain text. Output only, like `println`; anything else is not a
678    // string and is refused.
679    interp.register_fn(
680        "write_stdout",
681        Arity::Exact(1),
682        |a: &[Value], _h: &mut H, s| {
683            use std::io::Write;
684            let text = as_str(&a[0], s)?;
685            let mut out = std::io::stdout().lock();
686            out.write_all(text.as_bytes())
687                .and_then(|()| out.flush())
688                .map_err(|e| EvalError::native_fn("write_stdout", e.to_string(), s))?;
689            Ok(Value::Nil)
690        },
691    );
692    interp.register_fn(
693        "write_stderr",
694        Arity::Exact(1),
695        |a: &[Value], _h: &mut H, s| {
696            use std::io::Write;
697            let text = as_str(&a[0], s)?;
698            let mut err = std::io::stderr().lock();
699            err.write_all(text.as_bytes())
700                .and_then(|()| err.flush())
701                .map_err(|e| EvalError::native_fn("write_stderr", e.to_string(), s))?;
702            Ok(Value::Nil)
703        },
704    );
705
706    // `sort_keyed(key, xs)`: xs ordered by key(x), stably, in O(n log n),
707    // under `compare`'s rules. Every sort written in blue (junjo's) recurses
708    // per element and aborts the process near 600 elements; this is
709    // tatara-lisp's `sort-by-key` (0.3.60), which blue cannot name because
710    // it is kebab-case, bound to a name blue can. The engine is upstream,
711    // `hof::sort_keyed_values`; only the spelling lives here.
712    interp.register_higher_order_fn(
713        "sort_keyed",
714        Arity::Exact(2),
715        |a: &[Value], host: &mut H, caller: &tatara_lisp_eval::ffi::Caller<H>, s| {
716            let xs = match &a[1] {
717                Value::List(xs) => xs.to_vec(),
718                Value::Nil => Vec::new(),
719                other => {
720                    return Err(EvalError::type_mismatch("a list", other.type_name(), s));
721                }
722            };
723            let mut keyed = Vec::with_capacity(xs.len());
724            for x in xs {
725                keyed.push((caller.apply_value(&a[0], vec![x.clone()], host, s)?, x));
726            }
727            Ok(list(tatara_lisp_eval::hof::sort_keyed_values(keyed, s)?))
728        },
729    );
730
731    interp.register_fn(
732        "error_message",
733        Arity::Exact(1),
734        |a: &[Value], _h: &mut H, s| match &a[0] {
735            Value::Error(e) => Ok(Value::Str(e.message.clone())),
736            other => Err(EvalError::type_mismatch("an error", other.type_name(), s)),
737        },
738    );
739
740    interp.register_fn(
741        "error_kind",
742        Arity::Exact(1),
743        |a: &[Value], _h: &mut H, s| match &a[0] {
744            Value::Error(e) => Ok(Value::Keyword(e.tag.clone())),
745            other => Err(EvalError::type_mismatch("an error", other.type_name(), s)),
746        },
747    );
748
749    interp.register_fn(
750        "map_entries",
751        Arity::Exact(1),
752        |a: &[Value], _h: &mut H, s| match &a[0] {
753            Value::Map(m) => {
754                let keyed = m
755                    .iter()
756                    .map(|(k, v)| (k.to_value(), list(vec![k.to_value(), v.clone()])))
757                    .collect();
758                Ok(list(tatara_lisp_eval::hof::sort_keyed_values(keyed, s)?))
759            }
760            Value::Nil => Ok(list(Vec::new())),
761            other => Err(EvalError::type_mismatch("a map", other.type_name(), s)),
762        },
763    );
764}
765
766fn as_index(v: &Value, span: tatara_lisp::Span) -> Result<i64, EvalError> {
767    match v {
768        Value::Int(n) => Ok(*n),
769        other => Err(EvalError::type_mismatch(
770            "an integer",
771            other.type_name(),
772            span,
773        )),
774    }
775}
776
777fn text_index(text: &str, sub: &str, from: i64) -> i64 {
778    let from = usize::try_from(from.max(0)).unwrap_or(usize::MAX);
779    let start = match text.char_indices().nth(from) {
780        Some((b, _)) => b,
781        None if text.chars().count() == from => text.len(),
782        None => return -1,
783    };
784    text[start..].find(sub).map_or(-1, |b| {
785        i64::try_from(from + text[start..start + b].chars().count()).unwrap_or(i64::MAX)
786    })
787}
788
789fn text_last_index(text: &str, sub: &str, from: i64) -> i64 {
790    let Ok(from) = usize::try_from(from) else {
791        return -1;
792    };
793    let total = text.chars().count();
794    let width = sub.chars().count();
795    if width > total {
796        return -1;
797    }
798    let limit = from.min(total - width);
799    let end = text
800        .char_indices()
801        .nth(limit + width)
802        .map_or(text.len(), |(b, _)| b);
803    text[..end].rfind(sub).map_or(-1, |b| {
804        i64::try_from(text[..b].chars().count()).unwrap_or(i64::MAX)
805    })
806}
807
808/// A number's value as a float, or a type error naming `range`.
809fn range_num(v: &Value, s: tatara_lisp::Span) -> Result<f64, EvalError> {
810    match v {
811        Value::Int(n) => Ok(*n as f64),
812        Value::Float(x) => Ok(*x),
813        other => Err(EvalError::native_fn(
814            "range",
815            "expected a number, got ".to_string() + other.type_name(),
816            s,
817        )),
818    }
819}
820
821/// A float's integer part as an `i64`, or `None` when it is not finite or
822/// does not fit. `as i64` saturates instead.
823fn float_to_int(x: f64) -> Option<i64> {
824    const LIMIT: f64 = 9_223_372_036_854_775_808.0;
825    let t = x.trunc();
826    #[allow(clippy::cast_possible_truncation)]
827    (t.is_finite() && (-LIMIT..LIMIT).contains(&t)).then(|| t as i64)
828}
829
830/// `a + b` under the number rules the Lisp `range` used: Int + Int stays Int,
831/// anything with a Float is Float.
832fn range_add(a: &Value, b: &Value, s: tatara_lisp::Span) -> Result<Value, EvalError> {
833    match (a, b) {
834        (Value::Int(x), Value::Int(y)) => x.checked_add(*y).map(Value::Int).ok_or_else(|| {
835            EvalError::native_fn("range", "an element overflowed a 64-bit integer", s)
836        }),
837        _ => Ok(Value::Float(range_num(a, s)? + range_num(b, s)?)),
838    }
839}
840
841fn range_list(
842    start: Value,
843    end: &Value,
844    step: &Value,
845    s: tatara_lisp::Span,
846) -> Result<Value, EvalError> {
847    let end_f = range_num(end, s)?;
848    let step_f = range_num(step, s)?;
849    range_num(&start, s)?;
850    if step_f == 0.0 {
851        return Err(EvalError::native_fn(
852            "range",
853            "a step of zero never reaches the end",
854            s,
855        ));
856    }
857    let mut out = Vec::new();
858    let mut cur = start;
859    loop {
860        let c = range_num(&cur, s)?;
861        if (step_f > 0.0 && c >= end_f) || (step_f < 0.0 && c <= end_f) {
862            break;
863        }
864        let next = range_add(&cur, step, s)?;
865        out.push(cur);
866        cur = next;
867    }
868    Ok(list(out))
869}
870
871#[cfg(test)]
872mod tests {
873    use super::*;
874
875    #[test]
876    fn sort_keyed_is_stable_keyed_and_deep() {
877        // Ties keep input order; the key is computed per element.
878        assert_eq!(
879            ints("map(fn(p) last(p) end, sort_keyed(fn(p) first(p) end, [[2, 1], [1, 2], [2, 3], [1, 4]]))"),
880            vec![2, 4, 1, 3]
881        );
882        // 20,000 in reverse: every blue-written sort aborts far below this.
883        assert_eq!(
884            i("first(sort_keyed(fn(x) x end, reverse(range(0, 20000))))"),
885            0
886        );
887        assert_eq!(i("last(sort_keyed(fn(x) 0 - x end, range(0, 20000)))"), 0);
888        // The empty case, and a refusal for keys that cannot be ordered.
889        assert_eq!(ints("sort_keyed(fn(x) x end, [])"), Vec::<i64>::new());
890        assert!(crate::run("sort_keyed(fn(x) x end, [1, \"a\"])").is_err());
891    }
892
893    fn eval(src: &str) -> Value {
894        crate::run(src)
895            .unwrap_or_else(|e| panic!("{src:?}: {e}"))
896            .value
897    }
898
899    fn s(src: &str) -> String {
900        match eval(src) {
901            Value::Str(v) => v.to_string(),
902            other => panic!("{src:?} produced {other:?}"),
903        }
904    }
905
906    fn i(src: &str) -> i64 {
907        match eval(src) {
908            Value::Int(v) => v,
909            other => panic!("{src:?} produced {other:?}"),
910        }
911    }
912
913    fn ints(src: &str) -> Vec<i64> {
914        match eval(src) {
915            Value::List(xs) => xs
916                .iter()
917                .map(|v| match v {
918                    Value::Int(n) => *n,
919                    other => panic!("{src:?} produced a non-Int element {other:?}"),
920                })
921                .collect(),
922            other => panic!("{src:?} produced {other:?}"),
923        }
924    }
925
926    /// **`range` gives the Lisp definition's answers**, arity by arity,
927    /// including the descending form and the empty cases.
928    #[test]
929    fn range_answers_as_the_lisp_definition_did() {
930        assert_eq!(ints("range(5)"), vec![0, 1, 2, 3, 4]);
931        assert_eq!(ints("range(2, 6)"), vec![2, 3, 4, 5]);
932        assert_eq!(ints("range(0, 10, 2)"), vec![0, 2, 4, 6, 8]);
933        assert_eq!(ints("range(10, 0, 0 - 2)"), vec![10, 8, 6, 4, 2]);
934        assert!(ints("range(5, 5)").is_empty());
935        assert!(ints("range(5, 2)").is_empty());
936        assert!(ints("range(0)").is_empty());
937        // A float step makes the elements after the first floats, as `+` did.
938        match eval("range(0, 1, 0.5)") {
939            Value::List(xs) => {
940                assert!(matches!(xs[0], Value::Int(0)), "{xs:?}");
941                assert!(matches!(xs[1], Value::Float(x) if x == 0.5), "{xs:?}");
942                assert_eq!(xs.len(), 2);
943            }
944            other => panic!("{other:?}"),
945        }
946    }
947
948    /// **`range` is a loop, not a recursion.** The Lisp definition consed one
949    /// stack frame per element and aborted the process near 5,000 elements on
950    /// an 8 MiB stack. This runs on the test harness's 2 MiB thread, where the
951    /// Lisp one died sooner still. Red run, 2026-09-24: with the registration
952    /// removed, this test aborted the test binary with a stack overflow.
953    #[test]
954    fn range_builds_long_lists_without_recursing() {
955        assert_eq!(i("length(range(0, 200000))"), 200_000);
956        assert_eq!(i("nth(199999, range(0, 200000))"), 199_999);
957    }
958
959    /// A zero step never reaches the end: refused, where the Lisp definition
960    /// recursed until the stack gave out. A non-number is a named error.
961    #[test]
962    fn range_refuses_a_zero_step_and_a_non_number() {
963        assert!(crate::run("range(0, 5, 0)").is_err());
964        assert!(crate::run("range(0, \"x\")").is_err());
965    }
966
967    /// **`length` counts CHARACTERS, as Ruby does — not bytes.** A `length`
968    /// that returns bytes is the bug that only surfaces once a user types a
969    /// non-ASCII character, which is exactly when it is hardest to trace.
970    #[test]
971    fn length_counts_characters_not_bytes() {
972        assert_eq!(i("length(\"hello\")"), 5);
973        // "héllo" is 6 bytes, 5 characters.
974        assert_eq!(i("length(\"héllo\")"), 5, "must not be 6");
975        // An emoji is 4 bytes, 1 character.
976        assert_eq!(i("length(\"😀\")"), 1, "must not be 4");
977    }
978
979    /// blue reports **scalar values**, not grapheme clusters. Elixir would say
980    /// 1 here; blue says 2. Stated and pinned rather than left to surprise.
981    #[test]
982    fn a_combining_sequence_counts_scalars_not_graphemes() {
983        // "e" + U+0301 COMBINING ACUTE — one grapheme, two scalars.
984        assert_eq!(
985            i("length(\"e\\u{301}\")"),
986            2,
987            "blue counts scalar values; Elixir's String.length would say 1"
988        );
989    }
990
991    #[test]
992    fn length_also_works_on_a_list() {
993        assert_eq!(i("length([1, 2, 3])"), 3);
994    }
995
996    #[test]
997    fn case_and_trim() {
998        assert_eq!(s("upcase(\"abc\")"), "ABC");
999        assert_eq!(s("downcase(\"ABC\")"), "abc");
1000        assert_eq!(s("trim(\"  hi  \")"), "hi");
1001        // Non-ASCII case works, which a byte-wise implementation would botch.
1002        assert_eq!(s("upcase(\"é\")"), "É");
1003    }
1004
1005    #[test]
1006    fn concat_and_to_s() {
1007        assert_eq!(s("concat(\"a\", \"b\")"), "ab");
1008        assert_eq!(s("concat(\"n=\", 42)"), "n=42");
1009        assert_eq!(s("to_s(42)"), "42");
1010        assert_eq!(s("to_s(true)"), "true");
1011    }
1012
1013    /// **`+` stays arithmetic.** Ruby overloads it on String, but blue's `+`
1014    /// lowers to tatara's numeric `+`; making it polymorphic would let a type
1015    /// error at a seam disappear into a string.
1016    #[test]
1017    fn plus_is_not_string_concatenation() {
1018        assert!(
1019            crate::run("\"a\" + \"b\"").is_err(),
1020            "`+` must not silently concatenate — use concat"
1021        );
1022    }
1023
1024    #[test]
1025    fn split_and_join() {
1026        assert_eq!(i("length(split(\"a,b,c\", \",\"))"), 3);
1027        assert_eq!(s("join(split(\"a,b,c\", \",\"), \"-\")"), "a-b-c");
1028        // Ruby's `split("")` yields characters.
1029        assert_eq!(i("length(split(\"abc\", \"\"))"), 3);
1030    }
1031
1032    #[test]
1033    fn predicates() {
1034        assert!(matches!(
1035            eval("contains?(\"hello\", \"ell\")"),
1036            Value::Bool(true)
1037        ));
1038        assert!(matches!(
1039            eval("contains?(\"hello\", \"xyz\")"),
1040            Value::Bool(false)
1041        ));
1042        assert!(matches!(
1043            eval("starts_with?(\"hello\", \"he\")"),
1044            Value::Bool(true)
1045        ));
1046        assert!(matches!(
1047            eval("ends_with?(\"hello\", \"lo\")"),
1048            Value::Bool(true)
1049        ));
1050    }
1051
1052    fn refused(src: &str) -> String {
1053        match crate::run(src) {
1054            Ok(v) => panic!("{src:?} should fail, produced {:?}", v.value),
1055            Err(e) => e.to_string(),
1056        }
1057    }
1058
1059    #[test]
1060    fn trim_without_options_cuts_whitespace_at_both_ends() {
1061        assert_eq!(s("trim(\"  a b \\n\")"), "a b");
1062        assert_eq!(s("trim(\"  a \", nil)"), "a");
1063        assert_eq!(s("trim(\"  a \", {})"), "a");
1064        assert_eq!(s("trim(\"\")"), "");
1065        assert_eq!(s("trim(\"   \")"), "");
1066        assert_eq!(s("trim(\"\", {side: :start})"), "");
1067    }
1068
1069    #[test]
1070    fn trim_side_keeps_the_other_end() {
1071        assert_eq!(s("trim(\"  a  \", {side: :both})"), "a");
1072        assert_eq!(s("trim(\"  a  \", {side: :start})"), "a  ");
1073        assert_eq!(s("trim(\"  a  \", {side: :end})"), "  a");
1074    }
1075
1076    #[test]
1077    fn trim_chars_replaces_whitespace_as_the_set_to_cut() {
1078        assert_eq!(s("trim(\"--a-b--\", {chars: \"-\"})"), "a-b");
1079        assert_eq!(s("trim(\"-+a+-\", {chars: \"-+\", side: :start})"), "a+-");
1080        assert_eq!(s("trim(\"-+a+-\", {chars: \"-+\", side: :end})"), "-+a");
1081        assert_eq!(s("trim(\" a \", {chars: \"-\"})"), " a ");
1082        assert_eq!(s("trim(\"aaa\", {chars: \"a\"})"), "");
1083        assert_eq!(s("trim(\"héé\", {chars: \"é\", side: :end})"), "h");
1084        assert_eq!(s("trim(\"  a  \", {chars: \"\"})"), "  a  ");
1085    }
1086
1087    #[test]
1088    fn trim_refuses_what_it_does_not_take() {
1089        assert!(refused("trim(\"a\", {sid: :end})").contains("bad_option"));
1090        assert!(refused("trim(\"a\", {sid: :end})").contains("side, chars"));
1091        assert!(refused("trim(\"a\", {side: :middle})").contains("side must be"));
1092        assert!(refused("trim(\"a\", {side: \"end\"})").contains("bad_option"));
1093        assert!(refused("trim(\"a\", {chars: 1})").contains("chars must be text"));
1094        assert!(refused("trim(\"a\", 5)").contains("options must be a map"));
1095        assert!(refused("trim(\"a\", {}, {})").contains("1 to 2"));
1096        assert!(refused("trim()").contains("1 to 2"));
1097        assert!(refused("trim(5)").contains("string"));
1098    }
1099
1100    #[test]
1101    fn trim_is_still_a_value_other_functions_take() {
1102        assert_eq!(s("join(map(trim, [\" a\", \"b \"]), \",\")"), "a,b");
1103    }
1104
1105    #[test]
1106    fn text_index_answers_character_positions() {
1107        assert_eq!(i("text_index(\"héllo héllo\", \"llo\", 0)"), 2);
1108        assert_eq!(i("text_index(\"héllo héllo\", \"llo\", 3)"), 8);
1109        assert_eq!(i("text_index(\"héllo\", \"x\", 0)"), -1);
1110        assert_eq!(i("text_index(\"abc\", \"\", 3)"), 3);
1111        assert_eq!(i("text_index(\"abc\", \"\", 4)"), -1);
1112        assert_eq!(i("text_index(\"abc\", \"a\", -5)"), 0);
1113        assert_eq!(i("text_index(\"\", \"a\", 0)"), -1);
1114    }
1115
1116    #[test]
1117    fn text_last_index_finds_the_last_start_at_or_before_from() {
1118        assert_eq!(i("text_last_index(\"héllo héllo\", \"llo\", 99)"), 8);
1119        assert_eq!(i("text_last_index(\"héllo héllo\", \"llo\", 7)"), 2);
1120        assert_eq!(i("text_last_index(\"aaa\", \"aa\", 1)"), 1);
1121        assert_eq!(i("text_last_index(\"aaa\", \"aa\", 0)"), 0);
1122        assert_eq!(i("text_last_index(\"abc\", \"x\", 2)"), -1);
1123        assert_eq!(i("text_last_index(\"abc\", \"a\", -1)"), -1);
1124        assert_eq!(i("text_last_index(\"ab\", \"abc\", 0)"), -1);
1125        assert_eq!(i("text_last_index(\"abc\", \"\", 9)"), 3);
1126    }
1127
1128    #[test]
1129    fn replace_and_chars() {
1130        assert_eq!(s("replace(\"a-b-c\", \"-\", \"+\")"), "a+b+c");
1131        assert_eq!(i("length(chars(\"abc\"))"), 3);
1132    }
1133
1134    /// Reversed by character, so a multi-byte character survives. A byte-wise
1135    /// reverse produces invalid UTF-8.
1136    #[test]
1137    fn reverse_is_character_wise() {
1138        assert_eq!(s("reverse(\"abc\")"), "cba");
1139        assert_eq!(s("reverse(\"héllo\")"), "olléh", "must not corrupt the é");
1140    }
1141
1142    #[test]
1143    fn reverse_also_works_on_a_list() {
1144        assert_eq!(s("join(reverse([1, 2, 3]), \",\")"), "3,2,1");
1145    }
1146
1147    /// **`to_int` answers nil on garbage, not 0.** Ruby's `String#to_i` returns
1148    /// 0, which silently turns a parse failure into a plausible number — a
1149    /// deliberate divergence, and the reason `to_int!` exists for callers who
1150    /// want the failure loudly.
1151    #[test]
1152    fn to_int_is_nil_on_garbage_rather_than_zero() {
1153        assert_eq!(i("to_int(\"42\")"), 42);
1154        assert!(
1155            matches!(eval("to_int(\"banana\")"), Value::Nil),
1156            "Ruby would say 0 here; a falsy nil cannot be mistaken for a result"
1157        );
1158        assert!(
1159            matches!(eval("to_int(\"0\")"), Value::Int(0)),
1160            "and a real 0 is still a real 0 — the two must stay distinguishable"
1161        );
1162    }
1163
1164    #[test]
1165    fn to_int_bang_raises_on_garbage() {
1166        assert_eq!(i("to_int!(\"42\")"), 42);
1167        let err = crate::run("to_int!(\"banana\")").expect_err("must raise");
1168        assert!(err.to_string().contains("banana"), "must name it: {err}");
1169    }
1170
1171    #[test]
1172    fn numeric_conversions_and_abs() {
1173        assert_eq!(i("to_int(3.9)"), 3);
1174        assert_eq!(i("abs(0 - 5)"), 5);
1175        assert!(matches!(eval("to_float(\"1.5\")"), Value::Float(_)));
1176        assert!(matches!(eval("to_float(\"nope\")"), Value::Nil));
1177    }
1178
1179    /// A wrong-typed argument is a typed error, not a silent coercion.
1180    #[test]
1181    fn a_non_string_argument_is_a_type_error() {
1182        assert!(crate::run("upcase([1, 2])").is_err());
1183        assert!(crate::run("join(\"not a list\", \",\")").is_err());
1184    }
1185}