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/// Apply a builtin operation to evaluated arguments.
13///
14/// # Errors
15///
16/// Returns [`ExprError`] if argument types don't match or a runtime
17/// error occurs (division by zero, parse failure, etc.).
18pub fn apply_builtin(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
19    let expected = op.arity();
20    if args.len() != expected {
21        return Err(ExprError::ArityMismatch {
22            op: format!("{op:?}"),
23            expected,
24            got: args.len(),
25        });
26    }
27
28    match op {
29        // --- Arithmetic ---
30        BuiltinOp::Add
31        | BuiltinOp::Sub
32        | BuiltinOp::Mul
33        | BuiltinOp::Div
34        | BuiltinOp::Mod
35        | BuiltinOp::Neg
36        | BuiltinOp::Abs
37        | BuiltinOp::Floor
38        | BuiltinOp::Ceil => apply_arithmetic(op, args),
39
40        // --- Comparison ---
41        BuiltinOp::Eq
42        | BuiltinOp::Neq
43        | BuiltinOp::Lt
44        | BuiltinOp::Lte
45        | BuiltinOp::Gt
46        | BuiltinOp::Gte => apply_comparison(op, args),
47
48        // --- Boolean ---
49        BuiltinOp::And | BuiltinOp::Or | BuiltinOp::Not => apply_boolean(op, args),
50
51        // --- String ---
52        BuiltinOp::Concat
53        | BuiltinOp::Len
54        | BuiltinOp::Slice
55        | BuiltinOp::Upper
56        | BuiltinOp::Lower
57        | BuiltinOp::Trim
58        | BuiltinOp::Split
59        | BuiltinOp::Join
60        | BuiltinOp::Replace
61        | BuiltinOp::Contains => apply_string(op, args),
62
63        // --- List ---
64        BuiltinOp::Map
65        | BuiltinOp::Filter
66        | BuiltinOp::Fold
67        | BuiltinOp::FlatMap
68        | BuiltinOp::Append
69        | BuiltinOp::Head
70        | BuiltinOp::Tail
71        | BuiltinOp::Reverse
72        | BuiltinOp::Length => apply_list(op, args),
73
74        // --- Record ---
75        BuiltinOp::MergeRecords | BuiltinOp::Keys | BuiltinOp::Values | BuiltinOp::HasField => {
76            apply_record(op, args)
77        }
78
79        // --- Type coercions ---
80        BuiltinOp::IntToFloat
81        | BuiltinOp::FloatToInt
82        | BuiltinOp::IntToStr
83        | BuiltinOp::FloatToStr
84        | BuiltinOp::StrToInt
85        | BuiltinOp::StrToFloat => apply_coercion(op, args),
86
87        // --- Type inspection ---
88        BuiltinOp::TypeOf | BuiltinOp::IsNull | BuiltinOp::IsList => apply_inspection(op, args),
89        // Graph traversal builtins require an instance context.
90        // In the standard evaluator (no instance), they return Null.
91        // Use `panproto_inst::instance_env` for instance-aware evaluation.
92        BuiltinOp::Edge
93        | BuiltinOp::Children
94        | BuiltinOp::HasEdge
95        | BuiltinOp::EdgeCount
96        | BuiltinOp::Anchor => Ok(Literal::Null),
97    }
98}
99
100/// Arithmetic operations.
101fn apply_arithmetic(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
102    match op {
103        BuiltinOp::Add => numeric_binop(&args[0], &args[1], i64::checked_add, |a, b| a + b),
104        BuiltinOp::Sub => numeric_binop(&args[0], &args[1], i64::checked_sub, |a, b| a - b),
105        BuiltinOp::Mul => numeric_binop(&args[0], &args[1], i64::checked_mul, |a, b| a * b),
106        BuiltinOp::Div => {
107            let is_zero = match (&args[0], &args[1]) {
108                (_, Literal::Int(0)) => true,
109                (_, Literal::Float(b)) if *b == 0.0 => true,
110                _ => false,
111            };
112            if is_zero {
113                Err(ExprError::DivisionByZero)
114            } else {
115                numeric_binop(&args[0], &args[1], i64::checked_div, |a, b| a / b)
116            }
117        }
118        BuiltinOp::Mod => match (&args[0], &args[1]) {
119            (Literal::Int(_), Literal::Int(0)) => Err(ExprError::DivisionByZero),
120            (Literal::Int(a), Literal::Int(b)) => Ok(Literal::Int(a % b)),
121            _ => Err(type_err("int", &args[0])),
122        },
123        BuiltinOp::Neg => match &args[0] {
124            Literal::Int(n) => Ok(Literal::Int(-n)),
125            Literal::Float(f) => Ok(Literal::Float(-f)),
126            other => Err(type_err("int|float", other)),
127        },
128        BuiltinOp::Abs => match &args[0] {
129            Literal::Int(n) => Ok(Literal::Int(n.abs())),
130            Literal::Float(f) => Ok(Literal::Float(f.abs())),
131            other => Err(type_err("int|float", other)),
132        },
133        #[allow(clippy::cast_possible_truncation)]
134        BuiltinOp::Floor => match &args[0] {
135            Literal::Float(f) => Ok(Literal::Int(f.floor() as i64)),
136            other => Err(type_err("float", other)),
137        },
138        #[allow(clippy::cast_possible_truncation)]
139        BuiltinOp::Ceil => match &args[0] {
140            Literal::Float(f) => Ok(Literal::Int(f.ceil() as i64)),
141            other => Err(type_err("float", other)),
142        },
143        _ => unreachable!(),
144    }
145}
146
147/// Comparison operations.
148fn apply_comparison(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
149    match op {
150        BuiltinOp::Eq => Ok(Literal::Bool(args[0] == args[1])),
151        BuiltinOp::Neq => Ok(Literal::Bool(args[0] != args[1])),
152        BuiltinOp::Lt => compare(&args[0], &args[1], std::cmp::Ordering::is_lt),
153        BuiltinOp::Lte => compare(&args[0], &args[1], std::cmp::Ordering::is_le),
154        BuiltinOp::Gt => compare(&args[0], &args[1], std::cmp::Ordering::is_gt),
155        BuiltinOp::Gte => compare(&args[0], &args[1], std::cmp::Ordering::is_ge),
156        _ => unreachable!(),
157    }
158}
159
160/// Boolean operations.
161fn apply_boolean(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
162    match op {
163        BuiltinOp::And => match (&args[0], &args[1]) {
164            (Literal::Bool(a), Literal::Bool(b)) => Ok(Literal::Bool(*a && *b)),
165            (Literal::Bool(_), other) | (other, _) => Err(type_err("bool", other)),
166        },
167        BuiltinOp::Or => match (&args[0], &args[1]) {
168            (Literal::Bool(a), Literal::Bool(b)) => Ok(Literal::Bool(*a || *b)),
169            (Literal::Bool(_), other) | (other, _) => Err(type_err("bool", other)),
170        },
171        BuiltinOp::Not => match &args[0] {
172            Literal::Bool(b) => Ok(Literal::Bool(!b)),
173            other => Err(type_err("bool", other)),
174        },
175        _ => unreachable!(),
176    }
177}
178
179/// String operations.
180#[allow(
181    clippy::cast_possible_truncation,
182    clippy::cast_possible_wrap,
183    clippy::cast_sign_loss
184)]
185fn apply_string(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
186    match op {
187        BuiltinOp::Concat => match (&args[0], &args[1]) {
188            (Literal::Str(a), Literal::Str(b)) => {
189                let mut s = a.clone();
190                s.push_str(b);
191                Ok(Literal::Str(s))
192            }
193            (Literal::Str(_), other) | (other, _) => Err(type_err("string", other)),
194        },
195        BuiltinOp::Len => match &args[0] {
196            Literal::Str(s) => Ok(Literal::Int(s.len() as i64)),
197            other => Err(type_err("string", other)),
198        },
199        BuiltinOp::Slice => match (&args[0], &args[1], &args[2]) {
200            (Literal::Str(s), Literal::Int(start), Literal::Int(end)) => {
201                let start = (*start).max(0) as usize;
202                let end = (*end).max(0) as usize;
203                let end = end.min(s.len());
204                let start = start.min(end);
205                Ok(Literal::Str(s[start..end].to_string()))
206            }
207            _ => Err(type_err("(string, int, int)", &args[0])),
208        },
209        BuiltinOp::Upper => match &args[0] {
210            Literal::Str(s) => Ok(Literal::Str(s.to_uppercase())),
211            other => Err(type_err("string", other)),
212        },
213        BuiltinOp::Lower => match &args[0] {
214            Literal::Str(s) => Ok(Literal::Str(s.to_lowercase())),
215            other => Err(type_err("string", other)),
216        },
217        BuiltinOp::Trim => match &args[0] {
218            Literal::Str(s) => Ok(Literal::Str(s.trim().to_string())),
219            other => Err(type_err("string", other)),
220        },
221        BuiltinOp::Split => match (&args[0], &args[1]) {
222            (Literal::Str(s), Literal::Str(delim)) => Ok(Literal::List(
223                s.split(&**delim)
224                    .map(|p| Literal::Str(p.to_string()))
225                    .collect(),
226            )),
227            _ => Err(type_err("(string, string)", &args[0])),
228        },
229        BuiltinOp::Join => match (&args[0], &args[1]) {
230            (Literal::List(parts), Literal::Str(delim)) => {
231                let strs: Result<Vec<_>, _> = parts
232                    .iter()
233                    .map(|p| match p {
234                        Literal::Str(s) => Ok(s.as_str()),
235                        other => Err(type_err("string", other)),
236                    })
237                    .collect();
238                Ok(Literal::Str(strs?.join(delim)))
239            }
240            _ => Err(type_err("([string], string)", &args[0])),
241        },
242        BuiltinOp::Replace => match (&args[0], &args[1], &args[2]) {
243            (Literal::Str(s), Literal::Str(from), Literal::Str(to)) => {
244                Ok(Literal::Str(s.replace(&**from, to)))
245            }
246            _ => Err(type_err("(string, string, string)", &args[0])),
247        },
248        BuiltinOp::Contains => match (&args[0], &args[1]) {
249            (Literal::Str(s), Literal::Str(substr)) => Ok(Literal::Bool(s.contains(&**substr))),
250            _ => Err(type_err("(string, string)", &args[0])),
251        },
252        _ => unreachable!(),
253    }
254}
255
256/// List operations.
257#[allow(clippy::cast_possible_wrap)]
258fn apply_list(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
259    match op {
260        // Map, Filter, Fold, FlatMap require lambda evaluation — handled in eval.rs.
261        BuiltinOp::Map | BuiltinOp::Filter | BuiltinOp::Fold | BuiltinOp::FlatMap => {
262            Err(ExprError::TypeError {
263                expected: "handled in evaluator".into(),
264                got: "direct builtin call".into(),
265            })
266        }
267        BuiltinOp::Append => match (&args[0], &args[1]) {
268            (Literal::List(items), val) => {
269                let mut new_items = items.clone();
270                new_items.push(val.clone());
271                Ok(Literal::List(new_items))
272            }
273            (other, _) => Err(type_err("list", other)),
274        },
275        BuiltinOp::Head => match &args[0] {
276            Literal::List(items) if items.is_empty() => {
277                Err(ExprError::IndexOutOfBounds { index: 0, len: 0 })
278            }
279            Literal::List(items) => Ok(items[0].clone()),
280            other => Err(type_err("list", other)),
281        },
282        BuiltinOp::Tail => match &args[0] {
283            Literal::List(items) if items.is_empty() => {
284                Err(ExprError::IndexOutOfBounds { index: 0, len: 0 })
285            }
286            Literal::List(items) => Ok(Literal::List(items[1..].to_vec())),
287            other => Err(type_err("list", other)),
288        },
289        BuiltinOp::Reverse => match &args[0] {
290            Literal::List(items) => {
291                let mut reversed = items.clone();
292                reversed.reverse();
293                Ok(Literal::List(reversed))
294            }
295            other => Err(type_err("list", other)),
296        },
297        BuiltinOp::Length => match &args[0] {
298            Literal::List(items) => Ok(Literal::Int(items.len() as i64)),
299            other => Err(type_err("list", other)),
300        },
301        _ => unreachable!(),
302    }
303}
304
305/// Record operations.
306fn apply_record(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
307    match op {
308        BuiltinOp::MergeRecords => match (&args[0], &args[1]) {
309            (Literal::Record(a), Literal::Record(b)) => {
310                let mut merged = a.clone();
311                for (k, v) in b {
312                    if let Some(existing) = merged.iter_mut().find(|(ek, _)| ek == k) {
313                        existing.1 = v.clone();
314                    } else {
315                        merged.push((Arc::clone(k), v.clone()));
316                    }
317                }
318                Ok(Literal::Record(merged))
319            }
320            (Literal::Record(_), other) | (other, _) => Err(type_err("record", other)),
321        },
322        BuiltinOp::Keys => match &args[0] {
323            Literal::Record(fields) => Ok(Literal::List(
324                fields
325                    .iter()
326                    .map(|(k, _)| Literal::Str(k.to_string()))
327                    .collect(),
328            )),
329            other => Err(type_err("record", other)),
330        },
331        BuiltinOp::Values => match &args[0] {
332            Literal::Record(fields) => Ok(Literal::List(
333                fields.iter().map(|(_, v)| v.clone()).collect(),
334            )),
335            other => Err(type_err("record", other)),
336        },
337        BuiltinOp::HasField => match (&args[0], &args[1]) {
338            (Literal::Record(fields), Literal::Str(name)) => Ok(Literal::Bool(
339                fields.iter().any(|(k, _)| &**k == name.as_str()),
340            )),
341            _ => Err(type_err("(record, string)", &args[0])),
342        },
343        _ => unreachable!(),
344    }
345}
346
347/// Type coercion operations.
348#[allow(clippy::cast_possible_truncation, clippy::cast_precision_loss)]
349fn apply_coercion(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
350    match op {
351        BuiltinOp::IntToFloat => match &args[0] {
352            Literal::Int(n) => Ok(Literal::Float(*n as f64)),
353            other => Err(type_err("int", other)),
354        },
355        BuiltinOp::FloatToInt => match &args[0] {
356            #[allow(clippy::cast_possible_truncation)]
357            Literal::Float(f) => Ok(Literal::Int(*f as i64)),
358            other => Err(type_err("float", other)),
359        },
360        BuiltinOp::IntToStr => match &args[0] {
361            Literal::Int(n) => Ok(Literal::Str(n.to_string())),
362            other => Err(type_err("int", other)),
363        },
364        BuiltinOp::FloatToStr => match &args[0] {
365            Literal::Float(f) => Ok(Literal::Str(f.to_string())),
366            other => Err(type_err("float", other)),
367        },
368        BuiltinOp::StrToInt => match &args[0] {
369            Literal::Str(s) => {
370                s.parse::<i64>()
371                    .map(Literal::Int)
372                    .map_err(|_| ExprError::ParseError {
373                        value: s.clone(),
374                        target_type: "int".into(),
375                    })
376            }
377            other => Err(type_err("string", other)),
378        },
379        BuiltinOp::StrToFloat => match &args[0] {
380            Literal::Str(s) => {
381                s.parse::<f64>()
382                    .map(Literal::Float)
383                    .map_err(|_| ExprError::ParseError {
384                        value: s.clone(),
385                        target_type: "float".into(),
386                    })
387            }
388            other => Err(type_err("string", other)),
389        },
390        _ => unreachable!(),
391    }
392}
393
394/// Type inspection operations.
395fn apply_inspection(op: BuiltinOp, args: &[Literal]) -> Result<Literal, ExprError> {
396    match op {
397        BuiltinOp::TypeOf => Ok(Literal::Str(args[0].type_name().to_string())),
398        BuiltinOp::IsNull => Ok(Literal::Bool(args[0].is_null())),
399        BuiltinOp::IsList => Ok(Literal::Bool(matches!(args[0], Literal::List(_)))),
400        _ => unreachable!(),
401    }
402}
403
404/// Apply a numeric binary operation, promoting int+float to float.
405fn numeric_binop(
406    a: &Literal,
407    b: &Literal,
408    int_op: fn(i64, i64) -> Option<i64>,
409    float_op: fn(f64, f64) -> f64,
410) -> Result<Literal, ExprError> {
411    match (a, b) {
412        (Literal::Int(x), Literal::Int(y)) => {
413            int_op(*x, *y)
414                .map(Literal::Int)
415                .ok_or_else(|| ExprError::TypeError {
416                    expected: "non-overflowing arithmetic".into(),
417                    got: "integer overflow".into(),
418                })
419        }
420        (Literal::Float(x), Literal::Float(y)) => Ok(Literal::Float(float_op(*x, *y))),
421        #[allow(clippy::cast_precision_loss)]
422        (Literal::Int(x), Literal::Float(y)) => Ok(Literal::Float(float_op(*x as f64, *y))),
423        #[allow(clippy::cast_precision_loss)]
424        (Literal::Float(x), Literal::Int(y)) => Ok(Literal::Float(float_op(*x, *y as f64))),
425        _ => Err(type_err("int|float", a)),
426    }
427}
428
429/// Ordering comparison for numeric and string types.
430fn compare(
431    a: &Literal,
432    b: &Literal,
433    pred: fn(std::cmp::Ordering) -> bool,
434) -> Result<Literal, ExprError> {
435    let ord = match (a, b) {
436        (Literal::Int(x), Literal::Int(y)) => x.cmp(y),
437        (Literal::Float(x), Literal::Float(y)) => x.total_cmp(y),
438        #[allow(clippy::cast_precision_loss)]
439        (Literal::Int(x), Literal::Float(y)) => (*x as f64).total_cmp(y),
440        #[allow(clippy::cast_precision_loss)]
441        (Literal::Float(x), Literal::Int(y)) => x.total_cmp(&(*y as f64)),
442        (Literal::Str(x), Literal::Str(y)) => x.cmp(y),
443        _ => {
444            return Err(ExprError::TypeError {
445                expected: "comparable types (int, float, or string)".into(),
446                got: format!("({}, {})", a.type_name(), b.type_name()),
447            });
448        }
449    };
450    Ok(Literal::Bool(pred(ord)))
451}
452
453fn type_err(expected: &str, got: &Literal) -> ExprError {
454    ExprError::TypeError {
455        expected: expected.into(),
456        got: got.type_name().into(),
457    }
458}
459
460#[cfg(test)]
461#[allow(clippy::unwrap_used)]
462mod tests {
463    use super::*;
464
465    #[test]
466    fn add_ints() {
467        let result = apply_builtin(BuiltinOp::Add, &[Literal::Int(2), Literal::Int(3)]);
468        assert_eq!(result.unwrap(), Literal::Int(5));
469    }
470
471    #[test]
472    fn add_int_float_promotion() {
473        let result = apply_builtin(BuiltinOp::Add, &[Literal::Int(2), Literal::Float(1.5)]);
474        assert_eq!(result.unwrap(), Literal::Float(3.5));
475    }
476
477    #[test]
478    fn div_by_zero() {
479        let result = apply_builtin(BuiltinOp::Div, &[Literal::Int(1), Literal::Int(0)]);
480        assert!(matches!(result, Err(ExprError::DivisionByZero)));
481    }
482
483    #[test]
484    fn string_split_join_roundtrip() {
485        let parts = apply_builtin(
486            BuiltinOp::Split,
487            &[Literal::Str("a,b,c".into()), Literal::Str(",".into())],
488        )
489        .unwrap();
490        let joined = apply_builtin(BuiltinOp::Join, &[parts, Literal::Str(",".into())]).unwrap();
491        assert_eq!(joined, Literal::Str("a,b,c".into()));
492    }
493
494    #[test]
495    fn str_to_int_ok() {
496        let result = apply_builtin(BuiltinOp::StrToInt, &[Literal::Str("42".into())]);
497        assert_eq!(result.unwrap(), Literal::Int(42));
498    }
499
500    #[test]
501    fn str_to_int_fail() {
502        let result = apply_builtin(BuiltinOp::StrToInt, &[Literal::Str("hello".into())]);
503        assert!(matches!(result, Err(ExprError::ParseError { .. })));
504    }
505
506    #[test]
507    fn record_merge() {
508        let a = Literal::Record(vec![
509            (Arc::from("x"), Literal::Int(1)),
510            (Arc::from("y"), Literal::Int(2)),
511        ]);
512        let b = Literal::Record(vec![(Arc::from("y"), Literal::Int(99))]);
513        let result = apply_builtin(BuiltinOp::MergeRecords, &[a, b]).unwrap();
514        assert_eq!(
515            result,
516            Literal::Record(vec![
517                (Arc::from("x"), Literal::Int(1)),
518                (Arc::from("y"), Literal::Int(99)),
519            ])
520        );
521    }
522
523    #[test]
524    fn list_head_tail() {
525        let list = Literal::List(vec![Literal::Int(1), Literal::Int(2), Literal::Int(3)]);
526        assert_eq!(
527            apply_builtin(BuiltinOp::Head, std::slice::from_ref(&list)).unwrap(),
528            Literal::Int(1)
529        );
530        assert_eq!(
531            apply_builtin(BuiltinOp::Tail, &[list]).unwrap(),
532            Literal::List(vec![Literal::Int(2), Literal::Int(3)])
533        );
534    }
535
536    #[test]
537    fn empty_list_head_errors() {
538        let result = apply_builtin(BuiltinOp::Head, &[Literal::List(vec![])]);
539        assert!(matches!(result, Err(ExprError::IndexOutOfBounds { .. })));
540    }
541
542    #[test]
543    fn comparison_uses_total_cmp() {
544        // NaN comparisons should not panic
545        let result = apply_builtin(
546            BuiltinOp::Lt,
547            &[Literal::Float(f64::NAN), Literal::Float(1.0)],
548        );
549        assert!(result.is_ok());
550    }
551}