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