Skip to main content

panproto_expr/
builtin.rs

1//! Implementations of built-in operations.
2//!
3//! Each builtin is a pure function from `&[Literal]` to `Result<Literal, ExprError>`.
4//! Type checking is done at evaluation time; arguments must have the expected types.
5
6use std::sync::Arc;
7
8use crate::error::ExprError;
9use crate::expr::BuiltinOp;
10use crate::literal::Literal;
11
12/// Safely convert a float to i64, returning an error for NaN, infinity, or out-of-range values.
13fn float_to_i64(f: f64) -> Result<Literal, ExprError> {
14    if !f.is_finite() {
15        return Err(ExprError::FloatNotRepresentable(format!("{f}")));
16    }
17    #[allow(clippy::cast_precision_loss)]
18    if f < i64::MIN as f64 || f > i64::MAX as f64 {
19        return Err(ExprError::FloatNotRepresentable(format!("{f}")));
20    }
21    #[allow(clippy::cast_possible_truncation)]
22    Ok(Literal::Int(f as i64))
23}
24
25/// Apply a builtin operation to evaluated arguments.
26///
27/// # Errors
28///
29/// Returns [`ExprError`] if argument types don't match or a runtime
30/// error occurs (division by zero, parse failure, etc.).
31pub fn apply_builtin(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
32    let expected = op.arity();
33    if args.len() != expected {
34        return Err(ExprError::ArityMismatch {
35            op: format!("{op:?}"),
36            expected,
37            got: args.len(),
38        });
39    }
40
41    match op {
42        // --- Arithmetic ---
43        BuiltinOp::Add
44        | BuiltinOp::Sub
45        | BuiltinOp::Mul
46        | BuiltinOp::Div
47        | BuiltinOp::Mod
48        | BuiltinOp::Neg
49        | BuiltinOp::Abs
50        | BuiltinOp::Floor
51        | BuiltinOp::Ceil
52        | BuiltinOp::Round => apply_arithmetic(op, args),
53
54        // --- Comparison ---
55        BuiltinOp::Eq
56        | BuiltinOp::Neq
57        | BuiltinOp::Lt
58        | BuiltinOp::Lte
59        | BuiltinOp::Gt
60        | BuiltinOp::Gte => apply_comparison(op, args),
61
62        // --- Boolean ---
63        BuiltinOp::And | BuiltinOp::Or | BuiltinOp::Not => apply_boolean(op, args),
64
65        // --- String ---
66        BuiltinOp::Concat
67        | BuiltinOp::Len
68        | BuiltinOp::Slice
69        | BuiltinOp::Upper
70        | BuiltinOp::Lower
71        | BuiltinOp::Trim
72        | BuiltinOp::Split
73        | BuiltinOp::Join
74        | BuiltinOp::Replace
75        | BuiltinOp::Contains => apply_string(op, args),
76
77        // --- List ---
78        BuiltinOp::Map
79        | BuiltinOp::Filter
80        | BuiltinOp::Fold
81        | BuiltinOp::FlatMap
82        | BuiltinOp::Append
83        | BuiltinOp::Head
84        | BuiltinOp::Tail
85        | BuiltinOp::Reverse
86        | BuiltinOp::Length
87        | BuiltinOp::Range => apply_list(op, args),
88
89        // --- Record ---
90        BuiltinOp::MergeRecords | BuiltinOp::Keys | BuiltinOp::Values | BuiltinOp::HasField => {
91            apply_record(op, args)
92        }
93
94        // --- Utility ---
95        BuiltinOp::DefaultVal | BuiltinOp::Clamp | BuiltinOp::TruncateStr => {
96            apply_utility(op, args)
97        }
98
99        // --- Type coercions ---
100        BuiltinOp::IntToFloat
101        | BuiltinOp::FloatToInt
102        | BuiltinOp::IntToStr
103        | BuiltinOp::FloatToStr
104        | BuiltinOp::StrToInt
105        | BuiltinOp::StrToFloat => apply_coercion(op, args),
106
107        // --- Type inspection ---
108        BuiltinOp::TypeOf | BuiltinOp::IsNull | BuiltinOp::IsList => apply_inspection(op, args),
109        // Graph traversal builtins read an instance, which this evaluator
110        // does not hold. Answering `null` would make "no such edge" and "no
111        // graph was consulted" indistinguishable, so the caller is told
112        // instead. Supply a resolver — `panproto_inst::eval_with_instance`
113        // and `eval_with_element_ops` do — to make these answer.
114        BuiltinOp::Edge
115        | BuiltinOp::Children
116        | BuiltinOp::HasEdge
117        | BuiltinOp::EdgeCount
118        | BuiltinOp::Anchor => Err(ExprError::NoInstanceContext {
119            op: format!("{op:?}"),
120        }),
121    }
122}
123
124/// Arithmetic operations.
125fn apply_arithmetic(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
126    match op {
127        BuiltinOp::Add => numeric_binop(&args[0], &args[1], i64::checked_add, |a, b| a + b),
128        BuiltinOp::Sub => numeric_binop(&args[0], &args[1], i64::checked_sub, |a, b| a - b),
129        BuiltinOp::Mul => numeric_binop(&args[0], &args[1], i64::checked_mul, |a, b| a * b),
130        BuiltinOp::Div => {
131            let is_zero = match (&args[0], &args[1]) {
132                (_, Literal::Int(0)) => true,
133                (_, Literal::Float(b)) if *b == 0.0 => true,
134                _ => false,
135            };
136            if is_zero {
137                Err(ExprError::DivisionByZero)
138            } else {
139                numeric_binop(&args[0], &args[1], i64::checked_div, |a, b| a / b)
140            }
141        }
142        // `i64::MIN % -1` overflows, so the remainder is taken through
143        // `checked_rem` like every other integer operation here.
144        BuiltinOp::Mod => match (&args[0], &args[1]) {
145            (Literal::Int(_), Literal::Int(0)) => Err(ExprError::DivisionByZero),
146            (Literal::Int(a), Literal::Int(b)) => a
147                .checked_rem(*b)
148                .map(Literal::Int)
149                .ok_or(ExprError::Overflow),
150            _ => Err(type_err("int", &args[0])),
151        },
152        BuiltinOp::Neg => match &args[0] {
153            Literal::Int(n) => n.checked_neg().map(Literal::Int).ok_or(ExprError::Overflow),
154            Literal::Float(f) => Ok(Literal::Float(-f)),
155            other => Err(type_err("int|float", other)),
156        },
157        BuiltinOp::Abs => match &args[0] {
158            Literal::Int(n) => n.checked_abs().map(Literal::Int).ok_or(ExprError::Overflow),
159            Literal::Float(f) => Ok(Literal::Float(f.abs())),
160            other => Err(type_err("int|float", other)),
161        },
162        BuiltinOp::Floor => match &args[0] {
163            Literal::Float(f) => float_to_i64(f.floor()),
164            other => Err(type_err("float", other)),
165        },
166        BuiltinOp::Ceil => match &args[0] {
167            Literal::Float(f) => float_to_i64(f.ceil()),
168            other => Err(type_err("float", other)),
169        },
170        BuiltinOp::Round => match &args[0] {
171            Literal::Float(f) => float_to_i64(f.round()),
172            other => Err(type_err("float", other)),
173        },
174        _ => Err(ExprError::InternalDispatch {
175            op: format!("{op:?}"),
176        }),
177    }
178}
179
180/// Comparison operations.
181fn apply_comparison(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
182    match op {
183        BuiltinOp::Eq => Ok(Literal::Bool(args[0] == args[1])),
184        BuiltinOp::Neq => Ok(Literal::Bool(args[0] != args[1])),
185        BuiltinOp::Lt => compare(&args[0], &args[1], std::cmp::Ordering::is_lt),
186        BuiltinOp::Lte => compare(&args[0], &args[1], std::cmp::Ordering::is_le),
187        BuiltinOp::Gt => compare(&args[0], &args[1], std::cmp::Ordering::is_gt),
188        BuiltinOp::Gte => compare(&args[0], &args[1], std::cmp::Ordering::is_ge),
189        _ => Err(ExprError::InternalDispatch {
190            op: format!("{op:?}"),
191        }),
192    }
193}
194
195/// Boolean operations.
196fn apply_boolean(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
197    match op {
198        BuiltinOp::And => match (&args[0], &args[1]) {
199            (Literal::Bool(a), Literal::Bool(b)) => Ok(Literal::Bool(*a && *b)),
200            (Literal::Bool(_), other) | (other, _) => Err(type_err("bool", other)),
201        },
202        BuiltinOp::Or => match (&args[0], &args[1]) {
203            (Literal::Bool(a), Literal::Bool(b)) => Ok(Literal::Bool(*a || *b)),
204            (Literal::Bool(_), other) | (other, _) => Err(type_err("bool", other)),
205        },
206        BuiltinOp::Not => match &args[0] {
207            Literal::Bool(b) => Ok(Literal::Bool(!b)),
208            other => Err(type_err("bool", other)),
209        },
210        _ => Err(ExprError::InternalDispatch {
211            op: format!("{op:?}"),
212        }),
213    }
214}
215
216/// String operations.
217#[allow(
218    clippy::cast_possible_truncation,
219    clippy::cast_possible_wrap,
220    clippy::cast_sign_loss
221)]
222fn apply_string(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
223    match op {
224        BuiltinOp::Concat => match (&args[0], &args[1]) {
225            (Literal::Str(a), Literal::Str(b)) => {
226                let mut s = a.clone();
227                s.push_str(b);
228                Ok(Literal::Str(s))
229            }
230            (Literal::Str(_), other) | (other, _) => Err(type_err("string", other)),
231        },
232        BuiltinOp::Len => match &args[0] {
233            Literal::Str(s) => Ok(Literal::Int(s.len() as i64)),
234            other => Err(type_err("string", other)),
235        },
236        BuiltinOp::Slice => match (&args[0], &args[1], &args[2]) {
237            (Literal::Str(s), Literal::Int(start), Literal::Int(end)) => {
238                let chars: Vec<char> = s.chars().collect();
239                let start = (*start).max(0) as usize;
240                let end = (*end).max(0) as usize;
241                let end = end.min(chars.len());
242                let start = start.min(end);
243                let result: String = chars[start..end].iter().collect();
244                Ok(Literal::Str(result))
245            }
246            _ => Err(type_err("(string, int, int)", &args[0])),
247        },
248        BuiltinOp::Upper => match &args[0] {
249            Literal::Str(s) => Ok(Literal::Str(s.to_uppercase())),
250            other => Err(type_err("string", other)),
251        },
252        BuiltinOp::Lower => match &args[0] {
253            Literal::Str(s) => Ok(Literal::Str(s.to_lowercase())),
254            other => Err(type_err("string", other)),
255        },
256        BuiltinOp::Trim => match &args[0] {
257            Literal::Str(s) => Ok(Literal::Str(s.trim().to_string())),
258            other => Err(type_err("string", other)),
259        },
260        BuiltinOp::Split => match (&args[0], &args[1]) {
261            (Literal::Str(s), Literal::Str(delim)) => Ok(Literal::List(
262                s.split(&**delim)
263                    .map(|p| Literal::Str(p.to_string()))
264                    .collect(),
265            )),
266            _ => Err(type_err("(string, string)", &args[0])),
267        },
268        BuiltinOp::Join => match (&args[0], &args[1]) {
269            (Literal::List(parts), Literal::Str(delim)) => {
270                let strs: Result<Vec<_>, _> = parts
271                    .iter()
272                    .map(|p| match p {
273                        Literal::Str(s) => Ok(s.as_str()),
274                        other => Err(type_err("string", other)),
275                    })
276                    .collect();
277                Ok(Literal::Str(strs?.join(delim)))
278            }
279            _ => Err(type_err("([string], string)", &args[0])),
280        },
281        BuiltinOp::Replace => match (&args[0], &args[1], &args[2]) {
282            (Literal::Str(s), Literal::Str(from), Literal::Str(to)) => {
283                Ok(Literal::Str(s.replace(&**from, to)))
284            }
285            _ => Err(type_err("(string, string, string)", &args[0])),
286        },
287        // `Contains` is overloaded on its first argument: substring
288        // containment on a string, element membership on a list. The list
289        // case is what predicates over a list-valued field use — the field
290        // arrives as a `Literal::List`, so membership is tested directly
291        // rather than against a flattened string.
292        BuiltinOp::Contains => match (&args[0], &args[1]) {
293            (Literal::Str(s), Literal::Str(substr)) => Ok(Literal::Bool(s.contains(&**substr))),
294            (Literal::List(items), needle) => Ok(Literal::Bool(items.iter().any(|i| i == needle))),
295            _ => Err(type_err("(string, string) or (list, any)", &args[0])),
296        },
297        _ => Err(ExprError::InternalDispatch {
298            op: format!("{op:?}"),
299        }),
300    }
301}
302
303/// List operations.
304#[allow(clippy::cast_possible_wrap)]
305fn apply_list(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
306    match op {
307        // Map, Filter, Fold, and FlatMap require lambda evaluation, and
308        // Range needs the list-length budget; all are handled in eval.rs.
309        BuiltinOp::Map
310        | BuiltinOp::Filter
311        | BuiltinOp::Fold
312        | BuiltinOp::FlatMap
313        | BuiltinOp::Range => Err(ExprError::TypeError {
314            expected: "handled in evaluator".into(),
315            got: "direct builtin call".into(),
316        }),
317        BuiltinOp::Append => match (&args[0], &args[1]) {
318            (Literal::List(items), val) => {
319                let mut new_items = items.clone();
320                new_items.push(val.clone());
321                Ok(Literal::List(new_items))
322            }
323            (other, _) => Err(type_err("list", other)),
324        },
325        BuiltinOp::Head => match &args[0] {
326            Literal::List(items) if items.is_empty() => {
327                Err(ExprError::IndexOutOfBounds { index: 0, len: 0 })
328            }
329            Literal::List(items) => Ok(items[0].clone()),
330            other => Err(type_err("list", other)),
331        },
332        BuiltinOp::Tail => match &args[0] {
333            Literal::List(items) if items.is_empty() => {
334                Err(ExprError::IndexOutOfBounds { index: 0, len: 0 })
335            }
336            Literal::List(items) => Ok(Literal::List(items[1..].to_vec())),
337            other => Err(type_err("list", other)),
338        },
339        BuiltinOp::Reverse => match &args[0] {
340            Literal::List(items) => {
341                let mut reversed = items.clone();
342                reversed.reverse();
343                Ok(Literal::List(reversed))
344            }
345            other => Err(type_err("list", other)),
346        },
347        BuiltinOp::Length => match &args[0] {
348            Literal::List(items) => Ok(Literal::Int(items.len() as i64)),
349            other => Err(type_err("list", other)),
350        },
351        _ => Err(ExprError::InternalDispatch {
352            op: format!("{op:?}"),
353        }),
354    }
355}
356
357/// Record operations.
358fn apply_record(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
359    match op {
360        BuiltinOp::MergeRecords => match (&args[0], &args[1]) {
361            (Literal::Record(a), Literal::Record(b)) => {
362                let mut merged = a.clone();
363                for (k, v) in b {
364                    if let Some(existing) = merged.iter_mut().find(|(ek, _)| ek == k) {
365                        existing.1 = v.clone();
366                    } else {
367                        merged.push((Arc::clone(k), v.clone()));
368                    }
369                }
370                Ok(Literal::Record(merged))
371            }
372            (Literal::Record(_), other) | (other, _) => Err(type_err("record", other)),
373        },
374        BuiltinOp::Keys => match &args[0] {
375            Literal::Record(fields) => Ok(Literal::List(
376                fields
377                    .iter()
378                    .map(|(k, _)| Literal::Str(k.to_string()))
379                    .collect(),
380            )),
381            other => Err(type_err("record", other)),
382        },
383        BuiltinOp::Values => match &args[0] {
384            Literal::Record(fields) => Ok(Literal::List(
385                fields.iter().map(|(_, v)| v.clone()).collect(),
386            )),
387            other => Err(type_err("record", other)),
388        },
389        BuiltinOp::HasField => match (&args[0], &args[1]) {
390            (Literal::Record(fields), Literal::Str(name)) => Ok(Literal::Bool(
391                fields.iter().any(|(k, _)| &**k == name.as_str()),
392            )),
393            _ => Err(type_err("(record, string)", &args[0])),
394        },
395        _ => Err(ExprError::InternalDispatch {
396            op: format!("{op:?}"),
397        }),
398    }
399}
400
401/// Type coercion operations.
402#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
403fn apply_coercion(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
404    match op {
405        BuiltinOp::IntToFloat => match &args[0] {
406            Literal::Int(n) => Ok(Literal::Float(*n as f64)),
407            other => Err(type_err("int", other)),
408        },
409        BuiltinOp::FloatToInt => match &args[0] {
410            Literal::Float(f) => float_to_i64(*f),
411            other => Err(type_err("float", other)),
412        },
413        BuiltinOp::IntToStr => match &args[0] {
414            Literal::Int(n) => Ok(Literal::Str(n.to_string())),
415            other => Err(type_err("int", other)),
416        },
417        BuiltinOp::FloatToStr => match &args[0] {
418            Literal::Float(f) => Ok(Literal::Str(f.to_string())),
419            other => Err(type_err("float", other)),
420        },
421        BuiltinOp::StrToInt => match &args[0] {
422            Literal::Str(s) => {
423                s.parse::<i64>()
424                    .map(Literal::Int)
425                    .map_err(|_| ExprError::ParseError {
426                        value: s.clone(),
427                        target_type: "int".into(),
428                    })
429            }
430            other => Err(type_err("string", other)),
431        },
432        BuiltinOp::StrToFloat => match &args[0] {
433            Literal::Str(s) => {
434                s.parse::<f64>()
435                    .map(Literal::Float)
436                    .map_err(|_| ExprError::ParseError {
437                        value: s.clone(),
438                        target_type: "float".into(),
439                    })
440            }
441            other => Err(type_err("string", other)),
442        },
443        _ => Err(ExprError::InternalDispatch {
444            op: format!("{op:?}"),
445        }),
446    }
447}
448
449/// Type inspection operations.
450fn apply_inspection(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
451    match op {
452        BuiltinOp::TypeOf => Ok(Literal::Str(args[0].type_name().to_string())),
453        BuiltinOp::IsNull => Ok(Literal::Bool(args[0].is_null())),
454        BuiltinOp::IsList => Ok(Literal::Bool(matches!(args[0], Literal::List(_)))),
455        _ => Err(ExprError::InternalDispatch {
456            op: format!("{op:?}"),
457        }),
458    }
459}
460
461/// Utility operations: default, clamp, `truncate_str`.
462#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
463fn apply_utility(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
464    match op {
465        BuiltinOp::DefaultVal => {
466            if args[0].is_null() {
467                Ok(args[1].clone())
468            } else {
469                Ok(args[0].clone())
470            }
471        }
472        BuiltinOp::Clamp => match (&args[0], &args[1], &args[2]) {
473            (Literal::Int(x), Literal::Int(lo), Literal::Int(hi)) if lo <= hi => {
474                Ok(Literal::Int((*x).clamp(*lo, *hi)))
475            }
476            (Literal::Float(x), Literal::Float(lo), Literal::Float(hi)) if lo <= hi => {
477                Ok(Literal::Float(x.clamp(*lo, *hi)))
478            }
479            (Literal::Int(_), Literal::Int(_), Literal::Int(_))
480            | (Literal::Float(_), Literal::Float(_), Literal::Float(_)) => {
481                Err(ExprError::TypeError {
482                    expected: "clamp requires min <= max".into(),
483                    got: "min > max".into(),
484                })
485            }
486            _ => Err(type_err(
487                "(int, int, int) or (float, float, float)",
488                &args[0],
489            )),
490        },
491        BuiltinOp::TruncateStr => match (&args[0], &args[1]) {
492            (Literal::Str(s), Literal::Int(max_len)) => {
493                let max = (*max_len).max(0) as usize;
494                let truncated = if max >= s.len() {
495                    s.clone()
496                } else {
497                    // Find the last char boundary at or before max.
498                    let mut end = max;
499                    while end > 0 && !s.is_char_boundary(end) {
500                        end -= 1;
501                    }
502                    s[..end].to_string()
503                };
504                Ok(Literal::Str(truncated))
505            }
506            _ => Err(type_err("(string, int)", &args[0])),
507        },
508        _ => Err(ExprError::InternalDispatch {
509            op: format!("{op:?}"),
510        }),
511    }
512}
513
514/// Apply a numeric binary operation, promoting int+float to float.
515fn numeric_binop(
516    a: &Literal,
517    b: &Literal,
518    int_op: fn(i64, i64) -> Option<i64>,
519    float_op: fn(f64, f64) -> f64,
520) -> Result<Literal, ExprError> {
521    match (a, b) {
522        (Literal::Int(x), Literal::Int(y)) => {
523            int_op(*x, *y).map(Literal::Int).ok_or(ExprError::Overflow)
524        }
525        (Literal::Float(x), Literal::Float(y)) => Ok(Literal::Float(float_op(*x, *y))),
526        #[allow(clippy::cast_precision_loss)]
527        (Literal::Int(x), Literal::Float(y)) => Ok(Literal::Float(float_op(*x as f64, *y))),
528        #[allow(clippy::cast_precision_loss)]
529        (Literal::Float(x), Literal::Int(y)) => Ok(Literal::Float(float_op(*x, *y as f64))),
530        _ => Err(type_err("int|float", a)),
531    }
532}
533
534/// Ordering comparison for numeric and string types.
535fn compare(
536    a: &Literal,
537    b: &Literal,
538    pred: fn(std::cmp::Ordering) -> bool,
539) -> Result<Literal, ExprError> {
540    let ord = match (a, b) {
541        (Literal::Int(x), Literal::Int(y)) => x.cmp(y),
542        (Literal::Float(x), Literal::Float(y)) => x.total_cmp(y),
543        #[allow(clippy::cast_precision_loss)]
544        (Literal::Int(x), Literal::Float(y)) => (*x as f64).total_cmp(y),
545        #[allow(clippy::cast_precision_loss)]
546        (Literal::Float(x), Literal::Int(y)) => x.total_cmp(&(*y as f64)),
547        (Literal::Str(x), Literal::Str(y)) => x.cmp(y),
548        _ => {
549            return Err(ExprError::TypeError {
550                expected: "comparable types (int, float, or string)".into(),
551                got: format!("({}, {})", a.type_name(), b.type_name()),
552            });
553        }
554    };
555    Ok(Literal::Bool(pred(ord)))
556}
557
558fn type_err(expected: &str, got: &Literal) -> ExprError {
559    ExprError::TypeError {
560        expected: expected.into(),
561        got: got.type_name().into(),
562    }
563}
564
565#[cfg(test)]
566#[allow(clippy::unwrap_used)]
567mod tests {
568    use super::*;
569
570    #[test]
571    fn add_ints() {
572        let result = apply_builtin(BuiltinOp::Add, &[Literal::Int(2), Literal::Int(3)]);
573        assert_eq!(result.unwrap(), Literal::Int(5));
574    }
575
576    #[test]
577    fn add_int_float_promotion() {
578        let result = apply_builtin(BuiltinOp::Add, &[Literal::Int(2), Literal::Float(1.5)]);
579        assert_eq!(result.unwrap(), Literal::Float(3.5));
580    }
581
582    #[test]
583    fn div_by_zero() {
584        let result = apply_builtin(BuiltinOp::Div, &[Literal::Int(1), Literal::Int(0)]);
585        assert!(matches!(result, Err(ExprError::DivisionByZero)));
586    }
587
588    #[test]
589    fn string_split_join_roundtrip() {
590        let parts = apply_builtin(
591            BuiltinOp::Split,
592            &[Literal::Str("a,b,c".into()), Literal::Str(",".into())],
593        )
594        .unwrap();
595        let joined = apply_builtin(BuiltinOp::Join, &[parts, Literal::Str(",".into())]).unwrap();
596        assert_eq!(joined, Literal::Str("a,b,c".into()));
597    }
598
599    #[test]
600    fn str_to_int_ok() {
601        let result = apply_builtin(BuiltinOp::StrToInt, &[Literal::Str("42".into())]);
602        assert_eq!(result.unwrap(), Literal::Int(42));
603    }
604
605    #[test]
606    fn str_to_int_fail() {
607        let result = apply_builtin(BuiltinOp::StrToInt, &[Literal::Str("hello".into())]);
608        assert!(matches!(result, Err(ExprError::ParseError { .. })));
609    }
610
611    #[test]
612    fn record_merge() {
613        let a = Literal::Record(vec![
614            (Arc::from("x"), Literal::Int(1)),
615            (Arc::from("y"), Literal::Int(2)),
616        ]);
617        let b = Literal::Record(vec![(Arc::from("y"), Literal::Int(99))]);
618        let result = apply_builtin(BuiltinOp::MergeRecords, &[a, b]).unwrap();
619        assert_eq!(
620            result,
621            Literal::Record(vec![
622                (Arc::from("x"), Literal::Int(1)),
623                (Arc::from("y"), Literal::Int(99)),
624            ])
625        );
626    }
627
628    #[test]
629    fn list_head_tail() {
630        let list = Literal::List(vec![Literal::Int(1), Literal::Int(2), Literal::Int(3)]);
631        assert_eq!(
632            apply_builtin(BuiltinOp::Head, std::slice::from_ref(&list)).unwrap(),
633            Literal::Int(1)
634        );
635        assert_eq!(
636            apply_builtin(BuiltinOp::Tail, &[list]).unwrap(),
637            Literal::List(vec![Literal::Int(2), Literal::Int(3)])
638        );
639    }
640
641    #[test]
642    fn empty_list_head_errors() {
643        let result = apply_builtin(BuiltinOp::Head, &[Literal::List(vec![])]);
644        assert!(matches!(result, Err(ExprError::IndexOutOfBounds { .. })));
645    }
646
647    #[test]
648    fn comparison_uses_total_cmp() {
649        // NaN comparisons should not panic
650        let result = apply_builtin(
651            BuiltinOp::Lt,
652            &[Literal::Float(f64::NAN), Literal::Float(1.0)],
653        );
654        assert!(result.is_ok());
655    }
656
657    #[test]
658    fn misrouted_op_returns_internal_dispatch_error() {
659        // Calling a category handler with an op outside its category
660        // returns an error rather than panicking. `Add` is arithmetic,
661        // so routing it to the comparison handler must be rejected.
662        let result = apply_comparison(BuiltinOp::Add, &[Literal::Int(1), Literal::Int(2)]);
663        assert!(matches!(
664            result,
665            Err(ExprError::InternalDispatch { ref op }) if op == "Add"
666        ));
667    }
668}