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