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