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
38fn as_str(v: &Value, span: tatara_lisp::Span) -> Result<String, EvalError> {
39    match v {
40        Value::Str(s) => Ok(s.to_string()),
41        // A symbol is text the author wrote; accepting it makes `upcase(:ok)`
42        // work the way a Ruby programmer expects of a symbol.
43        Value::Symbol(s) | Value::Keyword(s) => Ok(s.to_string()),
44        other => Err(EvalError::type_mismatch(
45            "a string",
46            other.type_name(),
47            span,
48        )),
49    }
50}
51
52/// Render any value as text — blue's `to_s`.
53fn render(v: &Value) -> String {
54    match v {
55        Value::Nil => String::new(),
56        Value::Bool(b) => b.to_string(),
57        Value::Int(n) => n.to_string(),
58        Value::Float(x) => x.to_string(),
59        Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => s.to_string(),
60        Value::List(items) => items.iter().map(render).collect::<Vec<_>>().join(" "),
61        other => other.type_name().to_string(),
62    }
63}
64
65fn list(items: Vec<Value>) -> Value {
66    Value::List(std::sync::Arc::new(items))
67}
68
69/// Install blue's string and number core.
70pub fn install_blue_stdlib<H: 'static>(interp: &mut Interpreter<H>) {
71    // ── text ──────────────────────────────────────────────────────────
72
73    // `length` counts CHARACTERS, per Ruby. Also accepts a list, where it is
74    // the element count — Ruby's `Array#length`.
75    interp.register_fn(
76        "length",
77        Arity::Exact(1),
78        |a: &[Value], _h: &mut H, span| match &a[0] {
79            Value::List(items) => Ok(Value::Int(items.len() as i64)),
80            other => Ok(Value::Int(as_str(other, span)?.chars().count() as i64)),
81        },
82    );
83
84    interp.register_fn("to_s", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
85        Ok(Value::Str(render(&a[0]).into()))
86    });
87
88    interp.register_fn("upcase", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
89        Ok(Value::Str(as_str(&a[0], s)?.to_uppercase().into()))
90    });
91
92    interp.register_fn("downcase", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
93        Ok(Value::Str(as_str(&a[0], s)?.to_lowercase().into()))
94    });
95
96    interp.register_fn("trim", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
97        Ok(Value::Str(as_str(&a[0], s)?.trim().into()))
98    });
99
100    // `concat(a, b)` — two-arg so it composes; `+` stays arithmetic. Ruby
101    // overloads `+` on String, but blue's `+` lowers to tatara's numeric `+`,
102    // and silently making it polymorphic would make a type error at a seam
103    // disappear into a string.
104    interp.register_fn("concat", Arity::Exact(2), |a: &[Value], _h: &mut H, _s| {
105        let mut out = render(&a[0]);
106        out.push_str(&render(&a[1]));
107        Ok(Value::Str(out.into()))
108    });
109
110    interp.register_fn("split", Arity::Exact(2), |a: &[Value], _h: &mut H, s| {
111        let text = as_str(&a[0], s)?;
112        let sep = as_str(&a[1], s)?;
113        // An empty separator splits into characters, as Ruby's `split("")`
114        // does. Rust's `split("")` yields leading/trailing empties instead,
115        // which is the wrong answer here.
116        let parts: Vec<Value> = if sep.is_empty() {
117            text.chars()
118                .map(|c| Value::Str(c.to_string().into()))
119                .collect()
120        } else {
121            text.split(sep.as_str())
122                .map(|p| Value::Str(p.into()))
123                .collect()
124        };
125        Ok(list(parts))
126    });
127
128    interp.register_fn("join", Arity::Exact(2), |a: &[Value], _h: &mut H, s| {
129        let sep = as_str(&a[1], s)?;
130        match &a[0] {
131            Value::List(items) => Ok(Value::Str(
132                items
133                    .iter()
134                    .map(render)
135                    .collect::<Vec<_>>()
136                    .join(&sep)
137                    .into(),
138            )),
139            other => Err(EvalError::type_mismatch("a list", other.type_name(), s).into()),
140        }
141    });
142
143    interp.register_fn(
144        "contains?",
145        Arity::Exact(2),
146        |a: &[Value], _h: &mut H, s| {
147            Ok(Value::Bool(as_str(&a[0], s)?.contains(&as_str(&a[1], s)?)))
148        },
149    );
150
151    interp.register_fn(
152        "starts_with?",
153        Arity::Exact(2),
154        |a: &[Value], _h: &mut H, s| {
155            Ok(Value::Bool(
156                as_str(&a[0], s)?.starts_with(&as_str(&a[1], s)?),
157            ))
158        },
159    );
160
161    interp.register_fn(
162        "ends_with?",
163        Arity::Exact(2),
164        |a: &[Value], _h: &mut H, s| {
165            Ok(Value::Bool(as_str(&a[0], s)?.ends_with(&as_str(&a[1], s)?)))
166        },
167    );
168
169    interp.register_fn("replace", Arity::Exact(3), |a: &[Value], _h: &mut H, s| {
170        Ok(Value::Str(
171            as_str(&a[0], s)?
172                .replace(&as_str(&a[1], s)?, &as_str(&a[2], s)?)
173                .into(),
174        ))
175    });
176
177    interp.register_fn("reverse", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
178        match &a[0] {
179            Value::List(items) => {
180                let mut v = items.as_ref().clone();
181                v.reverse();
182                Ok(list(v))
183            }
184            // Reversed by CHARACTER, so a multi-byte character survives. A
185            // byte-wise reverse produces invalid UTF-8.
186            other => Ok(Value::Str(
187                as_str(other, s)?.chars().rev().collect::<String>().into(),
188            )),
189        }
190    });
191
192    interp.register_fn("chars", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
193        Ok(list(
194            as_str(&a[0], s)?
195                .chars()
196                .map(|c| Value::Str(c.to_string().into()))
197                .collect(),
198        ))
199    });
200
201    // ── numbers ───────────────────────────────────────────────────────
202
203    // `to_int` RETURNS NIL on unparseable input rather than raising. Ruby's
204    // `String#to_i` answers 0 for garbage, which silently turns a parse failure
205    // into a plausible number; nil is falsy and cannot be mistaken for a
206    // result. `to_int!` is the raising form for callers who want the failure.
207    interp.register_fn(
208        "to_int",
209        Arity::Exact(1),
210        |a: &[Value], _h: &mut H, s| match &a[0] {
211            Value::Int(n) => Ok(Value::Int(*n)),
212            Value::Float(x) => Ok(Value::Int(*x as i64)),
213            other => Ok(as_str(other, s)?
214                .trim()
215                .parse::<i64>()
216                .map_or(Value::Nil, Value::Int)),
217        },
218    );
219
220    interp.register_fn(
221        "to_int!",
222        Arity::Exact(1),
223        |a: &[Value], _h: &mut H, s| match &a[0] {
224            Value::Int(n) => Ok(Value::Int(*n)),
225            Value::Float(x) => Ok(Value::Int(*x as i64)),
226            other => {
227                let text = as_str(other, s)?;
228                text.trim().parse::<i64>().map(Value::Int).map_err(|_| {
229                    EvalError::native_fn(
230                        "to_int!",
231                        "`".to_string() + &text + "` is not an integer",
232                        s,
233                    )
234                    .into()
235                })
236            }
237        },
238    );
239
240    interp.register_fn(
241        "to_float",
242        Arity::Exact(1),
243        |a: &[Value], _h: &mut H, s| match &a[0] {
244            Value::Float(x) => Ok(Value::Float(*x)),
245            Value::Int(n) => Ok(Value::Float(*n as f64)),
246            other => Ok(as_str(other, s)?
247                .trim()
248                .parse::<f64>()
249                .map_or(Value::Nil, Value::Float)),
250        },
251    );
252
253    interp.register_fn(
254        "abs",
255        Arity::Exact(1),
256        |a: &[Value], _h: &mut H, s| match &a[0] {
257            Value::Int(n) => Ok(Value::Int(n.abs())),
258            Value::Float(x) => Ok(Value::Float(x.abs())),
259            other => Err(EvalError::type_mismatch("a number", other.type_name(), s).into()),
260        },
261    );
262
263    // ── ranges ────────────────────────────────────────────────────────
264
265    // `range` is a LOOP here, not a recursion. tatara-lisp's stdlib defines it
266    // in Lisp (`lisp_stdlib.tlisp`, where `range-impl` conses one frame per
267    // element), so `range(0, 5000)` overflowed the 8 MiB main stack and
268    // aborted the process. Everything built on it inherited the ceiling:
269    // retsu's `indexes`, `zip_with` and `enumerate`, and shomei's
270    // `chain_first_break`, which could not verify a 5,000-entry chain
271    // (measured 2026-09-24: 4,000 elements fine, 5,000 aborted).
272    //
273    // Same arities and answers as the Lisp definition: (end), (start, end),
274    // (start, end, step), ascending for a positive step, descending for a
275    // negative one, and each next element is the previous plus the step under
276    // the ordinary number rules (Int + Int is Int; a Float anywhere makes the
277    // rest Float). Two refusals the Lisp version lacked: a zero step, which
278    // recursed forever, and a wrong arity, which printed a message and
279    // returned nil.
280    //
281    // The destination is upstream: when tatara-lisp's own `range` is a loop,
282    // this registration is deleted.
283    interp.register_fn(
284        "range",
285        Arity::Range(1, 3),
286        |a: &[Value], _h: &mut H, s| {
287            let (start, end, step) = match a.len() {
288                1 => (Value::Int(0), a[0].clone(), Value::Int(1)),
289                2 => (a[0].clone(), a[1].clone(), Value::Int(1)),
290                _ => (a[0].clone(), a[1].clone(), a[2].clone()),
291            };
292            range_list(start, &end, &step, s)
293        },
294    );
295
296    // `write_stdout(s)` / `write_stderr(s)`: the text exactly, no quotes, no
297    // newline added. `print`, `println` and `display` all render a string
298    // with its quotes (a value printer, right for a REPL and wrong for a
299    // command's output), so until these a blue command-line tool could not
300    // print plain text. Output only, like `println`; anything else is not a
301    // string and is refused.
302    interp.register_fn("write_stdout", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
303        use std::io::Write;
304        let text = as_str(&a[0], s)?;
305        let mut out = std::io::stdout().lock();
306        out.write_all(text.as_bytes())
307            .and_then(|()| out.flush())
308            .map_err(|e| EvalError::native_fn("write_stdout", e.to_string(), s))?;
309        Ok(Value::Nil)
310    });
311    interp.register_fn("write_stderr", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
312        use std::io::Write;
313        let text = as_str(&a[0], s)?;
314        let mut err = std::io::stderr().lock();
315        err.write_all(text.as_bytes())
316            .and_then(|()| err.flush())
317            .map_err(|e| EvalError::native_fn("write_stderr", e.to_string(), s))?;
318        Ok(Value::Nil)
319    });
320
321    // `sort_keyed(key, xs)`: xs ordered by key(x), stably, in O(n log n),
322    // under `compare`'s rules. Every sort written in blue (junjo's) recurses
323    // per element and aborts the process near 600 elements; this is
324    // tatara-lisp's `sort-by-key` (0.3.60), which blue cannot name because
325    // it is kebab-case, bound to a name blue can. The engine is upstream,
326    // `hof::sort_keyed_values`; only the spelling lives here.
327    interp.register_higher_order_fn(
328        "sort_keyed",
329        Arity::Exact(2),
330        |a: &[Value], host: &mut H, caller: &tatara_lisp_eval::ffi::Caller<H>, s| {
331            let xs = match &a[1] {
332                Value::List(xs) => xs.as_ref().clone(),
333                Value::Nil => Vec::new(),
334                other => {
335                    return Err(EvalError::type_mismatch("a list", other.type_name(), s));
336                }
337            };
338            let mut keyed = Vec::with_capacity(xs.len());
339            for x in xs {
340                keyed.push((caller.apply_value(&a[0], vec![x.clone()], host, s)?, x));
341            }
342            Ok(list(tatara_lisp_eval::hof::sort_keyed_values(keyed, s)?))
343        },
344    );
345}
346
347/// A number's value as a float, or a type error naming `range`.
348fn range_num(v: &Value, s: tatara_lisp::Span) -> Result<f64, EvalError> {
349    match v {
350        Value::Int(n) => Ok(*n as f64),
351        Value::Float(x) => Ok(*x),
352        other => Err(EvalError::native_fn(
353            "range",
354            "expected a number, got ".to_string() + other.type_name(),
355            s,
356        )),
357    }
358}
359
360/// `a + b` under the number rules the Lisp `range` used: Int + Int stays Int,
361/// anything with a Float is Float.
362fn range_add(a: &Value, b: &Value, s: tatara_lisp::Span) -> Result<Value, EvalError> {
363    match (a, b) {
364        (Value::Int(x), Value::Int(y)) => x.checked_add(*y).map(Value::Int).ok_or_else(|| {
365            EvalError::native_fn("range", "an element overflowed a 64-bit integer", s)
366        }),
367        _ => Ok(Value::Float(range_num(a, s)? + range_num(b, s)?)),
368    }
369}
370
371fn range_list(
372    start: Value,
373    end: &Value,
374    step: &Value,
375    s: tatara_lisp::Span,
376) -> Result<Value, EvalError> {
377    let end_f = range_num(end, s)?;
378    let step_f = range_num(step, s)?;
379    range_num(&start, s)?;
380    if step_f == 0.0 {
381        return Err(EvalError::native_fn(
382            "range",
383            "a step of zero never reaches the end",
384            s,
385        ));
386    }
387    let mut out = Vec::new();
388    let mut cur = start;
389    loop {
390        let c = range_num(&cur, s)?;
391        if (step_f > 0.0 && c >= end_f) || (step_f < 0.0 && c <= end_f) {
392            break;
393        }
394        let next = range_add(&cur, step, s)?;
395        out.push(cur);
396        cur = next;
397    }
398    Ok(list(out))
399}
400
401#[cfg(test)]
402mod tests {
403    use super::*;
404
405    #[test]
406    fn sort_keyed_is_stable_keyed_and_deep() {
407        // Ties keep input order; the key is computed per element.
408        assert_eq!(
409            ints("map(fn(p) last(p) end, sort_keyed(fn(p) first(p) end, [[2, 1], [1, 2], [2, 3], [1, 4]]))"),
410            vec![2, 4, 1, 3]
411        );
412        // 20,000 in reverse: every blue-written sort aborts far below this.
413        assert_eq!(
414            i("first(sort_keyed(fn(x) x end, reverse(range(0, 20000))))"),
415            0
416        );
417        assert_eq!(i("last(sort_keyed(fn(x) 0 - x end, range(0, 20000)))"), 0);
418        // The empty case, and a refusal for keys that cannot be ordered.
419        assert_eq!(ints("sort_keyed(fn(x) x end, [])"), Vec::<i64>::new());
420        assert!(crate::run("sort_keyed(fn(x) x end, [1, \"a\"])").is_err());
421    }
422
423    fn eval(src: &str) -> Value {
424        crate::run(src)
425            .unwrap_or_else(|e| panic!("{src:?}: {e}"))
426            .value
427    }
428
429    fn s(src: &str) -> String {
430        match eval(src) {
431            Value::Str(v) => v.to_string(),
432            other => panic!("{src:?} produced {other:?}"),
433        }
434    }
435
436    fn i(src: &str) -> i64 {
437        match eval(src) {
438            Value::Int(v) => v,
439            other => panic!("{src:?} produced {other:?}"),
440        }
441    }
442
443    fn ints(src: &str) -> Vec<i64> {
444        match eval(src) {
445            Value::List(xs) => xs
446                .iter()
447                .map(|v| match v {
448                    Value::Int(n) => *n,
449                    other => panic!("{src:?} produced a non-Int element {other:?}"),
450                })
451                .collect(),
452            other => panic!("{src:?} produced {other:?}"),
453        }
454    }
455
456    /// **`range` gives the Lisp definition's answers**, arity by arity,
457    /// including the descending form and the empty cases.
458    #[test]
459    fn range_answers_as_the_lisp_definition_did() {
460        assert_eq!(ints("range(5)"), vec![0, 1, 2, 3, 4]);
461        assert_eq!(ints("range(2, 6)"), vec![2, 3, 4, 5]);
462        assert_eq!(ints("range(0, 10, 2)"), vec![0, 2, 4, 6, 8]);
463        assert_eq!(ints("range(10, 0, 0 - 2)"), vec![10, 8, 6, 4, 2]);
464        assert!(ints("range(5, 5)").is_empty());
465        assert!(ints("range(5, 2)").is_empty());
466        assert!(ints("range(0)").is_empty());
467        // A float step makes the elements after the first floats, as `+` did.
468        match eval("range(0, 1, 0.5)") {
469            Value::List(xs) => {
470                assert!(matches!(xs[0], Value::Int(0)), "{xs:?}");
471                assert!(matches!(xs[1], Value::Float(x) if x == 0.5), "{xs:?}");
472                assert_eq!(xs.len(), 2);
473            }
474            other => panic!("{other:?}"),
475        }
476    }
477
478    /// **`range` is a loop, not a recursion.** The Lisp definition consed one
479    /// stack frame per element and aborted the process near 5,000 elements on
480    /// an 8 MiB stack. This runs on the test harness's 2 MiB thread, where the
481    /// Lisp one died sooner still. Red run, 2026-09-24: with the registration
482    /// removed, this test aborted the test binary with a stack overflow.
483    #[test]
484    fn range_builds_long_lists_without_recursing() {
485        assert_eq!(i("length(range(0, 200000))"), 200_000);
486        assert_eq!(i("nth(199999, range(0, 200000))"), 199_999);
487    }
488
489    /// A zero step never reaches the end: refused, where the Lisp definition
490    /// recursed until the stack gave out. A non-number is a named error.
491    #[test]
492    fn range_refuses_a_zero_step_and_a_non_number() {
493        assert!(crate::run("range(0, 5, 0)").is_err());
494        assert!(crate::run("range(0, \"x\")").is_err());
495    }
496
497    /// **`length` counts CHARACTERS, as Ruby does — not bytes.** A `length`
498    /// that returns bytes is the bug that only surfaces once a user types a
499    /// non-ASCII character, which is exactly when it is hardest to trace.
500    #[test]
501    fn length_counts_characters_not_bytes() {
502        assert_eq!(i("length(\"hello\")"), 5);
503        // "héllo" is 6 bytes, 5 characters.
504        assert_eq!(i("length(\"héllo\")"), 5, "must not be 6");
505        // An emoji is 4 bytes, 1 character.
506        assert_eq!(i("length(\"😀\")"), 1, "must not be 4");
507    }
508
509    /// blue reports **scalar values**, not grapheme clusters. Elixir would say
510    /// 1 here; blue says 2. Stated and pinned rather than left to surprise.
511    #[test]
512    fn a_combining_sequence_counts_scalars_not_graphemes() {
513        // "e" + U+0301 COMBINING ACUTE — one grapheme, two scalars.
514        assert_eq!(
515            i("length(\"e\\u{301}\")"),
516            2,
517            "blue counts scalar values; Elixir's String.length would say 1"
518        );
519    }
520
521    #[test]
522    fn length_also_works_on_a_list() {
523        assert_eq!(i("length([1, 2, 3])"), 3);
524    }
525
526    #[test]
527    fn case_and_trim() {
528        assert_eq!(s("upcase(\"abc\")"), "ABC");
529        assert_eq!(s("downcase(\"ABC\")"), "abc");
530        assert_eq!(s("trim(\"  hi  \")"), "hi");
531        // Non-ASCII case works, which a byte-wise implementation would botch.
532        assert_eq!(s("upcase(\"é\")"), "É");
533    }
534
535    #[test]
536    fn concat_and_to_s() {
537        assert_eq!(s("concat(\"a\", \"b\")"), "ab");
538        assert_eq!(s("concat(\"n=\", 42)"), "n=42");
539        assert_eq!(s("to_s(42)"), "42");
540        assert_eq!(s("to_s(true)"), "true");
541    }
542
543    /// **`+` stays arithmetic.** Ruby overloads it on String, but blue's `+`
544    /// lowers to tatara's numeric `+`; making it polymorphic would let a type
545    /// error at a seam disappear into a string.
546    #[test]
547    fn plus_is_not_string_concatenation() {
548        assert!(
549            crate::run("\"a\" + \"b\"").is_err(),
550            "`+` must not silently concatenate — use concat"
551        );
552    }
553
554    #[test]
555    fn split_and_join() {
556        assert_eq!(i("length(split(\"a,b,c\", \",\"))"), 3);
557        assert_eq!(s("join(split(\"a,b,c\", \",\"), \"-\")"), "a-b-c");
558        // Ruby's `split("")` yields characters.
559        assert_eq!(i("length(split(\"abc\", \"\"))"), 3);
560    }
561
562    #[test]
563    fn predicates() {
564        assert!(matches!(
565            eval("contains?(\"hello\", \"ell\")"),
566            Value::Bool(true)
567        ));
568        assert!(matches!(
569            eval("contains?(\"hello\", \"xyz\")"),
570            Value::Bool(false)
571        ));
572        assert!(matches!(
573            eval("starts_with?(\"hello\", \"he\")"),
574            Value::Bool(true)
575        ));
576        assert!(matches!(
577            eval("ends_with?(\"hello\", \"lo\")"),
578            Value::Bool(true)
579        ));
580    }
581
582    #[test]
583    fn replace_and_chars() {
584        assert_eq!(s("replace(\"a-b-c\", \"-\", \"+\")"), "a+b+c");
585        assert_eq!(i("length(chars(\"abc\"))"), 3);
586    }
587
588    /// Reversed by character, so a multi-byte character survives. A byte-wise
589    /// reverse produces invalid UTF-8.
590    #[test]
591    fn reverse_is_character_wise() {
592        assert_eq!(s("reverse(\"abc\")"), "cba");
593        assert_eq!(s("reverse(\"héllo\")"), "olléh", "must not corrupt the é");
594    }
595
596    #[test]
597    fn reverse_also_works_on_a_list() {
598        assert_eq!(s("join(reverse([1, 2, 3]), \",\")"), "3,2,1");
599    }
600
601    /// **`to_int` answers nil on garbage, not 0.** Ruby's `String#to_i` returns
602    /// 0, which silently turns a parse failure into a plausible number — a
603    /// deliberate divergence, and the reason `to_int!` exists for callers who
604    /// want the failure loudly.
605    #[test]
606    fn to_int_is_nil_on_garbage_rather_than_zero() {
607        assert_eq!(i("to_int(\"42\")"), 42);
608        assert!(
609            matches!(eval("to_int(\"banana\")"), Value::Nil),
610            "Ruby would say 0 here; a falsy nil cannot be mistaken for a result"
611        );
612        assert!(
613            matches!(eval("to_int(\"0\")"), Value::Int(0)),
614            "and a real 0 is still a real 0 — the two must stay distinguishable"
615        );
616    }
617
618    #[test]
619    fn to_int_bang_raises_on_garbage() {
620        assert_eq!(i("to_int!(\"42\")"), 42);
621        let err = crate::run("to_int!(\"banana\")").expect_err("must raise");
622        assert!(err.to_string().contains("banana"), "must name it: {err}");
623    }
624
625    #[test]
626    fn numeric_conversions_and_abs() {
627        assert_eq!(i("to_int(3.9)"), 3);
628        assert_eq!(i("abs(0 - 5)"), 5);
629        assert!(matches!(eval("to_float(\"1.5\")"), Value::Float(_)));
630        assert!(matches!(eval("to_float(\"nope\")"), Value::Nil));
631    }
632
633    /// A wrong-typed argument is a typed error, not a silent coercion.
634    #[test]
635    fn a_non_string_argument_is_a_type_error() {
636        assert!(crate::run("upcase([1, 2])").is_err());
637        assert!(crate::run("join(\"not a list\", \",\")").is_err());
638    }
639}