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#[derive(PartialEq, Eq, Hash)]
140enum EqualKey<'a> {
141    Nil,
142    Bool(bool),
143    Number(u64),
144    Str(&'a str),
145    Symbol(&'a str),
146    Keyword(&'a str),
147}
148
149fn number_key(n: f64) -> Option<EqualKey<'static>> {
150    (!n.is_nan()).then(|| EqualKey::Number(if n == 0.0 { 0 } else { n.to_bits() }))
151}
152
153fn equal_key(v: &Value) -> Option<EqualKey<'_>> {
154    match v {
155        Value::Nil => Some(EqualKey::Nil),
156        Value::Bool(b) => Some(EqualKey::Bool(*b)),
157        Value::Int(i) => number_key(*i as f64),
158        Value::Float(f) => number_key(*f),
159        Value::Str(s) => Some(EqualKey::Str(s)),
160        Value::Symbol(s) => Some(EqualKey::Symbol(s)),
161        Value::Keyword(s) => Some(EqualKey::Keyword(s)),
162        _ => None,
163    }
164}
165
166/// A map from each element to how many times it occurs, keyed as tatara's
167/// `hash-map` keys (an Int and a Float are two keys): the meaning of tatara's
168/// `frequencies`, built in one native pass.
169///
170/// # Errors
171///
172/// An element that cannot be a map key, as `hash-map` refuses it.
173pub fn frequencies<'a>(
174    xs: impl Iterator<Item = &'a Value>,
175    span: tatara_lisp::Span,
176) -> Result<Value, EvalError> {
177    let mut counts = tatara_lisp_eval::Map::new();
178    for x in xs {
179        let key = tatara_lisp_eval::value::MapKey::from_value(x).ok_or_else(|| {
180            EvalError::native_fn(
181                "hash-map",
182                format!("non-hashable key kind: {}", x.type_name()),
183                span,
184            )
185        })?;
186        let n = match counts.get(&key) {
187            Some(Value::Int(n)) => n + 1,
188            _ => 1,
189        };
190        counts.insert(key, Value::Int(n));
191    }
192    Ok(Value::Map(std::sync::Arc::new(counts)))
193}
194
195/// The elements with repeats removed under [`blue_equal`], first occurrence
196/// kept: the meaning of tatara's `distinct`, in linear time for the scalars.
197///
198/// Two values `blue_equal` calls equal always share a key, so a value is
199/// compared only with the kept values of its own key; values with no key
200/// (lists, maps, functions, NaN) are compared with every kept keyless value.
201pub fn distinct<'a>(xs: impl Iterator<Item = &'a Value>) -> Vec<Value> {
202    let mut kept: Vec<Value> = Vec::new();
203    let mut buckets: std::collections::HashMap<EqualKey<'a>, Vec<usize>> =
204        std::collections::HashMap::new();
205    let mut keyless: Vec<usize> = Vec::new();
206    for x in xs {
207        let seen = match equal_key(x) {
208            Some(k) => buckets.entry(k).or_default(),
209            None => &mut keyless,
210        };
211        if !seen.iter().any(|&i| blue_equal(&kept[i], x)) {
212            seen.push(kept.len());
213            kept.push(x.clone());
214        }
215    }
216    kept
217}
218
219/// `to_s` (okite D0005, D0006): text for a person. A string, symbol or keyword
220/// is its own text and nil is empty, as in Ruby; every other value is its blue
221/// literal. Until 2026-09-27 a map rendered as the word `map`, a list lost its
222/// brackets (`[1, {a: 2}]` came out `1 map`) and `1.0` came out `1`.
223fn chain_rules_value() -> Value {
224    use blue_lang_check::chains::{cases, Case, Kind, RULES};
225    let sym = |s: &str| Value::Symbol(std::sync::Arc::from(s));
226    let text = |s: &str| Value::Str(std::sync::Arc::from(s));
227    let hole = |n: usize| (0..n).map(|_| sym("x")).collect::<Vec<_>>();
228    let lambda = |body: Value| Value::list([sym("lambda"), Value::list([sym("x")]), body]);
229    let call = |name: &str, args: Vec<Value>| Value::list(std::iter::once(sym(name)).chain(args));
230    Value::list(RULES.iter().map(|rule| {
231        let rows = cases(rule).into_iter().map(|case| match case {
232            Case::Call { inner, argc } => {
233                let inner_call = call(inner, hole(argc));
234                Value::list([
235                    text(&format!("{}({}(x))", rule.outer, inner)),
236                    Value::Nil,
237                    lambda(call(rule.outer, vec![inner_call.clone()])),
238                    lambda(inner_call),
239                ])
240            }
241            Case::Literal(kind) => Value::list([
242                text(&format!("{}(<literal>)", rule.outer)),
243                Value::Keyword(std::sync::Arc::from(match kind {
244                    Kind::Text => "text",
245                    Kind::Int => "int",
246                    Kind::Float => "float",
247                })),
248                lambda(call(rule.outer, vec![sym("x")])),
249                lambda(sym("x")),
250            ]),
251        });
252        Value::list([
253            text(rule.id),
254            text(rule.slug),
255            text(rule.outer),
256            text(rule.soundness),
257            Value::list(rows),
258        ])
259    }))
260}
261
262fn render(v: &Value) -> String {
263    match v {
264        Value::Nil => String::new(),
265        Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => s.to_string(),
266        Value::Error(e) => crate::errors::describe(e),
267        other => literal(other),
268    }
269}
270
271/// A value as blue source writes it: `[1, "a", nil]`, `{a: 1, b: 2.0}`.
272/// Map entries are sorted by key, since a map has no order of its own and the
273/// text must be the same for equal maps (D0006).
274fn literal(v: &Value) -> String {
275    match v {
276        Value::Nil => "nil".into(),
277        Value::Bool(b) => b.to_string(),
278        Value::Int(n) => n.to_string(),
279        Value::Float(x) => float_text(*x),
280        Value::Str(s) => quoted(s),
281        Value::Symbol(s) => s.to_string(),
282        Value::Keyword(s) => format!(":{s}"),
283        Value::List(items) => {
284            let parts: Vec<String> = items.iter().map(literal).collect();
285            format!("[{}]", parts.join(", "))
286        }
287        Value::Map(m) => {
288            let mut entries: Vec<String> = m
289                .iter()
290                .map(|(k, v)| format!("{} {}", key_text(k), literal(v)))
291                .collect();
292            entries.sort();
293            format!("{{{}}}", entries.join(", "))
294        }
295        other => other.type_name().to_string(),
296    }
297}
298
299/// A map key as it is written before its value: `a:` for a keyword or symbol,
300/// `"a" =>` / `1 =>` for anything else.
301fn key_text(k: &tatara_lisp_eval::value::MapKey) -> String {
302    use tatara_lisp_eval::value::MapKey;
303    match k {
304        MapKey::Keyword(s) | MapKey::Symbol(s) => format!("{s}:"),
305        MapKey::Str(s) => format!("{} =>", quoted(s)),
306        MapKey::Int(n) => format!("{n} =>"),
307        MapKey::Float(bits) => format!("{} =>", float_text(f64::from_bits(*bits))),
308        MapKey::Bool(b) => format!("{b} =>"),
309        MapKey::Nil => "nil =>".into(),
310    }
311}
312
313/// A float keeps its point, as in Ruby: `1.0`, `2.5`.
314fn float_text(x: f64) -> String {
315    if x.is_finite() && x.fract() == 0.0 && x.abs() < 1e16 {
316        format!("{x:.1}")
317    } else {
318        format!("{x}")
319    }
320}
321
322fn quoted(s: &str) -> String {
323    let mut out = String::with_capacity(s.len() + 2);
324    out.push('"');
325    for c in s.chars() {
326        match c {
327            '"' => out.push_str("\\\""),
328            '\\' => out.push_str("\\\\"),
329            '\n' => out.push_str("\\n"),
330            '\t' => out.push_str("\\t"),
331            c => out.push(c),
332        }
333    }
334    out.push('"');
335    out
336}
337
338fn list(items: Vec<Value>) -> Value {
339    Value::list(items)
340}
341
342/// Install blue's string and number core.
343pub fn install_blue_stdlib<H: 'static>(interp: &mut Interpreter<H>) {
344    crate::errors::install(interp);
345    crate::paths::install(interp);
346    crate::spread::install(interp);
347    crate::collection::install(interp);
348    // ── text ──────────────────────────────────────────────────────────
349
350    // `length` counts CHARACTERS, per Ruby. Also accepts a list, where it is
351    // the element count — Ruby's `Array#length`.
352    interp.register_fn(
353        "length",
354        Arity::Exact(1),
355        |a: &[Value], _h: &mut H, span| match &a[0] {
356            Value::List(items) => Ok(Value::Int(items.len() as i64)),
357            other => Ok(Value::Int(as_str(other, span)?.chars().count() as i64)),
358        },
359    );
360
361    // The documented builtins, for a program that reasons about names (the
362    // catalogue's "shares a builtin's name" column).
363    interp.register_fn(
364        "builtin_names",
365        Arity::Exact(0),
366        |_a: &[Value], _h: &mut H, _s| {
367            Ok(Value::list(
368                crate::docs::NAMES
369                    .iter()
370                    .map(|n| Value::Str(std::sync::Arc::from(n.name))),
371            ))
372        },
373    );
374    interp.register_fn(
375        "chain_rules",
376        Arity::Exact(0),
377        |_a: &[Value], _h: &mut H, _s| Ok(chain_rules_value()),
378    );
379    interp.register_fn(
380        "volatile",
381        Arity::Exact(1),
382        |a: &[Value], _h: &mut H, s| match &a[0] {
383            Value::Closure(_) | Value::NativeFn(_) | Value::Foreign(_) => Ok(Value::Nil),
384            other => Err(EvalError::type_mismatch("function", other.type_name(), s)),
385        },
386    );
387    interp.register_fn("to_s", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
388        Ok(Value::Str(render(&a[0]).into()))
389    });
390
391    interp.register_fn("upcase", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
392        Ok(Value::Str(as_str(&a[0], s)?.to_uppercase().into()))
393    });
394
395    interp.register_fn("downcase", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
396        Ok(Value::Str(as_str(&a[0], s)?.to_lowercase().into()))
397    });
398
399    interp.register_fn("trim", Arity::Range(1, 2), |a: &[Value], _h: &mut H, s| {
400        let text = as_str(&a[0], s)?;
401        trim_with(&text, a.get(1).unwrap_or(&Value::Nil), s)
402    });
403
404    interp.register_fn(
405        blue_lang_syntax::LOWERED_OPTIONAL_ARITY,
406        Arity::Exact(4),
407        |a: &[Value], _h: &mut H, s| {
408            let name = as_str(&a[0], s)?;
409            let (Value::List(rest), Value::Int(required), Value::Int(most)) = (&a[1], &a[2], &a[3])
410            else {
411                return Err(EvalError::native_fn(
412                    blue_lang_syntax::LOWERED_OPTIONAL_ARITY,
413                    "expects a name, the rest list and two counts",
414                    s,
415                ));
416            };
417            if rest.len() as i64 > *most {
418                let want = Arity::Range(*required as usize, (*required + *most) as usize);
419                return Err(EvalError::native_fn(
420                    name.as_str(),
421                    format!(
422                        "takes {}, got {}",
423                        crate::messages::arguments(&want),
424                        *required as usize + rest.len()
425                    ),
426                    s,
427                ));
428            }
429            Ok(Value::Nil)
430        },
431    );
432
433    interp.register_fn(
434        blue_lang_syntax::LOWERED_OPTIONAL_ARG,
435        Arity::Exact(2),
436        |a: &[Value], _h: &mut H, s| match (&a[0], &a[1]) {
437            (Value::List(rest), Value::Int(i)) => {
438                Ok(rest.get(*i as usize).cloned().unwrap_or(Value::Nil))
439            }
440            _ => Err(EvalError::native_fn(
441                blue_lang_syntax::LOWERED_OPTIONAL_ARG,
442                "expects the rest list and an index",
443                s,
444            )),
445        },
446    );
447
448    // `concat(a, …)` joins the text of one or more arguments, left to right
449    // (okite D0007; Ruby's String#concat takes many). Two-only until
450    // 2026-09-27, which made `concat(a, b, c)` an arity error. `+` stays
451    // arithmetic: Ruby overloads `+` on String, but blue's `+` lowers to
452    // tatara's numeric `+`, and making it polymorphic would make a type error
453    // at a seam disappear into a string.
454    interp.register_fn(
455        "concat",
456        Arity::AtLeast(1),
457        |a: &[Value], _h: &mut H, _s| {
458            Ok(Value::Str(a.iter().map(render).collect::<String>().into()))
459        },
460    );
461
462    // ── equality and kinds (okite D0001–D0004, D0010) ─────────────────────
463    //
464    // `==` lowers to `equal?` and `!=` to `not=`; both are redefined here so
465    // blue's interpreter has one equality. See `blue_equal`.
466    interp.register_fn("equal?", Arity::Exact(2), |a: &[Value], _h: &mut H, _s| {
467        Ok(Value::Bool(blue_equal(&a[0], &a[1])))
468    });
469    interp.register_fn("not=", Arity::Exact(2), |a: &[Value], _h: &mut H, _s| {
470        Ok(Value::Bool(!blue_equal(&a[0], &a[1])))
471    });
472    interp.register_fn(
473        "distinct",
474        Arity::Exact(1),
475        |a: &[Value], _h: &mut H, span| match &a[0] {
476            Value::Nil => Ok(Value::list(std::iter::empty())),
477            Value::List(xs) => Ok(Value::list(distinct(xs.iter()))),
478            other => Err(EvalError::type_mismatch("a list", other.type_name(), span)),
479        },
480    );
481    interp.register_fn(
482        "frequencies",
483        Arity::Exact(1),
484        |a: &[Value], _h: &mut H, span| match &a[0] {
485            Value::Nil => Ok(frequencies(std::iter::empty(), span)?),
486            Value::List(xs) => Ok(frequencies(xs.iter(), span)?),
487            other => Err(EvalError::type_mismatch("a list", other.type_name(), span)),
488        },
489    );
490
491    // One predicate per kind, and exactly one holds of any value (D0010).
492    // tatara's `nil?` is its Scheme alias for `null?`, true of `[]` as well,
493    // and its `list?` is true of nil; blue's nil is absent and `[]` is an empty
494    // list (D0004), so blue redefines both. tatara's own library ends its
495    // recursion with `null?`, which is untouched.
496    interp.register_fn("nil?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
497        Ok(Value::Bool(matches!(a[0], Value::Nil)))
498    });
499    interp.register_fn("list?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
500        Ok(Value::Bool(matches!(a[0], Value::List(_))))
501    });
502    interp.register_fn("map?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
503        Ok(Value::Bool(matches!(a[0], Value::Map(_))))
504    });
505    interp.register_fn("float?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
506        Ok(Value::Bool(matches!(a[0], Value::Float(_))))
507    });
508    interp.register_fn("bool?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
509        Ok(Value::Bool(matches!(a[0], Value::Bool(_))))
510    });
511
512    // The empty list is always `[]`, never nil (D0011). tatara's `cdr` (and so
513    // `rest`, which is `cdr`) and `append` returned nil for an empty result,
514    // Scheme's convention, which under D0004 made `rest([1]) == []` false.
515    // Same contracts otherwise: `cdr` of an empty list is still an error.
516    interp.register_fn("cdr", Arity::Exact(1), |a: &[Value], _h: &mut H, span| {
517        match &a[0] {
518            // Shares the tail's structure: O(log n), not a copy of it.
519            Value::List(xs) if !xs.is_empty() => Ok(Value::List(std::sync::Arc::new(xs.skip(1)))),
520            Value::Nil | Value::List(_) => Err(EvalError::native_fn(
521                std::sync::Arc::<str>::from("cdr"),
522                "cdr of empty list",
523                span,
524            )),
525            other => Err(EvalError::type_mismatch("pair", other.type_name(), span)),
526        }
527    });
528    interp.register_owned_fn("append", Arity::Any, |a: Vec<Value>, _h: &mut H, span| {
529        // Persistent concatenation: each operand's structure is shared, so
530        // building a list by `append(acc, [x])` is linear overall, not
531        // quadratic (G7). The arguments arrive by value, so an accumulator
532        // the caller holds alone (tatara-lisp-eval runs a tail call to an
533        // owned primitive after releasing the caller's frame) is extended in
534        // its own allocation by `Arc::make_mut` instead of copied at the tail.
535        let mut lists = Vec::with_capacity(a.len());
536        for v in a {
537            match v {
538                Value::Nil => {}
539                Value::List(xs) => lists.push(xs),
540                other => return Err(EvalError::type_mismatch("list", other.type_name(), span)),
541            }
542        }
543        let mut lists = lists.into_iter();
544        let Some(mut out) = lists.next() else {
545            return Ok(Value::List(std::sync::Arc::new(
546                tatara_lisp_eval::List::new(),
547            )));
548        };
549        let acc = std::sync::Arc::make_mut(&mut out);
550        for xs in lists {
551            acc.append(
552                std::sync::Arc::try_unwrap(xs)
553                    .unwrap_or_else(|shared| tatara_lisp_eval::List::clone(&shared)),
554            );
555        }
556        Ok(Value::List(out))
557    });
558
559    interp.register_fn("split", Arity::Exact(2), |a: &[Value], _h: &mut H, s| {
560        let text = as_str(&a[0], s)?;
561        let sep = as_str(&a[1], s)?;
562        // An empty separator splits into characters, as Ruby's `split("")`
563        // does. Rust's `split("")` yields leading/trailing empties instead,
564        // which is the wrong answer here.
565        // Every field is kept: n separators give n + 1 fields, so `join` undoes
566        // `split` exactly (okite D0012). A deliberate deviation from Ruby, which
567        // drops trailing empty fields: tried on 2026-09-27, it broke nisshi's
568        // hash chain, kueri's scripts and moji's `lines`, because a parser whose
569        // split loses a field loses data and still reports success.
570        let parts: Vec<Value> = if sep.is_empty() {
571            text.chars()
572                .map(|c| Value::Str(c.to_string().into()))
573                .collect()
574        } else {
575            text.split(sep.as_str())
576                .map(|p| Value::Str(p.into()))
577                .collect()
578        };
579        Ok(list(parts))
580    });
581
582    interp.register_fn("join", Arity::Exact(2), |a: &[Value], _h: &mut H, s| {
583        let sep = as_str(&a[1], s)?;
584        match &a[0] {
585            Value::List(items) => Ok(Value::Str(
586                items
587                    .iter()
588                    .map(render)
589                    .collect::<Vec<_>>()
590                    .join(&sep)
591                    .into(),
592            )),
593            other => Err(EvalError::type_mismatch("a list", other.type_name(), s).into()),
594        }
595    });
596
597    interp.register_fn(
598        "join_lines",
599        Arity::Exact(1),
600        |a: &[Value], _h: &mut H, s| match &a[0] {
601            Value::List(items) => Ok(Value::Str(
602                items
603                    .iter()
604                    .map(render)
605                    .collect::<Vec<_>>()
606                    .join("\n")
607                    .into(),
608            )),
609            other => Err(EvalError::type_mismatch("a list", other.type_name(), s).into()),
610        },
611    );
612
613    interp.register_fn(
614        "unlines",
615        Arity::Exact(1),
616        |a: &[Value], _h: &mut H, s| match &a[0] {
617            Value::List(items) => Ok(Value::Str(
618                items
619                    .iter()
620                    .map(|v| render(v) + "\n")
621                    .collect::<String>()
622                    .into(),
623            )),
624            other => Err(EvalError::type_mismatch("a list", other.type_name(), s).into()),
625        },
626    );
627
628    interp.register_fn(
629        "line_break",
630        Arity::Exact(0),
631        |_a: &[Value], _h: &mut H, _s| Ok(Value::Str("\n".into())),
632    );
633
634    interp.register_fn(
635        "contains?",
636        Arity::Exact(2),
637        |a: &[Value], _h: &mut H, s| {
638            Ok(Value::Bool(as_str(&a[0], s)?.contains(&as_str(&a[1], s)?)))
639        },
640    );
641
642    interp.register_fn(
643        "starts_with?",
644        Arity::Exact(2),
645        |a: &[Value], _h: &mut H, s| {
646            Ok(Value::Bool(
647                as_str(&a[0], s)?.starts_with(&as_str(&a[1], s)?),
648            ))
649        },
650    );
651
652    interp.register_fn(
653        "ends_with?",
654        Arity::Exact(2),
655        |a: &[Value], _h: &mut H, s| {
656            Ok(Value::Bool(as_str(&a[0], s)?.ends_with(&as_str(&a[1], s)?)))
657        },
658    );
659
660    interp.register_fn(
661        "text_index",
662        Arity::Exact(3),
663        |a: &[Value], _h: &mut H, s| {
664            Ok(Value::Int(text_index(
665                &as_str(&a[0], s)?,
666                &as_str(&a[1], s)?,
667                as_index(&a[2], s)?,
668            )))
669        },
670    );
671
672    interp.register_fn(
673        "text_last_index",
674        Arity::Exact(3),
675        |a: &[Value], _h: &mut H, s| {
676            Ok(Value::Int(text_last_index(
677                &as_str(&a[0], s)?,
678                &as_str(&a[1], s)?,
679                as_index(&a[2], s)?,
680            )))
681        },
682    );
683
684    interp.register_fn("replace", Arity::Exact(3), |a: &[Value], _h: &mut H, s| {
685        Ok(Value::Str(
686            as_str(&a[0], s)?
687                .replace(&as_str(&a[1], s)?, &as_str(&a[2], s)?)
688                .into(),
689        ))
690    });
691
692    interp.register_fn("reverse", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
693        match &a[0] {
694            Value::List(items) => Ok(Value::list(items.iter().rev().cloned())),
695            // Reversed by CHARACTER, so a multi-byte character survives. A
696            // byte-wise reverse produces invalid UTF-8.
697            other => Ok(Value::Str(
698                as_str(other, s)?.chars().rev().collect::<String>().into(),
699            )),
700        }
701    });
702
703    interp.register_fn("chars", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
704        Ok(list(
705            as_str(&a[0], s)?
706                .chars()
707                .map(|c| Value::Str(c.to_string().into()))
708                .collect(),
709        ))
710    });
711
712    interp.register_fn(
713        "text_bytes",
714        Arity::Exact(1),
715        |a: &[Value], _h: &mut H, s| {
716            Ok(list(
717                as_str(&a[0], s)?
718                    .as_bytes()
719                    .iter()
720                    .map(|b| Value::Int(i64::from(*b)))
721                    .collect(),
722            ))
723        },
724    );
725
726    interp.register_fn(
727        "bytes_text",
728        Arity::Exact(1),
729        |a: &[Value], _h: &mut H, s| {
730            let bytes = crate::crypto::message_bytes(&a[0])
731                .map_err(|e| EvalError::native_fn("bytes_text", e.to_string(), s))?;
732            Ok(match String::from_utf8(bytes) {
733                Ok(text) => Value::Str(text.into()),
734                Err(_) => Value::Nil,
735            })
736        },
737    );
738
739    // ── numbers ───────────────────────────────────────────────────────
740
741    // `to_int` RETURNS NIL on unparseable input rather than raising. Ruby's
742    // `String#to_i` answers 0 for garbage, which silently turns a parse failure
743    // into a plausible number; nil is falsy and cannot be mistaken for a
744    // result. `to_int!` is the raising form for callers who want the failure.
745    interp.register_fn(
746        "to_int",
747        Arity::Exact(1),
748        |a: &[Value], _h: &mut H, s| match &a[0] {
749            Value::Int(n) => Ok(Value::Int(*n)),
750            // A float with no Int (non-finite, or past i64) is nil like any
751            // other unconvertible input; `as i64` saturated it to a plausible
752            // wrong number.
753            Value::Float(x) => Ok(float_to_int(*x).map_or(Value::Nil, Value::Int)),
754            other => Ok(as_str(other, s)?
755                .trim()
756                .parse::<i64>()
757                .map_or(Value::Nil, Value::Int)),
758        },
759    );
760
761    interp.register_fn(
762        "to_int!",
763        Arity::Exact(1),
764        |a: &[Value], _h: &mut H, s| match &a[0] {
765            Value::Int(n) => Ok(Value::Int(*n)),
766            Value::Float(x) => float_to_int(*x)
767                .map(Value::Int)
768                .ok_or(EvalError::IntegerOverflow {
769                    op: "to_int!",
770                    at: s,
771                }),
772            other => {
773                let text = as_str(other, s)?;
774                text.trim().parse::<i64>().map(Value::Int).map_err(|_| {
775                    EvalError::native_fn(
776                        "to_int!",
777                        "`".to_string() + &text + "` is not an integer",
778                        s,
779                    )
780                    .into()
781                })
782            }
783        },
784    );
785
786    interp.register_fn(
787        "to_float",
788        Arity::Exact(1),
789        |a: &[Value], _h: &mut H, s| match &a[0] {
790            Value::Float(x) => Ok(Value::Float(*x)),
791            Value::Int(n) => Ok(Value::Float(*n as f64)),
792            other => Ok(as_str(other, s)?
793                .trim()
794                .parse::<f64>()
795                .map_or(Value::Nil, Value::Float)),
796        },
797    );
798
799    interp.register_fn(
800        "abs",
801        Arity::Exact(1),
802        |a: &[Value], _h: &mut H, s| match &a[0] {
803            // Checked, as tatara's `abs` is (G5): |i64::MIN| does not fit, and
804            // `n.abs()` wrapped in release and panicked in debug.
805            Value::Int(n) => n
806                .checked_abs()
807                .map(Value::Int)
808                .ok_or(EvalError::IntegerOverflow { op: "abs", at: s }),
809            Value::Float(x) => Ok(Value::Float(x.abs())),
810            other => Err(EvalError::type_mismatch("a number", other.type_name(), s).into()),
811        },
812    );
813
814    // ── ranges ────────────────────────────────────────────────────────
815
816    // `range` is a LOOP here, not a recursion. tatara-lisp's stdlib defines it
817    // in Lisp (`lisp_stdlib.tlisp`, where `range-impl` conses one frame per
818    // element), so `range(0, 5000)` overflowed the 8 MiB main stack and
819    // aborted the process. Everything built on it inherited the ceiling:
820    // retsu's `indexes`, `zip_with` and `enumerate`, and shomei's
821    // `chain_first_break`, which could not verify a 5,000-entry chain
822    // (measured 2026-09-24: 4,000 elements fine, 5,000 aborted).
823    //
824    // Same arities and answers as the Lisp definition: (end), (start, end),
825    // (start, end, step), ascending for a positive step, descending for a
826    // negative one, and each next element is the previous plus the step under
827    // the ordinary number rules (Int + Int is Int; a Float anywhere makes the
828    // rest Float). Two refusals the Lisp version lacked: a zero step, which
829    // recursed forever, and a wrong arity, which printed a message and
830    // returned nil.
831    //
832    // The destination is upstream: when tatara-lisp's own `range` is a loop,
833    // this registration is deleted.
834    interp.register_fn("range", Arity::Range(1, 3), |a: &[Value], _h: &mut H, s| {
835        let (start, end, step) = match a.len() {
836            1 => (Value::Int(0), a[0].clone(), Value::Int(1)),
837            2 => (a[0].clone(), a[1].clone(), Value::Int(1)),
838            _ => (a[0].clone(), a[1].clone(), a[2].clone()),
839        };
840        range_list(start, &end, &step, s)
841    });
842
843    // `write_stdout(s)` / `write_stderr(s)`: the text exactly, no quotes, no
844    // newline added. `print`, `println` and `display` all render a string
845    // with its quotes (a value printer, right for a REPL and wrong for a
846    // command's output), so until these a blue command-line tool could not
847    // print plain text. Output only, like `println`; anything else is not a
848    // string and is refused.
849    interp.register_fn(
850        "write_stdout",
851        Arity::Exact(1),
852        |a: &[Value], _h: &mut H, s| {
853            use std::io::Write;
854            let text = as_str(&a[0], s)?;
855            let mut out = std::io::stdout().lock();
856            out.write_all(text.as_bytes())
857                .and_then(|()| out.flush())
858                .map_err(|e| EvalError::native_fn("write_stdout", e.to_string(), s))?;
859            Ok(Value::Nil)
860        },
861    );
862    interp.register_fn(
863        "write_stderr",
864        Arity::Exact(1),
865        |a: &[Value], _h: &mut H, s| {
866            use std::io::Write;
867            let text = as_str(&a[0], s)?;
868            let mut err = std::io::stderr().lock();
869            err.write_all(text.as_bytes())
870                .and_then(|()| err.flush())
871                .map_err(|e| EvalError::native_fn("write_stderr", e.to_string(), s))?;
872            Ok(Value::Nil)
873        },
874    );
875
876    // `sort_keyed(key, xs)`: xs ordered by key(x), stably, in O(n log n),
877    // under `compare`'s rules. Every sort written in blue (junjo's) recurses
878    // per element and aborts the process near 600 elements; this is
879    // tatara-lisp's `sort-by-key` (0.3.60), which blue cannot name because
880    // it is kebab-case, bound to a name blue can. The engine is upstream,
881    // `hof::sort_keyed_values`; only the spelling lives here.
882    interp.register_higher_order_fn(
883        "sort_keyed",
884        Arity::Exact(2),
885        |a: &[Value], host: &mut H, caller: &tatara_lisp_eval::ffi::Caller<H>, s| {
886            let xs = match &a[1] {
887                Value::List(xs) => xs.to_vec(),
888                Value::Nil => Vec::new(),
889                other => {
890                    return Err(EvalError::type_mismatch("a list", other.type_name(), s));
891                }
892            };
893            let mut keyed = Vec::with_capacity(xs.len());
894            for x in xs {
895                keyed.push((caller.apply_value(&a[0], vec![x.clone()], host, s)?, x));
896            }
897            Ok(list(tatara_lisp_eval::hof::sort_keyed_values(keyed, s)?))
898        },
899    );
900
901    interp.register_fn(
902        "error_message",
903        Arity::Exact(1),
904        |a: &[Value], _h: &mut H, s| match &a[0] {
905            Value::Error(e) => Ok(Value::Str(e.message.clone())),
906            other => Err(EvalError::type_mismatch("an error", other.type_name(), s)),
907        },
908    );
909
910    interp.register_fn(
911        "error_kind",
912        Arity::Exact(1),
913        |a: &[Value], _h: &mut H, s| match &a[0] {
914            Value::Error(e) => Ok(Value::Keyword(e.tag.clone())),
915            other => Err(EvalError::type_mismatch("an error", other.type_name(), s)),
916        },
917    );
918
919    interp.register_fn(
920        "map_entries",
921        Arity::Exact(1),
922        |a: &[Value], _h: &mut H, s| match &a[0] {
923            Value::Map(m) => {
924                let keyed = m
925                    .iter()
926                    .map(|(k, v)| (k.to_value(), list(vec![k.to_value(), v.clone()])))
927                    .collect();
928                Ok(list(tatara_lisp_eval::hof::sort_keyed_values(keyed, s)?))
929            }
930            Value::Nil => Ok(list(Vec::new())),
931            other => Err(EvalError::type_mismatch("a map", other.type_name(), s)),
932        },
933    );
934}
935
936fn as_index(v: &Value, span: tatara_lisp::Span) -> Result<i64, EvalError> {
937    match v {
938        Value::Int(n) => Ok(*n),
939        other => Err(EvalError::type_mismatch(
940            "an integer",
941            other.type_name(),
942            span,
943        )),
944    }
945}
946
947fn text_index(text: &str, sub: &str, from: i64) -> i64 {
948    let from = usize::try_from(from.max(0)).unwrap_or(usize::MAX);
949    let start = match text.char_indices().nth(from) {
950        Some((b, _)) => b,
951        None if text.chars().count() == from => text.len(),
952        None => return -1,
953    };
954    text[start..].find(sub).map_or(-1, |b| {
955        i64::try_from(from + text[start..start + b].chars().count()).unwrap_or(i64::MAX)
956    })
957}
958
959fn text_last_index(text: &str, sub: &str, from: i64) -> i64 {
960    let Ok(from) = usize::try_from(from) else {
961        return -1;
962    };
963    let total = text.chars().count();
964    let width = sub.chars().count();
965    if width > total {
966        return -1;
967    }
968    let limit = from.min(total - width);
969    let end = text
970        .char_indices()
971        .nth(limit + width)
972        .map_or(text.len(), |(b, _)| b);
973    text[..end].rfind(sub).map_or(-1, |b| {
974        i64::try_from(text[..b].chars().count()).unwrap_or(i64::MAX)
975    })
976}
977
978/// A number's value as a float, or a type error naming `range`.
979fn range_num(v: &Value, s: tatara_lisp::Span) -> Result<f64, EvalError> {
980    match v {
981        Value::Int(n) => Ok(*n as f64),
982        Value::Float(x) => Ok(*x),
983        other => Err(EvalError::native_fn(
984            "range",
985            "expected a number, got ".to_string() + other.type_name(),
986            s,
987        )),
988    }
989}
990
991/// A float's integer part as an `i64`, or `None` when it is not finite or
992/// does not fit. `as i64` saturates instead.
993fn float_to_int(x: f64) -> Option<i64> {
994    const LIMIT: f64 = 9_223_372_036_854_775_808.0;
995    let t = x.trunc();
996    #[allow(clippy::cast_possible_truncation)]
997    (t.is_finite() && (-LIMIT..LIMIT).contains(&t)).then(|| t as i64)
998}
999
1000/// `a + b` under the number rules the Lisp `range` used: Int + Int stays Int,
1001/// anything with a Float is Float.
1002fn range_add(a: &Value, b: &Value, s: tatara_lisp::Span) -> Result<Value, EvalError> {
1003    match (a, b) {
1004        (Value::Int(x), Value::Int(y)) => x.checked_add(*y).map(Value::Int).ok_or_else(|| {
1005            EvalError::native_fn("range", "an element overflowed a 64-bit integer", s)
1006        }),
1007        _ => Ok(Value::Float(range_num(a, s)? + range_num(b, s)?)),
1008    }
1009}
1010
1011fn range_list(
1012    start: Value,
1013    end: &Value,
1014    step: &Value,
1015    s: tatara_lisp::Span,
1016) -> Result<Value, EvalError> {
1017    let end_f = range_num(end, s)?;
1018    let step_f = range_num(step, s)?;
1019    range_num(&start, s)?;
1020    if step_f == 0.0 {
1021        return Err(EvalError::native_fn(
1022            "range",
1023            "a step of zero never reaches the end",
1024            s,
1025        ));
1026    }
1027    let mut out = Vec::new();
1028    let mut cur = start;
1029    loop {
1030        let c = range_num(&cur, s)?;
1031        if (step_f > 0.0 && c >= end_f) || (step_f < 0.0 && c <= end_f) {
1032            break;
1033        }
1034        let next = range_add(&cur, step, s)?;
1035        out.push(cur);
1036        cur = next;
1037    }
1038    Ok(list(out))
1039}
1040
1041#[cfg(test)]
1042mod tests {
1043    use super::*;
1044
1045    #[test]
1046    fn sort_keyed_is_stable_keyed_and_deep() {
1047        // Ties keep input order; the key is computed per element.
1048        assert_eq!(
1049            ints("map(fn(p) last(p) end, sort_keyed(fn(p) first(p) end, [[2, 1], [1, 2], [2, 3], [1, 4]]))"),
1050            vec![2, 4, 1, 3]
1051        );
1052        // 20,000 in reverse: every blue-written sort aborts far below this.
1053        assert_eq!(
1054            i("first(sort_keyed(fn(x) x end, reverse(range(0, 20000))))"),
1055            0
1056        );
1057        assert_eq!(i("last(sort_keyed(fn(x) 0 - x end, range(0, 20000)))"), 0);
1058        // The empty case, and a refusal for keys that cannot be ordered.
1059        assert_eq!(ints("sort_keyed(fn(x) x end, [])"), Vec::<i64>::new());
1060        assert!(crate::run("sort_keyed(fn(x) x end, [1, \"a\"])").is_err());
1061    }
1062
1063    #[test]
1064    fn distinct_is_native_and_keeps_tataras_meaning() {
1065        assert!(matches!(
1066            crate::interpreter_hostless().lookup_global("distinct"),
1067            Some(Value::NativeFn(_))
1068        ));
1069        let corpus = eval(
1070            "[1, 1.0, 0, -0.0, 0.0, 9007199254740993, 9007199254740992, 9007199254740992.0, \
1071             \"a\", :a, \"a\", nil, nil, true, false, true, [1, 2], [1, 2.0], [2], \
1072             {a: 1}, {a: 1.0}, 2.5, 2.5, -1, -1.0, \"\", \"\"]",
1073        );
1074        let Value::List(xs) = &corpus else {
1075            panic!("{corpus:?}")
1076        };
1077        let mut naive: Vec<Value> = Vec::new();
1078        for x in xs.iter() {
1079            if !naive.iter().any(|k| blue_equal(k, x)) {
1080                naive.push(x.clone());
1081            }
1082        }
1083        let fast = distinct(xs.iter());
1084        assert_eq!(fast.len(), naive.len());
1085        assert!(fast.iter().zip(&naive).all(|(a, b)| blue_equal(a, b)));
1086        assert_eq!(
1087            i("length(distinct(map((x) -> x % 997, range(300000))))"),
1088            997
1089        );
1090        assert_eq!(i("length(distinct(nil))"), 0);
1091    }
1092
1093    #[test]
1094    fn frequencies_is_native_and_counts_as_tatara_does() {
1095        assert!(matches!(
1096            crate::interpreter_hostless().lookup_global("frequencies"),
1097            Some(Value::NativeFn(_))
1098        ));
1099        let corpus = "(list 1 1.0 1 \"a\" 'a :a \"a\" #t #f #t 2.5 2.5 -0.0 0.0 0)";
1100        let mut upstream = tatara_lisp_eval::Interpreter::<()>::new();
1101        tatara_lisp_eval::install_full_stdlib_with(&mut upstream, &mut ());
1102        let forms = tatara_lisp::read_spanned(&format!("(frequencies {corpus})")).expect("reads");
1103        let expected = upstream
1104            .eval_program(&forms, &mut ())
1105            .expect("upstream counts");
1106        let Value::List(xs) = upstream
1107            .eval_program(&tatara_lisp::read_spanned(corpus).expect("reads"), &mut ())
1108            .expect("corpus")
1109        else {
1110            panic!("a list")
1111        };
1112        let ours = frequencies(xs.iter(), tatara_lisp::Span::synthetic()).expect("counts");
1113        assert!(blue_equal(&ours, &expected), "{ours:?} vs {expected:?}");
1114        assert!(crate::run("frequencies([[1]])").is_err());
1115        assert_eq!(
1116            i("get(frequencies(map((x) -> x % 7, range(70000))), 3)"),
1117            10000
1118        );
1119    }
1120
1121    fn eval(src: &str) -> Value {
1122        crate::run(src)
1123            .unwrap_or_else(|e| panic!("{src:?}: {e}"))
1124            .value
1125    }
1126
1127    fn s(src: &str) -> String {
1128        match eval(src) {
1129            Value::Str(v) => v.to_string(),
1130            other => panic!("{src:?} produced {other:?}"),
1131        }
1132    }
1133
1134    fn i(src: &str) -> i64 {
1135        match eval(src) {
1136            Value::Int(v) => v,
1137            other => panic!("{src:?} produced {other:?}"),
1138        }
1139    }
1140
1141    fn ints(src: &str) -> Vec<i64> {
1142        match eval(src) {
1143            Value::List(xs) => xs
1144                .iter()
1145                .map(|v| match v {
1146                    Value::Int(n) => *n,
1147                    other => panic!("{src:?} produced a non-Int element {other:?}"),
1148                })
1149                .collect(),
1150            other => panic!("{src:?} produced {other:?}"),
1151        }
1152    }
1153
1154    /// **`range` gives the Lisp definition's answers**, arity by arity,
1155    /// including the descending form and the empty cases.
1156    #[test]
1157    fn range_answers_as_the_lisp_definition_did() {
1158        assert_eq!(ints("range(5)"), vec![0, 1, 2, 3, 4]);
1159        assert_eq!(ints("range(2, 6)"), vec![2, 3, 4, 5]);
1160        assert_eq!(ints("range(0, 10, 2)"), vec![0, 2, 4, 6, 8]);
1161        assert_eq!(ints("range(10, 0, 0 - 2)"), vec![10, 8, 6, 4, 2]);
1162        assert!(ints("range(5, 5)").is_empty());
1163        assert!(ints("range(5, 2)").is_empty());
1164        assert!(ints("range(0)").is_empty());
1165        // A float step makes the elements after the first floats, as `+` did.
1166        match eval("range(0, 1, 0.5)") {
1167            Value::List(xs) => {
1168                assert!(matches!(xs[0], Value::Int(0)), "{xs:?}");
1169                assert!(matches!(xs[1], Value::Float(x) if x == 0.5), "{xs:?}");
1170                assert_eq!(xs.len(), 2);
1171            }
1172            other => panic!("{other:?}"),
1173        }
1174    }
1175
1176    /// **`range` is a loop, not a recursion.** The Lisp definition consed one
1177    /// stack frame per element and aborted the process near 5,000 elements on
1178    /// an 8 MiB stack. This runs on the test harness's 2 MiB thread, where the
1179    /// Lisp one died sooner still. Red run, 2026-09-24: with the registration
1180    /// removed, this test aborted the test binary with a stack overflow.
1181    #[test]
1182    fn range_builds_long_lists_without_recursing() {
1183        assert_eq!(i("length(range(0, 200000))"), 200_000);
1184        assert_eq!(i("nth(199999, range(0, 200000))"), 199_999);
1185    }
1186
1187    /// A zero step never reaches the end: refused, where the Lisp definition
1188    /// recursed until the stack gave out. A non-number is a named error.
1189    #[test]
1190    fn range_refuses_a_zero_step_and_a_non_number() {
1191        assert!(crate::run("range(0, 5, 0)").is_err());
1192        assert!(crate::run("range(0, \"x\")").is_err());
1193    }
1194
1195    /// **`length` counts CHARACTERS, as Ruby does — not bytes.** A `length`
1196    /// that returns bytes is the bug that only surfaces once a user types a
1197    /// non-ASCII character, which is exactly when it is hardest to trace.
1198    #[test]
1199    fn length_counts_characters_not_bytes() {
1200        assert_eq!(i("length(\"hello\")"), 5);
1201        // "héllo" is 6 bytes, 5 characters.
1202        assert_eq!(i("length(\"héllo\")"), 5, "must not be 6");
1203        // An emoji is 4 bytes, 1 character.
1204        assert_eq!(i("length(\"😀\")"), 1, "must not be 4");
1205    }
1206
1207    /// blue reports **scalar values**, not grapheme clusters. Elixir would say
1208    /// 1 here; blue says 2. Stated and pinned rather than left to surprise.
1209    #[test]
1210    fn a_combining_sequence_counts_scalars_not_graphemes() {
1211        // "e" + U+0301 COMBINING ACUTE — one grapheme, two scalars.
1212        assert_eq!(
1213            i("length(\"e\\u{301}\")"),
1214            2,
1215            "blue counts scalar values; Elixir's String.length would say 1"
1216        );
1217    }
1218
1219    #[test]
1220    fn length_also_works_on_a_list() {
1221        assert_eq!(i("length([1, 2, 3])"), 3);
1222    }
1223
1224    #[test]
1225    fn case_and_trim() {
1226        assert_eq!(s("upcase(\"abc\")"), "ABC");
1227        assert_eq!(s("downcase(\"ABC\")"), "abc");
1228        assert_eq!(s("trim(\"  hi  \")"), "hi");
1229        // Non-ASCII case works, which a byte-wise implementation would botch.
1230        assert_eq!(s("upcase(\"é\")"), "É");
1231    }
1232
1233    #[test]
1234    fn concat_and_to_s() {
1235        assert_eq!(s("concat(\"a\", \"b\")"), "ab");
1236        assert_eq!(s("concat(\"n=\", 42)"), "n=42");
1237        assert_eq!(s("to_s(42)"), "42");
1238        assert_eq!(s("to_s(true)"), "true");
1239    }
1240
1241    /// **`+` stays arithmetic.** Ruby overloads it on String, but blue's `+`
1242    /// lowers to tatara's numeric `+`; making it polymorphic would let a type
1243    /// error at a seam disappear into a string.
1244    #[test]
1245    fn plus_is_not_string_concatenation() {
1246        assert!(
1247            crate::run("\"a\" + \"b\"").is_err(),
1248            "`+` must not silently concatenate — use concat"
1249        );
1250    }
1251
1252    #[test]
1253    fn split_and_join() {
1254        assert_eq!(i("length(split(\"a,b,c\", \",\"))"), 3);
1255        assert_eq!(s("join(split(\"a,b,c\", \",\"), \"-\")"), "a-b-c");
1256        // Ruby's `split("")` yields characters.
1257        assert_eq!(i("length(split(\"abc\", \"\"))"), 3);
1258    }
1259
1260    #[test]
1261    fn predicates() {
1262        assert!(matches!(
1263            eval("contains?(\"hello\", \"ell\")"),
1264            Value::Bool(true)
1265        ));
1266        assert!(matches!(
1267            eval("contains?(\"hello\", \"xyz\")"),
1268            Value::Bool(false)
1269        ));
1270        assert!(matches!(
1271            eval("starts_with?(\"hello\", \"he\")"),
1272            Value::Bool(true)
1273        ));
1274        assert!(matches!(
1275            eval("ends_with?(\"hello\", \"lo\")"),
1276            Value::Bool(true)
1277        ));
1278    }
1279
1280    fn refused(src: &str) -> String {
1281        match crate::run(src) {
1282            Ok(v) => panic!("{src:?} should fail, produced {:?}", v.value),
1283            Err(e) => e.to_string(),
1284        }
1285    }
1286
1287    #[test]
1288    fn text_bytes_and_bytes_text_are_utf8_both_ways() {
1289        assert_eq!(ints("text_bytes(\"abc\")"), vec![97, 98, 99]);
1290        assert_eq!(ints("text_bytes(\"é\")"), vec![195, 169]);
1291        assert!(ints("text_bytes(\"\")").is_empty());
1292        assert_eq!(s("bytes_text([104, 195, 169])"), "hé");
1293        assert_eq!(s("bytes_text(text_bytes(\"€ x\"))"), "€ x");
1294        assert_eq!(s("bytes_text([])"), "");
1295        assert!(matches!(eval("bytes_text([195])"), Value::Nil));
1296        assert!(matches!(eval("bytes_text([255, 97])"), Value::Nil));
1297        assert!(refused("bytes_text([256])").contains("outside 0..=255"));
1298        assert!(refused("bytes_text(nil)").contains("string or a list of bytes"));
1299        assert!(refused("text_bytes(5)").contains("string"));
1300    }
1301
1302    #[test]
1303    fn trim_without_options_cuts_whitespace_at_both_ends() {
1304        assert_eq!(s("trim(\"  a b \\n\")"), "a b");
1305        assert_eq!(s("trim(\"  a \", nil)"), "a");
1306        assert_eq!(s("trim(\"  a \", {})"), "a");
1307        assert_eq!(s("trim(\"\")"), "");
1308        assert_eq!(s("trim(\"   \")"), "");
1309        assert_eq!(s("trim(\"\", {side: :start})"), "");
1310    }
1311
1312    #[test]
1313    fn trim_side_keeps_the_other_end() {
1314        assert_eq!(s("trim(\"  a  \", {side: :both})"), "a");
1315        assert_eq!(s("trim(\"  a  \", {side: :start})"), "a  ");
1316        assert_eq!(s("trim(\"  a  \", {side: :end})"), "  a");
1317    }
1318
1319    #[test]
1320    fn trim_chars_replaces_whitespace_as_the_set_to_cut() {
1321        assert_eq!(s("trim(\"--a-b--\", {chars: \"-\"})"), "a-b");
1322        assert_eq!(s("trim(\"-+a+-\", {chars: \"-+\", side: :start})"), "a+-");
1323        assert_eq!(s("trim(\"-+a+-\", {chars: \"-+\", side: :end})"), "-+a");
1324        assert_eq!(s("trim(\" a \", {chars: \"-\"})"), " a ");
1325        assert_eq!(s("trim(\"aaa\", {chars: \"a\"})"), "");
1326        assert_eq!(s("trim(\"héé\", {chars: \"é\", side: :end})"), "h");
1327        assert_eq!(s("trim(\"  a  \", {chars: \"\"})"), "  a  ");
1328    }
1329
1330    #[test]
1331    fn trim_refuses_what_it_does_not_take() {
1332        assert!(refused("trim(\"a\", {sid: :end})").contains("bad_option"));
1333        assert!(refused("trim(\"a\", {sid: :end})").contains("side, chars"));
1334        assert!(refused("trim(\"a\", {side: :middle})").contains("side must be"));
1335        assert!(refused("trim(\"a\", {side: \"end\"})").contains("bad_option"));
1336        assert!(refused("trim(\"a\", {chars: 1})").contains("chars must be text"));
1337        assert!(refused("trim(\"a\", 5)").contains("options must be a map"));
1338        assert!(refused("trim(\"a\", {}, {})").contains("1 to 2"));
1339        assert!(refused("trim()").contains("1 to 2"));
1340        assert!(refused("trim(5)").contains("string"));
1341    }
1342
1343    #[test]
1344    fn trim_is_still_a_value_other_functions_take() {
1345        assert_eq!(s("join(map(trim, [\" a\", \"b \"]), \",\")"), "a,b");
1346    }
1347
1348    #[test]
1349    fn text_index_answers_character_positions() {
1350        assert_eq!(i("text_index(\"héllo héllo\", \"llo\", 0)"), 2);
1351        assert_eq!(i("text_index(\"héllo héllo\", \"llo\", 3)"), 8);
1352        assert_eq!(i("text_index(\"héllo\", \"x\", 0)"), -1);
1353        assert_eq!(i("text_index(\"abc\", \"\", 3)"), 3);
1354        assert_eq!(i("text_index(\"abc\", \"\", 4)"), -1);
1355        assert_eq!(i("text_index(\"abc\", \"a\", -5)"), 0);
1356        assert_eq!(i("text_index(\"\", \"a\", 0)"), -1);
1357    }
1358
1359    #[test]
1360    fn text_last_index_finds_the_last_start_at_or_before_from() {
1361        assert_eq!(i("text_last_index(\"héllo héllo\", \"llo\", 99)"), 8);
1362        assert_eq!(i("text_last_index(\"héllo héllo\", \"llo\", 7)"), 2);
1363        assert_eq!(i("text_last_index(\"aaa\", \"aa\", 1)"), 1);
1364        assert_eq!(i("text_last_index(\"aaa\", \"aa\", 0)"), 0);
1365        assert_eq!(i("text_last_index(\"abc\", \"x\", 2)"), -1);
1366        assert_eq!(i("text_last_index(\"abc\", \"a\", -1)"), -1);
1367        assert_eq!(i("text_last_index(\"ab\", \"abc\", 0)"), -1);
1368        assert_eq!(i("text_last_index(\"abc\", \"\", 9)"), 3);
1369    }
1370
1371    #[test]
1372    fn replace_and_chars() {
1373        assert_eq!(s("replace(\"a-b-c\", \"-\", \"+\")"), "a+b+c");
1374        assert_eq!(i("length(chars(\"abc\"))"), 3);
1375    }
1376
1377    /// Reversed by character, so a multi-byte character survives. A byte-wise
1378    /// reverse produces invalid UTF-8.
1379    #[test]
1380    fn reverse_is_character_wise() {
1381        assert_eq!(s("reverse(\"abc\")"), "cba");
1382        assert_eq!(s("reverse(\"héllo\")"), "olléh", "must not corrupt the é");
1383    }
1384
1385    #[test]
1386    fn reverse_also_works_on_a_list() {
1387        assert_eq!(s("join(reverse([1, 2, 3]), \",\")"), "3,2,1");
1388    }
1389
1390    /// **`to_int` answers nil on garbage, not 0.** Ruby's `String#to_i` returns
1391    /// 0, which silently turns a parse failure into a plausible number — a
1392    /// deliberate divergence, and the reason `to_int!` exists for callers who
1393    /// want the failure loudly.
1394    #[test]
1395    fn to_int_is_nil_on_garbage_rather_than_zero() {
1396        assert_eq!(i("to_int(\"42\")"), 42);
1397        assert!(
1398            matches!(eval("to_int(\"banana\")"), Value::Nil),
1399            "Ruby would say 0 here; a falsy nil cannot be mistaken for a result"
1400        );
1401        assert!(
1402            matches!(eval("to_int(\"0\")"), Value::Int(0)),
1403            "and a real 0 is still a real 0 — the two must stay distinguishable"
1404        );
1405    }
1406
1407    #[test]
1408    fn to_int_bang_raises_on_garbage() {
1409        assert_eq!(i("to_int!(\"42\")"), 42);
1410        let err = crate::run("to_int!(\"banana\")").expect_err("must raise");
1411        assert!(err.to_string().contains("banana"), "must name it: {err}");
1412    }
1413
1414    #[test]
1415    fn numeric_conversions_and_abs() {
1416        assert_eq!(i("to_int(3.9)"), 3);
1417        assert_eq!(i("abs(0 - 5)"), 5);
1418        assert!(matches!(eval("to_float(\"1.5\")"), Value::Float(_)));
1419        assert!(matches!(eval("to_float(\"nope\")"), Value::Nil));
1420    }
1421
1422    /// A wrong-typed argument is a typed error, not a silent coercion.
1423    #[test]
1424    fn a_non_string_argument_is_a_type_error() {
1425        assert!(crate::run("upcase([1, 2])").is_err());
1426        assert!(crate::run("join(\"not a list\", \",\")").is_err());
1427    }
1428}