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/// blue's one equality — what `==`, `!=`, `contains`, `index_of`, `count_of`
53/// and `distinct` all mean (okite D0001–D0003).
54///
55/// Ruby's answer: numbers compare by value across Int and Float (`4 == 4.0`),
56/// strings, symbols and keywords by text (each only with its own kind), lists
57/// element by element, maps by their entries whatever order they were built in,
58/// nil only with nil (not with `[]`, D0004), functions by identity. Until
59/// 2026-09-27 `==` was tatara's Scheme `equal?`, under which `4 == 4.0` was
60/// false and two equal maps were unequal unless they were the same object.
61/// tatara keeps Scheme's `equal?` for its own programs; blue redefines the
62/// name in its interpreter, and tatara's own `member?`, `position` and
63/// `distinct` call `equal?` by name, so they follow.
64pub fn blue_equal(a: &Value, b: &Value) -> bool {
65    match (a, b) {
66        (Value::Nil, Value::Nil) => true,
67        (Value::Bool(x), Value::Bool(y)) => x == y,
68        (Value::Int(x), Value::Int(y)) => x == y,
69        (Value::Float(x), Value::Float(y)) => x == y,
70        (Value::Int(x), Value::Float(y)) | (Value::Float(y), Value::Int(x)) => (*x as f64) == *y,
71        (Value::Str(x), Value::Str(y))
72        | (Value::Symbol(x), Value::Symbol(y))
73        | (Value::Keyword(x), Value::Keyword(y)) => x == y,
74        (Value::List(x), Value::List(y)) => {
75            x.len() == y.len() && x.iter().zip(y.iter()).all(|(p, q)| blue_equal(p, q))
76        }
77        (Value::Map(x), Value::Map(y)) => {
78            x.len() == y.len()
79                && x.iter().all(|(k, v)| y.get(k).is_some_and(|w| blue_equal(v, w)))
80        }
81        (Value::Closure(x), Value::Closure(y)) => std::sync::Arc::ptr_eq(x, y),
82        (Value::NativeFn(x), Value::NativeFn(y)) => x.name == y.name,
83        (Value::Sexp(x, _), Value::Sexp(y, _)) => x == y,
84        _ => false,
85    }
86}
87
88/// `to_s` (okite D0005, D0006): text for a person. A string, symbol or keyword
89/// is its own text and nil is empty, as in Ruby; every other value is its blue
90/// literal. Until 2026-09-27 a map rendered as the word `map`, a list lost its
91/// brackets (`[1, {a: 2}]` came out `1 map`) and `1.0` came out `1`.
92fn render(v: &Value) -> String {
93    match v {
94        Value::Nil => String::new(),
95        Value::Str(s) | Value::Symbol(s) | Value::Keyword(s) => s.to_string(),
96        other => literal(other),
97    }
98}
99
100/// A value as blue source writes it: `[1, "a", nil]`, `{a: 1, b: 2.0}`.
101/// Map entries are sorted by key, since a map has no order of its own and the
102/// text must be the same for equal maps (D0006).
103fn literal(v: &Value) -> String {
104    match v {
105        Value::Nil => "nil".into(),
106        Value::Bool(b) => b.to_string(),
107        Value::Int(n) => n.to_string(),
108        Value::Float(x) => float_text(*x),
109        Value::Str(s) => quoted(s),
110        Value::Symbol(s) => s.to_string(),
111        Value::Keyword(s) => format!(":{s}"),
112        Value::List(items) => {
113            let parts: Vec<String> = items.iter().map(literal).collect();
114            format!("[{}]", parts.join(", "))
115        }
116        Value::Map(m) => {
117            let mut entries: Vec<String> = m
118                .iter()
119                .map(|(k, v)| format!("{} {}", key_text(k), literal(v)))
120                .collect();
121            entries.sort();
122            format!("{{{}}}", entries.join(", "))
123        }
124        other => other.type_name().to_string(),
125    }
126}
127
128/// A map key as it is written before its value: `a:` for a keyword or symbol,
129/// `"a" =>` / `1 =>` for anything else.
130fn key_text(k: &tatara_lisp_eval::value::MapKey) -> String {
131    use tatara_lisp_eval::value::MapKey;
132    match k {
133        MapKey::Keyword(s) | MapKey::Symbol(s) => format!("{s}:"),
134        MapKey::Str(s) => format!("{} =>", quoted(s)),
135        MapKey::Int(n) => format!("{n} =>"),
136        MapKey::Float(bits) => format!("{} =>", float_text(f64::from_bits(*bits))),
137        MapKey::Bool(b) => format!("{b} =>"),
138        MapKey::Nil => "nil =>".into(),
139    }
140}
141
142/// A float keeps its point, as in Ruby: `1.0`, `2.5`.
143fn float_text(x: f64) -> String {
144    if x.is_finite() && x.fract() == 0.0 && x.abs() < 1e16 {
145        format!("{x:.1}")
146    } else {
147        format!("{x}")
148    }
149}
150
151fn quoted(s: &str) -> String {
152    let mut out = String::with_capacity(s.len() + 2);
153    out.push('"');
154    for c in s.chars() {
155        match c {
156            '"' => out.push_str("\\\""),
157            '\\' => out.push_str("\\\\"),
158            '\n' => out.push_str("\\n"),
159            '\t' => out.push_str("\\t"),
160            c => out.push(c),
161        }
162    }
163    out.push('"');
164    out
165}
166
167fn list(items: Vec<Value>) -> Value {
168    Value::List(std::sync::Arc::new(items))
169}
170
171/// Install blue's string and number core.
172pub fn install_blue_stdlib<H: 'static>(interp: &mut Interpreter<H>) {
173    // ── text ──────────────────────────────────────────────────────────
174
175    // `length` counts CHARACTERS, per Ruby. Also accepts a list, where it is
176    // the element count — Ruby's `Array#length`.
177    interp.register_fn(
178        "length",
179        Arity::Exact(1),
180        |a: &[Value], _h: &mut H, span| match &a[0] {
181            Value::List(items) => Ok(Value::Int(items.len() as i64)),
182            other => Ok(Value::Int(as_str(other, span)?.chars().count() as i64)),
183        },
184    );
185
186    interp.register_fn("to_s", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
187        Ok(Value::Str(render(&a[0]).into()))
188    });
189
190    interp.register_fn("upcase", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
191        Ok(Value::Str(as_str(&a[0], s)?.to_uppercase().into()))
192    });
193
194    interp.register_fn("downcase", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
195        Ok(Value::Str(as_str(&a[0], s)?.to_lowercase().into()))
196    });
197
198    interp.register_fn("trim", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
199        Ok(Value::Str(as_str(&a[0], s)?.trim().into()))
200    });
201
202    // `concat(a, …)` joins the text of one or more arguments, left to right
203    // (okite D0007; Ruby's String#concat takes many). Two-only until
204    // 2026-09-27, which made `concat(a, b, c)` an arity error. `+` stays
205    // arithmetic: Ruby overloads `+` on String, but blue's `+` lowers to
206    // tatara's numeric `+`, and making it polymorphic would make a type error
207    // at a seam disappear into a string.
208    interp.register_fn("concat", Arity::AtLeast(1), |a: &[Value], _h: &mut H, _s| {
209        Ok(Value::Str(a.iter().map(render).collect::<String>().into()))
210    });
211
212    // ── equality and kinds (okite D0001–D0004, D0010) ─────────────────────
213    //
214    // `==` lowers to `equal?` and `!=` to `not=`; both are redefined here so
215    // blue's interpreter has one equality. See `blue_equal`.
216    interp.register_fn("equal?", Arity::Exact(2), |a: &[Value], _h: &mut H, _s| {
217        Ok(Value::Bool(blue_equal(&a[0], &a[1])))
218    });
219    interp.register_fn("not=", Arity::Exact(2), |a: &[Value], _h: &mut H, _s| {
220        Ok(Value::Bool(!blue_equal(&a[0], &a[1])))
221    });
222
223    // One predicate per kind, and exactly one holds of any value (D0010).
224    // tatara's `nil?` is its Scheme alias for `null?`, true of `[]` as well,
225    // and its `list?` is true of nil; blue's nil is absent and `[]` is an empty
226    // list (D0004), so blue redefines both. tatara's own library ends its
227    // recursion with `null?`, which is untouched.
228    interp.register_fn("nil?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
229        Ok(Value::Bool(matches!(a[0], Value::Nil)))
230    });
231    interp.register_fn("list?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
232        Ok(Value::Bool(matches!(a[0], Value::List(_))))
233    });
234    interp.register_fn("map?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
235        Ok(Value::Bool(matches!(a[0], Value::Map(_))))
236    });
237    interp.register_fn("float?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
238        Ok(Value::Bool(matches!(a[0], Value::Float(_))))
239    });
240    interp.register_fn("bool?", Arity::Exact(1), |a: &[Value], _h: &mut H, _s| {
241        Ok(Value::Bool(matches!(a[0], Value::Bool(_))))
242    });
243
244    // The empty list is always `[]`, never nil (D0011). tatara's `cdr` (and so
245    // `rest`, which is `cdr`) and `append` returned nil for an empty result,
246    // Scheme's convention, which under D0004 made `rest([1]) == []` false.
247    // Same contracts otherwise: `cdr` of an empty list is still an error.
248    interp.register_fn("cdr", Arity::Exact(1), |a: &[Value], _h: &mut H, span| {
249        match &a[0] {
250            Value::List(xs) if !xs.is_empty() => Ok(list(xs[1..].to_vec())),
251            Value::Nil | Value::List(_) => Err(EvalError::native_fn(
252                std::sync::Arc::<str>::from("cdr"),
253                "cdr of empty list",
254                span,
255            )),
256            other => Err(EvalError::type_mismatch("pair", other.type_name(), span)),
257        }
258    });
259    interp.register_fn("append", Arity::Any, |a: &[Value], _h: &mut H, span| {
260        let mut out = Vec::new();
261        for v in a {
262            match v {
263                Value::Nil => {}
264                Value::List(xs) => out.extend(xs.iter().cloned()),
265                other => return Err(EvalError::type_mismatch("list", other.type_name(), span)),
266            }
267        }
268        Ok(list(out))
269    });
270
271    interp.register_fn("split", Arity::Exact(2), |a: &[Value], _h: &mut H, s| {
272        let text = as_str(&a[0], s)?;
273        let sep = as_str(&a[1], s)?;
274        // An empty separator splits into characters, as Ruby's `split("")`
275        // does. Rust's `split("")` yields leading/trailing empties instead,
276        // which is the wrong answer here.
277        // Every field is kept: n separators give n + 1 fields, so `join` undoes
278        // `split` exactly (okite D0012). A deliberate deviation from Ruby, which
279        // drops trailing empty fields: tried on 2026-09-27, it broke nisshi's
280        // hash chain, kueri's scripts and moji's `lines`, because a parser whose
281        // split loses a field loses data and still reports success.
282        let parts: Vec<Value> = if sep.is_empty() {
283            text.chars()
284                .map(|c| Value::Str(c.to_string().into()))
285                .collect()
286        } else {
287            text.split(sep.as_str())
288                .map(|p| Value::Str(p.into()))
289                .collect()
290        };
291        Ok(list(parts))
292    });
293
294    interp.register_fn("join", Arity::Exact(2), |a: &[Value], _h: &mut H, s| {
295        let sep = as_str(&a[1], s)?;
296        match &a[0] {
297            Value::List(items) => Ok(Value::Str(
298                items
299                    .iter()
300                    .map(render)
301                    .collect::<Vec<_>>()
302                    .join(&sep)
303                    .into(),
304            )),
305            other => Err(EvalError::type_mismatch("a list", other.type_name(), s).into()),
306        }
307    });
308
309    interp.register_fn(
310        "contains?",
311        Arity::Exact(2),
312        |a: &[Value], _h: &mut H, s| {
313            Ok(Value::Bool(as_str(&a[0], s)?.contains(&as_str(&a[1], s)?)))
314        },
315    );
316
317    interp.register_fn(
318        "starts_with?",
319        Arity::Exact(2),
320        |a: &[Value], _h: &mut H, s| {
321            Ok(Value::Bool(
322                as_str(&a[0], s)?.starts_with(&as_str(&a[1], s)?),
323            ))
324        },
325    );
326
327    interp.register_fn(
328        "ends_with?",
329        Arity::Exact(2),
330        |a: &[Value], _h: &mut H, s| {
331            Ok(Value::Bool(as_str(&a[0], s)?.ends_with(&as_str(&a[1], s)?)))
332        },
333    );
334
335    interp.register_fn("replace", Arity::Exact(3), |a: &[Value], _h: &mut H, s| {
336        Ok(Value::Str(
337            as_str(&a[0], s)?
338                .replace(&as_str(&a[1], s)?, &as_str(&a[2], s)?)
339                .into(),
340        ))
341    });
342
343    interp.register_fn("reverse", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
344        match &a[0] {
345            Value::List(items) => {
346                let mut v = items.as_ref().clone();
347                v.reverse();
348                Ok(list(v))
349            }
350            // Reversed by CHARACTER, so a multi-byte character survives. A
351            // byte-wise reverse produces invalid UTF-8.
352            other => Ok(Value::Str(
353                as_str(other, s)?.chars().rev().collect::<String>().into(),
354            )),
355        }
356    });
357
358    interp.register_fn("chars", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
359        Ok(list(
360            as_str(&a[0], s)?
361                .chars()
362                .map(|c| Value::Str(c.to_string().into()))
363                .collect(),
364        ))
365    });
366
367    // ── numbers ───────────────────────────────────────────────────────
368
369    // `to_int` RETURNS NIL on unparseable input rather than raising. Ruby's
370    // `String#to_i` answers 0 for garbage, which silently turns a parse failure
371    // into a plausible number; nil is falsy and cannot be mistaken for a
372    // result. `to_int!` is the raising form for callers who want the failure.
373    interp.register_fn(
374        "to_int",
375        Arity::Exact(1),
376        |a: &[Value], _h: &mut H, s| match &a[0] {
377            Value::Int(n) => Ok(Value::Int(*n)),
378            Value::Float(x) => Ok(Value::Int(*x as i64)),
379            other => Ok(as_str(other, s)?
380                .trim()
381                .parse::<i64>()
382                .map_or(Value::Nil, Value::Int)),
383        },
384    );
385
386    interp.register_fn(
387        "to_int!",
388        Arity::Exact(1),
389        |a: &[Value], _h: &mut H, s| match &a[0] {
390            Value::Int(n) => Ok(Value::Int(*n)),
391            Value::Float(x) => Ok(Value::Int(*x as i64)),
392            other => {
393                let text = as_str(other, s)?;
394                text.trim().parse::<i64>().map(Value::Int).map_err(|_| {
395                    EvalError::native_fn(
396                        "to_int!",
397                        "`".to_string() + &text + "` is not an integer",
398                        s,
399                    )
400                    .into()
401                })
402            }
403        },
404    );
405
406    interp.register_fn(
407        "to_float",
408        Arity::Exact(1),
409        |a: &[Value], _h: &mut H, s| match &a[0] {
410            Value::Float(x) => Ok(Value::Float(*x)),
411            Value::Int(n) => Ok(Value::Float(*n as f64)),
412            other => Ok(as_str(other, s)?
413                .trim()
414                .parse::<f64>()
415                .map_or(Value::Nil, Value::Float)),
416        },
417    );
418
419    interp.register_fn(
420        "abs",
421        Arity::Exact(1),
422        |a: &[Value], _h: &mut H, s| match &a[0] {
423            Value::Int(n) => Ok(Value::Int(n.abs())),
424            Value::Float(x) => Ok(Value::Float(x.abs())),
425            other => Err(EvalError::type_mismatch("a number", other.type_name(), s).into()),
426        },
427    );
428
429    // ── ranges ────────────────────────────────────────────────────────
430
431    // `range` is a LOOP here, not a recursion. tatara-lisp's stdlib defines it
432    // in Lisp (`lisp_stdlib.tlisp`, where `range-impl` conses one frame per
433    // element), so `range(0, 5000)` overflowed the 8 MiB main stack and
434    // aborted the process. Everything built on it inherited the ceiling:
435    // retsu's `indexes`, `zip_with` and `enumerate`, and shomei's
436    // `chain_first_break`, which could not verify a 5,000-entry chain
437    // (measured 2026-09-24: 4,000 elements fine, 5,000 aborted).
438    //
439    // Same arities and answers as the Lisp definition: (end), (start, end),
440    // (start, end, step), ascending for a positive step, descending for a
441    // negative one, and each next element is the previous plus the step under
442    // the ordinary number rules (Int + Int is Int; a Float anywhere makes the
443    // rest Float). Two refusals the Lisp version lacked: a zero step, which
444    // recursed forever, and a wrong arity, which printed a message and
445    // returned nil.
446    //
447    // The destination is upstream: when tatara-lisp's own `range` is a loop,
448    // this registration is deleted.
449    interp.register_fn(
450        "range",
451        Arity::Range(1, 3),
452        |a: &[Value], _h: &mut H, s| {
453            let (start, end, step) = match a.len() {
454                1 => (Value::Int(0), a[0].clone(), Value::Int(1)),
455                2 => (a[0].clone(), a[1].clone(), Value::Int(1)),
456                _ => (a[0].clone(), a[1].clone(), a[2].clone()),
457            };
458            range_list(start, &end, &step, s)
459        },
460    );
461
462    // `write_stdout(s)` / `write_stderr(s)`: the text exactly, no quotes, no
463    // newline added. `print`, `println` and `display` all render a string
464    // with its quotes (a value printer, right for a REPL and wrong for a
465    // command's output), so until these a blue command-line tool could not
466    // print plain text. Output only, like `println`; anything else is not a
467    // string and is refused.
468    interp.register_fn("write_stdout", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
469        use std::io::Write;
470        let text = as_str(&a[0], s)?;
471        let mut out = std::io::stdout().lock();
472        out.write_all(text.as_bytes())
473            .and_then(|()| out.flush())
474            .map_err(|e| EvalError::native_fn("write_stdout", e.to_string(), s))?;
475        Ok(Value::Nil)
476    });
477    interp.register_fn("write_stderr", Arity::Exact(1), |a: &[Value], _h: &mut H, s| {
478        use std::io::Write;
479        let text = as_str(&a[0], s)?;
480        let mut err = std::io::stderr().lock();
481        err.write_all(text.as_bytes())
482            .and_then(|()| err.flush())
483            .map_err(|e| EvalError::native_fn("write_stderr", e.to_string(), s))?;
484        Ok(Value::Nil)
485    });
486
487    // `sort_keyed(key, xs)`: xs ordered by key(x), stably, in O(n log n),
488    // under `compare`'s rules. Every sort written in blue (junjo's) recurses
489    // per element and aborts the process near 600 elements; this is
490    // tatara-lisp's `sort-by-key` (0.3.60), which blue cannot name because
491    // it is kebab-case, bound to a name blue can. The engine is upstream,
492    // `hof::sort_keyed_values`; only the spelling lives here.
493    interp.register_higher_order_fn(
494        "sort_keyed",
495        Arity::Exact(2),
496        |a: &[Value], host: &mut H, caller: &tatara_lisp_eval::ffi::Caller<H>, s| {
497            let xs = match &a[1] {
498                Value::List(xs) => xs.as_ref().clone(),
499                Value::Nil => Vec::new(),
500                other => {
501                    return Err(EvalError::type_mismatch("a list", other.type_name(), s));
502                }
503            };
504            let mut keyed = Vec::with_capacity(xs.len());
505            for x in xs {
506                keyed.push((caller.apply_value(&a[0], vec![x.clone()], host, s)?, x));
507            }
508            Ok(list(tatara_lisp_eval::hof::sort_keyed_values(keyed, s)?))
509        },
510    );
511}
512
513/// A number's value as a float, or a type error naming `range`.
514fn range_num(v: &Value, s: tatara_lisp::Span) -> Result<f64, EvalError> {
515    match v {
516        Value::Int(n) => Ok(*n as f64),
517        Value::Float(x) => Ok(*x),
518        other => Err(EvalError::native_fn(
519            "range",
520            "expected a number, got ".to_string() + other.type_name(),
521            s,
522        )),
523    }
524}
525
526/// `a + b` under the number rules the Lisp `range` used: Int + Int stays Int,
527/// anything with a Float is Float.
528fn range_add(a: &Value, b: &Value, s: tatara_lisp::Span) -> Result<Value, EvalError> {
529    match (a, b) {
530        (Value::Int(x), Value::Int(y)) => x.checked_add(*y).map(Value::Int).ok_or_else(|| {
531            EvalError::native_fn("range", "an element overflowed a 64-bit integer", s)
532        }),
533        _ => Ok(Value::Float(range_num(a, s)? + range_num(b, s)?)),
534    }
535}
536
537fn range_list(
538    start: Value,
539    end: &Value,
540    step: &Value,
541    s: tatara_lisp::Span,
542) -> Result<Value, EvalError> {
543    let end_f = range_num(end, s)?;
544    let step_f = range_num(step, s)?;
545    range_num(&start, s)?;
546    if step_f == 0.0 {
547        return Err(EvalError::native_fn(
548            "range",
549            "a step of zero never reaches the end",
550            s,
551        ));
552    }
553    let mut out = Vec::new();
554    let mut cur = start;
555    loop {
556        let c = range_num(&cur, s)?;
557        if (step_f > 0.0 && c >= end_f) || (step_f < 0.0 && c <= end_f) {
558            break;
559        }
560        let next = range_add(&cur, step, s)?;
561        out.push(cur);
562        cur = next;
563    }
564    Ok(list(out))
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570
571    #[test]
572    fn sort_keyed_is_stable_keyed_and_deep() {
573        // Ties keep input order; the key is computed per element.
574        assert_eq!(
575            ints("map(fn(p) last(p) end, sort_keyed(fn(p) first(p) end, [[2, 1], [1, 2], [2, 3], [1, 4]]))"),
576            vec![2, 4, 1, 3]
577        );
578        // 20,000 in reverse: every blue-written sort aborts far below this.
579        assert_eq!(
580            i("first(sort_keyed(fn(x) x end, reverse(range(0, 20000))))"),
581            0
582        );
583        assert_eq!(i("last(sort_keyed(fn(x) 0 - x end, range(0, 20000)))"), 0);
584        // The empty case, and a refusal for keys that cannot be ordered.
585        assert_eq!(ints("sort_keyed(fn(x) x end, [])"), Vec::<i64>::new());
586        assert!(crate::run("sort_keyed(fn(x) x end, [1, \"a\"])").is_err());
587    }
588
589    fn eval(src: &str) -> Value {
590        crate::run(src)
591            .unwrap_or_else(|e| panic!("{src:?}: {e}"))
592            .value
593    }
594
595    fn s(src: &str) -> String {
596        match eval(src) {
597            Value::Str(v) => v.to_string(),
598            other => panic!("{src:?} produced {other:?}"),
599        }
600    }
601
602    fn i(src: &str) -> i64 {
603        match eval(src) {
604            Value::Int(v) => v,
605            other => panic!("{src:?} produced {other:?}"),
606        }
607    }
608
609    fn ints(src: &str) -> Vec<i64> {
610        match eval(src) {
611            Value::List(xs) => xs
612                .iter()
613                .map(|v| match v {
614                    Value::Int(n) => *n,
615                    other => panic!("{src:?} produced a non-Int element {other:?}"),
616                })
617                .collect(),
618            other => panic!("{src:?} produced {other:?}"),
619        }
620    }
621
622    /// **`range` gives the Lisp definition's answers**, arity by arity,
623    /// including the descending form and the empty cases.
624    #[test]
625    fn range_answers_as_the_lisp_definition_did() {
626        assert_eq!(ints("range(5)"), vec![0, 1, 2, 3, 4]);
627        assert_eq!(ints("range(2, 6)"), vec![2, 3, 4, 5]);
628        assert_eq!(ints("range(0, 10, 2)"), vec![0, 2, 4, 6, 8]);
629        assert_eq!(ints("range(10, 0, 0 - 2)"), vec![10, 8, 6, 4, 2]);
630        assert!(ints("range(5, 5)").is_empty());
631        assert!(ints("range(5, 2)").is_empty());
632        assert!(ints("range(0)").is_empty());
633        // A float step makes the elements after the first floats, as `+` did.
634        match eval("range(0, 1, 0.5)") {
635            Value::List(xs) => {
636                assert!(matches!(xs[0], Value::Int(0)), "{xs:?}");
637                assert!(matches!(xs[1], Value::Float(x) if x == 0.5), "{xs:?}");
638                assert_eq!(xs.len(), 2);
639            }
640            other => panic!("{other:?}"),
641        }
642    }
643
644    /// **`range` is a loop, not a recursion.** The Lisp definition consed one
645    /// stack frame per element and aborted the process near 5,000 elements on
646    /// an 8 MiB stack. This runs on the test harness's 2 MiB thread, where the
647    /// Lisp one died sooner still. Red run, 2026-09-24: with the registration
648    /// removed, this test aborted the test binary with a stack overflow.
649    #[test]
650    fn range_builds_long_lists_without_recursing() {
651        assert_eq!(i("length(range(0, 200000))"), 200_000);
652        assert_eq!(i("nth(199999, range(0, 200000))"), 199_999);
653    }
654
655    /// A zero step never reaches the end: refused, where the Lisp definition
656    /// recursed until the stack gave out. A non-number is a named error.
657    #[test]
658    fn range_refuses_a_zero_step_and_a_non_number() {
659        assert!(crate::run("range(0, 5, 0)").is_err());
660        assert!(crate::run("range(0, \"x\")").is_err());
661    }
662
663    /// **`length` counts CHARACTERS, as Ruby does — not bytes.** A `length`
664    /// that returns bytes is the bug that only surfaces once a user types a
665    /// non-ASCII character, which is exactly when it is hardest to trace.
666    #[test]
667    fn length_counts_characters_not_bytes() {
668        assert_eq!(i("length(\"hello\")"), 5);
669        // "héllo" is 6 bytes, 5 characters.
670        assert_eq!(i("length(\"héllo\")"), 5, "must not be 6");
671        // An emoji is 4 bytes, 1 character.
672        assert_eq!(i("length(\"😀\")"), 1, "must not be 4");
673    }
674
675    /// blue reports **scalar values**, not grapheme clusters. Elixir would say
676    /// 1 here; blue says 2. Stated and pinned rather than left to surprise.
677    #[test]
678    fn a_combining_sequence_counts_scalars_not_graphemes() {
679        // "e" + U+0301 COMBINING ACUTE — one grapheme, two scalars.
680        assert_eq!(
681            i("length(\"e\\u{301}\")"),
682            2,
683            "blue counts scalar values; Elixir's String.length would say 1"
684        );
685    }
686
687    #[test]
688    fn length_also_works_on_a_list() {
689        assert_eq!(i("length([1, 2, 3])"), 3);
690    }
691
692    #[test]
693    fn case_and_trim() {
694        assert_eq!(s("upcase(\"abc\")"), "ABC");
695        assert_eq!(s("downcase(\"ABC\")"), "abc");
696        assert_eq!(s("trim(\"  hi  \")"), "hi");
697        // Non-ASCII case works, which a byte-wise implementation would botch.
698        assert_eq!(s("upcase(\"é\")"), "É");
699    }
700
701    #[test]
702    fn concat_and_to_s() {
703        assert_eq!(s("concat(\"a\", \"b\")"), "ab");
704        assert_eq!(s("concat(\"n=\", 42)"), "n=42");
705        assert_eq!(s("to_s(42)"), "42");
706        assert_eq!(s("to_s(true)"), "true");
707    }
708
709    /// **`+` stays arithmetic.** Ruby overloads it on String, but blue's `+`
710    /// lowers to tatara's numeric `+`; making it polymorphic would let a type
711    /// error at a seam disappear into a string.
712    #[test]
713    fn plus_is_not_string_concatenation() {
714        assert!(
715            crate::run("\"a\" + \"b\"").is_err(),
716            "`+` must not silently concatenate — use concat"
717        );
718    }
719
720    #[test]
721    fn split_and_join() {
722        assert_eq!(i("length(split(\"a,b,c\", \",\"))"), 3);
723        assert_eq!(s("join(split(\"a,b,c\", \",\"), \"-\")"), "a-b-c");
724        // Ruby's `split("")` yields characters.
725        assert_eq!(i("length(split(\"abc\", \"\"))"), 3);
726    }
727
728    #[test]
729    fn predicates() {
730        assert!(matches!(
731            eval("contains?(\"hello\", \"ell\")"),
732            Value::Bool(true)
733        ));
734        assert!(matches!(
735            eval("contains?(\"hello\", \"xyz\")"),
736            Value::Bool(false)
737        ));
738        assert!(matches!(
739            eval("starts_with?(\"hello\", \"he\")"),
740            Value::Bool(true)
741        ));
742        assert!(matches!(
743            eval("ends_with?(\"hello\", \"lo\")"),
744            Value::Bool(true)
745        ));
746    }
747
748    #[test]
749    fn replace_and_chars() {
750        assert_eq!(s("replace(\"a-b-c\", \"-\", \"+\")"), "a+b+c");
751        assert_eq!(i("length(chars(\"abc\"))"), 3);
752    }
753
754    /// Reversed by character, so a multi-byte character survives. A byte-wise
755    /// reverse produces invalid UTF-8.
756    #[test]
757    fn reverse_is_character_wise() {
758        assert_eq!(s("reverse(\"abc\")"), "cba");
759        assert_eq!(s("reverse(\"héllo\")"), "olléh", "must not corrupt the é");
760    }
761
762    #[test]
763    fn reverse_also_works_on_a_list() {
764        assert_eq!(s("join(reverse([1, 2, 3]), \",\")"), "3,2,1");
765    }
766
767    /// **`to_int` answers nil on garbage, not 0.** Ruby's `String#to_i` returns
768    /// 0, which silently turns a parse failure into a plausible number — a
769    /// deliberate divergence, and the reason `to_int!` exists for callers who
770    /// want the failure loudly.
771    #[test]
772    fn to_int_is_nil_on_garbage_rather_than_zero() {
773        assert_eq!(i("to_int(\"42\")"), 42);
774        assert!(
775            matches!(eval("to_int(\"banana\")"), Value::Nil),
776            "Ruby would say 0 here; a falsy nil cannot be mistaken for a result"
777        );
778        assert!(
779            matches!(eval("to_int(\"0\")"), Value::Int(0)),
780            "and a real 0 is still a real 0 — the two must stay distinguishable"
781        );
782    }
783
784    #[test]
785    fn to_int_bang_raises_on_garbage() {
786        assert_eq!(i("to_int!(\"42\")"), 42);
787        let err = crate::run("to_int!(\"banana\")").expect_err("must raise");
788        assert!(err.to_string().contains("banana"), "must name it: {err}");
789    }
790
791    #[test]
792    fn numeric_conversions_and_abs() {
793        assert_eq!(i("to_int(3.9)"), 3);
794        assert_eq!(i("abs(0 - 5)"), 5);
795        assert!(matches!(eval("to_float(\"1.5\")"), Value::Float(_)));
796        assert!(matches!(eval("to_float(\"nope\")"), Value::Nil));
797    }
798
799    /// A wrong-typed argument is a typed error, not a silent coercion.
800    #[test]
801    fn a_non_string_argument_is_a_type_error() {
802        assert!(crate::run("upcase([1, 2])").is_err());
803        assert!(crate::run("join(\"not a list\", \",\")").is_err());
804    }
805}