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