Skip to main content

github_actions_expressions/
call.rs

1//! Representation of function calls in GitHub Actions expressions.
2
3use crate::{Evaluation, EvaluationSema, SpannedExpr};
4
5/// Errors that can occur during parsing of function calls.
6#[derive(Debug, thiserror::Error)]
7pub enum Error {
8    /// An unknown function name was encountered during parsing.
9    #[error("Unknown function: {0}")]
10    UnknownFunction(String),
11    /// A function was called with an incorrect number of arguments.
12    #[error("Incorrect arguments for function {0:?}: expected {1}")]
13    Arity(Function, &'static str),
14}
15
16/// Represents a function in a GitHub Actions expression.
17///
18/// Function names are case-insensitive.
19#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
20pub enum Function {
21    /// `contains(haystack, needle)`
22    Contains,
23    /// `startsWith(string, prefix)`
24    StartsWith,
25    /// `endsWith(string, suffix)`
26    EndsWith,
27    /// `format(fmtspec, args...)`
28    Format,
29    /// `join(separator, items...)`
30    Join,
31    /// `toJSON(value)`
32    ToJSON,
33    /// `fromJSON(json)`
34    FromJSON,
35    /// `hashFiles(glob, ...)`
36    HashFiles,
37    /// `case(pred1, val1, pred2, val2, ..., default)`
38    Case,
39    // Special status-check functions.
40    // See: <https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#status-check-functions>
41    /// `success()`
42    Success,
43    /// `always()`
44    Always,
45    /// `cancelled()`
46    Cancelled,
47    /// `failure()`
48    Failure,
49}
50
51impl Function {
52    pub(crate) fn new(name: &str) -> Option<Self> {
53        match name.to_ascii_lowercase().as_str() {
54            "contains" => Some(Self::Contains),
55            "startswith" => Some(Self::StartsWith),
56            "endswith" => Some(Self::EndsWith),
57            "format" => Some(Self::Format),
58            "join" => Some(Self::Join),
59            "tojson" => Some(Self::ToJSON),
60            "fromjson" => Some(Self::FromJSON),
61            "hashfiles" => Some(Self::HashFiles),
62            "case" => Some(Self::Case),
63            "success" => Some(Self::Success),
64            "always" => Some(Self::Always),
65            "cancelled" => Some(Self::Cancelled),
66            "failure" => Some(Self::Failure),
67            _ => None,
68        }
69    }
70}
71
72/// Represents a function call in a GitHub Actions expression.
73#[derive(Debug, PartialEq)]
74pub struct Call<'src> {
75    /// The function name, e.g. `foo` in `foo()`.
76    pub func: Function,
77    /// The function's arguments.
78    pub args: Vec<SpannedExpr<'src>>,
79}
80
81impl<'src> Call<'src> {
82    pub(crate) fn new(func: &str, args: Vec<SpannedExpr<'src>>) -> Result<Self, Error> {
83        let func = Function::new(func).ok_or_else(|| Error::UnknownFunction(func.to_string()))?;
84
85        match func {
86            Function::Contains | Function::StartsWith | Function::EndsWith if args.len() != 2 => {
87                return Err(Error::Arity(func, "exactly 2 arguments"));
88            }
89            Function::ToJSON | Function::FromJSON if args.len() != 1 => {
90                return Err(Error::Arity(func, "exactly 1 argument"));
91            }
92            Function::Join if args.is_empty() || args.len() > 2 => {
93                return Err(Error::Arity(func, "1 or 2 arguments"));
94            }
95            Function::HashFiles if args.is_empty() => {
96                return Err(Error::Arity(func, "at least 1 argument"));
97            }
98            Function::Case => {
99                if args.len() < 3 {
100                    return Err(Error::Arity(func, "at least 3 arguments"));
101                }
102                if args.len().is_multiple_of(2) {
103                    return Err(Error::Arity(func, "odd number of arguments"));
104                }
105            }
106            Function::Success | Function::Always | Function::Cancelled | Function::Failure
107                if !args.is_empty() =>
108            {
109                return Err(Error::Arity(func, "no arguments"));
110            }
111            _ => (),
112        }
113
114        Ok(Self { func, args })
115    }
116
117    /// Performs constant evaluation of a GitHub Actions expression
118    /// function call.
119    pub(crate) fn consteval(&self) -> Option<Evaluation> {
120        let args = self
121            .args
122            .iter()
123            .map(|arg| arg.consteval())
124            .collect::<Option<Vec<Evaluation>>>()?;
125
126        match &self.func {
127            Function::Format => Self::consteval_format(&args),
128            Function::Contains => Self::consteval_contains(&args),
129            Function::StartsWith => Self::consteval_startswith(&args),
130            Function::EndsWith => Self::consteval_endswith(&args),
131            Function::ToJSON => Self::consteval_tojson(&args),
132            Function::FromJSON => Self::consteval_fromjson(&args),
133            Function::Join => Self::consteval_join(&args),
134            Function::Case => Self::consteval_case(&args),
135            _ => None,
136        }
137    }
138
139    /// Constant-evaluates a `format(fmtspec, args...)` call.
140    ///
141    /// See: <https://github.com/actions/languageservices/blob/1f3436c3cacc0f99d5d79e7120a5a9270cf13a72/expressions/src/funcs/format.ts>
142    fn consteval_format(args: &[Evaluation]) -> Option<Evaluation> {
143        if args.is_empty() {
144            return None;
145        }
146
147        let template = args[0].sema().to_string();
148        let mut result = String::new();
149        let mut index = 0;
150
151        while index < template.len() {
152            let lbrace = template[index..].find('{').map(|pos| index + pos);
153            let rbrace = template[index..].find('}').map(|pos| index + pos);
154
155            // Left brace
156            #[allow(clippy::unwrap_used)]
157            if let Some(lbrace_pos) = lbrace
158                && (rbrace.is_none() || rbrace.unwrap() > lbrace_pos)
159            {
160                // Escaped left brace
161                if template.as_bytes().get(lbrace_pos + 1) == Some(&b'{') {
162                    result.push_str(&template[index..=lbrace_pos]);
163                    index = lbrace_pos + 2;
164                    continue;
165                }
166
167                // Left brace, number, optional format specifiers, right brace
168                if let Some(rbrace_pos) = rbrace
169                    && rbrace_pos > lbrace_pos + 1
170                    && let Some(arg_index) = Self::read_arg_index(&template, lbrace_pos + 1)
171                {
172                    // Check parameter count
173                    if 1 + arg_index > args.len() - 1 {
174                        // Invalid format string - too few arguments
175                        return None;
176                    }
177
178                    // Append the portion before the left brace
179                    if lbrace_pos > index {
180                        result.push_str(&template[index..lbrace_pos]);
181                    }
182
183                    // Append the arg
184                    result.push_str(&args[1 + arg_index].sema().to_string());
185                    index = rbrace_pos + 1;
186                    continue;
187                }
188
189                // Invalid format string
190                return None;
191            }
192
193            // Right brace
194            if let Some(rbrace_pos) = rbrace {
195                #[allow(clippy::unwrap_used)]
196                if lbrace.is_none() || lbrace.unwrap() > rbrace_pos {
197                    // Escaped right brace
198                    if template.as_bytes().get(rbrace_pos + 1) == Some(&b'}') {
199                        result.push_str(&template[index..=rbrace_pos]);
200                        index = rbrace_pos + 2;
201                    } else {
202                        // Invalid format string
203                        return None;
204                    }
205                }
206            } else {
207                // Last segment
208                result.push_str(&template[index..]);
209                break;
210            }
211        }
212
213        Some(Evaluation::String(result))
214    }
215
216    /// Helper function to read argument index from format string.
217    fn read_arg_index(string: &str, start_index: usize) -> Option<usize> {
218        let mut length = 0;
219        let chars: Vec<char> = string.chars().collect();
220
221        // Count the number of digits
222        while start_index + length < chars.len() {
223            let next_char = chars[start_index + length];
224            if next_char.is_ascii_digit() {
225                length += 1;
226            } else {
227                break;
228            }
229        }
230
231        // Validate at least one digit
232        if length < 1 {
233            return None;
234        }
235
236        // Parse the number
237        let number_str: String = chars[start_index..start_index + length].iter().collect();
238        number_str.parse::<usize>().ok()
239    }
240
241    /// Constant-evaluates a `contains(haystack, needle)` call.
242    ///
243    /// See: <https://github.com/actions/languageservices/blob/1f3436c3cacc0f99d5d79e7120a5a9270cf13a72/expressions/src/funcs/contains.ts>
244    fn consteval_contains(args: &[Evaluation]) -> Option<Evaluation> {
245        if args.len() != 2 {
246            return None;
247        }
248
249        let search = &args[0];
250        let item = &args[1];
251
252        match search {
253            // For primitive types (strings, numbers, booleans, null), do case-insensitive string search
254            Evaluation::String(_)
255            | Evaluation::Number(_)
256            | Evaluation::Boolean(_)
257            | Evaluation::Null => {
258                let search_str = search.sema().to_string().to_uppercase();
259                let item_str = item.sema().to_string().to_uppercase();
260                Some(Evaluation::Boolean(search_str.contains(&item_str)))
261            }
262            // For arrays, check if any element equals the item
263            Evaluation::Array(arr) => {
264                if arr.iter().any(|element| element.sema() == item.sema()) {
265                    Some(Evaluation::Boolean(true))
266                } else {
267                    Some(Evaluation::Boolean(false))
268                }
269            }
270            // `contains(object, ...)` is not defined in the reference implementation
271            Evaluation::Object(_) => None,
272        }
273    }
274
275    /// Constant-evaluates a `startsWith(string, prefix)` call.
276    ///
277    /// See: <https://github.com/actions/languageservices/blob/1f3436c3cacc0f99d5d79e7120a5a9270cf13a72/expressions/src/funcs/startswith.ts>
278    fn consteval_startswith(args: &[Evaluation]) -> Option<Evaluation> {
279        if args.len() != 2 {
280            return None;
281        }
282
283        let search_string = &args[0];
284        let search_value = &args[1];
285
286        // Both arguments must be primitive types (not arrays or dictionaries)
287        match (search_string, search_value) {
288            (
289                Evaluation::String(_)
290                | Evaluation::Number(_)
291                | Evaluation::Boolean(_)
292                | Evaluation::Null,
293                Evaluation::String(_)
294                | Evaluation::Number(_)
295                | Evaluation::Boolean(_)
296                | Evaluation::Null,
297            ) => {
298                // Case-insensitive comparison
299                let haystack = EvaluationSema::upper_special(&search_string.sema().to_string());
300                let needle = EvaluationSema::upper_special(&search_value.sema().to_string());
301                Some(Evaluation::Boolean(haystack.starts_with(&needle)))
302            }
303            // If either argument is not primitive (array or dictionary), return false
304            _ => Some(Evaluation::Boolean(false)),
305        }
306    }
307
308    /// Constant-evaluates an `endsWith(string, suffix)` call.
309    ///
310    /// See: <https://github.com/actions/languageservices/blob/1f3436c3cacc0f99d5d79e7120a5a9270cf13a72/expressions/src/funcs/endswith.ts>
311    fn consteval_endswith(args: &[Evaluation]) -> Option<Evaluation> {
312        if args.len() != 2 {
313            return None;
314        }
315
316        let search_string = &args[0];
317        let search_value = &args[1];
318
319        // Both arguments must be primitive types (not arrays or dictionaries)
320        match (search_string, search_value) {
321            (
322                Evaluation::String(_)
323                | Evaluation::Number(_)
324                | Evaluation::Boolean(_)
325                | Evaluation::Null,
326                Evaluation::String(_)
327                | Evaluation::Number(_)
328                | Evaluation::Boolean(_)
329                | Evaluation::Null,
330            ) => {
331                // Case-insensitive comparison
332                let haystack = EvaluationSema::upper_special(&search_string.sema().to_string());
333                let needle = EvaluationSema::upper_special(&search_value.sema().to_string());
334                Some(Evaluation::Boolean(haystack.ends_with(&needle)))
335            }
336            // If either argument is not primitive (array or dictionary), return false
337            _ => Some(Evaluation::Boolean(false)),
338        }
339    }
340
341    /// Constant-evaluates a `toJSON(value)` call.
342    ///
343    /// See: <https://github.com/actions/languageservices/blob/1f3436c3cacc0f99d5d79e7120a5a9270cf13a72/expressions/src/funcs/tojson.ts>
344    fn consteval_tojson(args: &[Evaluation]) -> Option<Evaluation> {
345        if args.len() != 1 {
346            return None;
347        }
348
349        let value = &args[0];
350        let json_value: serde_json::Value = value.clone().try_into().ok()?;
351        let json_str = serde_json::to_string_pretty(&json_value).ok()?;
352
353        Some(Evaluation::String(json_str))
354    }
355
356    /// Constant-evaluates a `fromJSON(json_string)` call.
357    ///
358    /// See: <https://github.com/actions/languageservices/blob/1f3436c3cacc0f99d5d79e7120a5a9270cf13a72/expressions/src/funcs/fromjson.ts>
359    fn consteval_fromjson(args: &[Evaluation]) -> Option<Evaluation> {
360        if args.len() != 1 {
361            return None;
362        }
363
364        let json_str = args[0].sema().to_string();
365
366        // Match reference implementation: error on empty input
367        if json_str.trim().is_empty() {
368            return None;
369        }
370
371        serde_json::from_str::<serde_json::Value>(&json_str)
372            .ok()?
373            .try_into()
374            .ok()
375    }
376
377    /// Constant-evaluates a `join(array, optionalSeparator)` call.
378    ///
379    /// See: <https://github.com/actions/languageservices/blob/1f3436c3cacc0f99d5d79e7120a5a9270cf13a72/expressions/src/funcs/join.ts>
380    fn consteval_join(args: &[Evaluation]) -> Option<Evaluation> {
381        if args.is_empty() || args.len() > 2 {
382            return None;
383        }
384
385        let array_or_string = &args[0];
386
387        // Get separator (default is comma)
388        let separator = if args.len() > 1 {
389            args[1].sema().to_string()
390        } else {
391            ",".to_string()
392        };
393
394        match array_or_string {
395            // For primitive types (strings, numbers, booleans, null), return as string
396            Evaluation::String(_)
397            | Evaluation::Number(_)
398            | Evaluation::Boolean(_)
399            | Evaluation::Null => Some(Evaluation::String(array_or_string.sema().to_string())),
400            // For arrays, join elements with separator
401            Evaluation::Array(arr) => {
402                let joined = arr
403                    .iter()
404                    .map(|item| item.sema().to_string())
405                    .collect::<Vec<String>>()
406                    .join(&separator);
407                Some(Evaluation::String(joined))
408            }
409            // For dictionaries, return empty string (not supported in reference)
410            Evaluation::Object(_) => Some(Evaluation::String("".to_string())),
411        }
412    }
413
414    /// Constant-evaluates a `case(pred1, val1, pred2, val2, ..., default)` call.
415    ///
416    /// See: <https://github.com/actions/languageservices/blob/83de320ba99ee2bdbb14a2869462a8033714cd96/expressions/src/funcs/case.ts>
417    fn consteval_case(args: &[Evaluation]) -> Option<Evaluation> {
418        if args.len() < 3 || args.len().is_multiple_of(2) {
419            return None;
420        }
421
422        let (pairs, default) = args.as_chunks::<2>();
423        for pair in pairs {
424            let pred = &pair[0];
425            let val = &pair[1];
426
427            match pred {
428                Evaluation::Boolean(true) => return Some(val.clone()),
429                Evaluation::Boolean(false) => continue,
430                // `case` predicates must be booleans; non-booleans are not coerced.
431                _ => return None,
432            }
433        }
434
435        // Default case
436        Some(default[0].clone())
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use crate::{
443        Error, Expr,
444        call::{Call, Function},
445    };
446
447    #[test]
448    fn test_function_new() {
449        assert_eq!(Function::new("contains"), Some(Function::Contains));
450        assert_eq!(Function::new("STARTSWITH"), Some(Function::StartsWith));
451        assert_eq!(Function::new("endsWith"), Some(Function::EndsWith));
452
453        assert_eq!(Function::new("unknown"), None);
454    }
455
456    #[test]
457    fn test_consteval_fromjson() -> Result<(), Error> {
458        use crate::Evaluation;
459
460        let test_cases = &[
461            // Basic primitives
462            ("fromJSON('null')", Evaluation::Null),
463            ("fromJSON('true')", Evaluation::Boolean(true)),
464            ("fromJSON('false')", Evaluation::Boolean(false)),
465            ("fromJSON('42')", Evaluation::Number(42.0)),
466            ("fromJSON('3.14')", Evaluation::Number(3.14)),
467            ("fromJSON('-0')", Evaluation::Number(0.0)),
468            ("fromJSON('0')", Evaluation::Number(0.0)),
469            (
470                "fromJSON('\"hello\"')",
471                Evaluation::String("hello".to_string()),
472            ),
473            ("fromJSON('\"\"')", Evaluation::String("".to_string())),
474            // Arrays
475            ("fromJSON('[]')", Evaluation::Array(vec![])),
476            (
477                "fromJSON('[1, 2, 3]')",
478                Evaluation::Array(vec![
479                    Evaluation::Number(1.0),
480                    Evaluation::Number(2.0),
481                    Evaluation::Number(3.0),
482                ]),
483            ),
484            (
485                "fromJSON('[\"a\", \"b\", null, true, 123]')",
486                Evaluation::Array(vec![
487                    Evaluation::String("a".to_string()),
488                    Evaluation::String("b".to_string()),
489                    Evaluation::Null,
490                    Evaluation::Boolean(true),
491                    Evaluation::Number(123.0),
492                ]),
493            ),
494            // Objects
495            (
496                "fromJSON('{}')",
497                Evaluation::Object(std::collections::HashMap::new()),
498            ),
499            (
500                "fromJSON('{\"key\": \"value\"}')",
501                Evaluation::Object({
502                    let mut map = std::collections::HashMap::new();
503                    map.insert("key".to_string(), Evaluation::String("value".to_string()));
504                    map
505                }),
506            ),
507            (
508                "fromJSON('{\"num\": 42, \"bool\": true, \"null\": null}')",
509                Evaluation::Object({
510                    let mut map = std::collections::HashMap::new();
511                    map.insert("num".to_string(), Evaluation::Number(42.0));
512                    map.insert("bool".to_string(), Evaluation::Boolean(true));
513                    map.insert("null".to_string(), Evaluation::Null);
514                    map
515                }),
516            ),
517            // Nested structures
518            (
519                "fromJSON('{\"array\": [1, 2], \"object\": {\"nested\": true}}')",
520                Evaluation::Object({
521                    let mut map = std::collections::HashMap::new();
522                    map.insert(
523                        "array".to_string(),
524                        Evaluation::Array(vec![Evaluation::Number(1.0), Evaluation::Number(2.0)]),
525                    );
526                    let mut nested_map = std::collections::HashMap::new();
527                    nested_map.insert("nested".to_string(), Evaluation::Boolean(true));
528                    map.insert("object".to_string(), Evaluation::Object(nested_map));
529                    map
530                }),
531            ),
532        ];
533
534        for (expr_str, expected) in test_cases {
535            let expr = Expr::parse(expr_str)?;
536            let result = expr.consteval().unwrap();
537            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
538        }
539
540        Ok(())
541    }
542
543    #[test]
544    fn test_consteval_fromjson_error_cases() -> Result<(), Error> {
545        let error_cases = &[
546            "fromJSON('')",          // Empty string
547            "fromJSON('   ')",       // Whitespace only
548            "fromJSON('invalid')",   // Invalid JSON
549            "fromJSON('{invalid}')", // Invalid JSON syntax
550            "fromJSON('[1, 2,]')",   // Trailing comma (invalid in strict JSON)
551        ];
552
553        for expr_str in error_cases {
554            let expr = Expr::parse(expr_str)?;
555            let result = expr.consteval();
556            assert!(
557                result.is_none(),
558                "Expected None for invalid JSON: {}",
559                expr_str
560            );
561        }
562
563        Ok(())
564    }
565
566    #[test]
567    fn test_consteval_fromjson_display_format() -> Result<(), Error> {
568        use crate::Evaluation;
569
570        let test_cases = &[
571            (Evaluation::Array(vec![Evaluation::Number(1.0)]), "Array"),
572            (
573                Evaluation::Object(std::collections::HashMap::new()),
574                "Object",
575            ),
576        ];
577
578        for (result, expected) in test_cases {
579            assert_eq!(result.sema().to_string(), *expected);
580        }
581
582        Ok(())
583    }
584
585    #[test]
586    fn test_consteval_tojson_fromjson_roundtrip() -> Result<(), Error> {
587        use crate::Evaluation;
588
589        // Test round-trip conversion for complex structures
590        let test_cases = &[
591            // Simple array
592            "[1, 2, 3]",
593            // Simple object
594            r#"{"key": "value"}"#,
595            // Mixed array
596            r#"[1, "hello", true, null]"#,
597            // Nested structure
598            r#"{"array": [1, 2], "object": {"nested": true}}"#,
599        ];
600
601        for json_str in test_cases {
602            // Parse with fromJSON
603            let from_expr_str = format!("fromJSON('{}')", json_str);
604            let from_expr = Expr::parse(&from_expr_str)?;
605            let parsed = from_expr.consteval().unwrap();
606
607            // Convert back with toJSON (using a dummy toJSON call structure)
608            let to_result = Call::consteval_tojson(&[parsed.clone()]).unwrap();
609
610            // Parse the result again to compare structure
611            let reparsed_expr_str = format!("fromJSON('{}')", to_result.sema().to_string());
612            let reparsed_expr = Expr::parse(&reparsed_expr_str)?;
613            let reparsed = reparsed_expr.consteval().unwrap();
614
615            // The structure should be preserved (though ordering might differ for objects)
616            match (&parsed, &reparsed) {
617                (Evaluation::Array(a), Evaluation::Array(b)) => assert_eq!(a, b),
618                (Evaluation::Object(_), Evaluation::Object(_)) => {
619                    // For dictionaries, we just check that both are dictionaries
620                    // since ordering might differ
621                    assert!(matches!(parsed, Evaluation::Object(_)));
622                    assert!(matches!(reparsed, Evaluation::Object(_)));
623                }
624                (a, b) => assert_eq!(a, b),
625            }
626        }
627
628        Ok(())
629    }
630
631    #[test]
632    fn test_consteval_format() -> Result<(), Error> {
633        use crate::Evaluation;
634
635        let test_cases = &[
636            // Basic formatting
637            (
638                "format('Hello {0}', 'world')",
639                Evaluation::String("Hello world".to_string()),
640            ),
641            (
642                "format('{0} {1}', 'Hello', 'world')",
643                Evaluation::String("Hello world".to_string()),
644            ),
645            (
646                "format('Value: {0}', 42)",
647                Evaluation::String("Value: 42".to_string()),
648            ),
649            // Escaped braces
650            (
651                "format('{{0}}', 'test')",
652                Evaluation::String("{0}".to_string()),
653            ),
654            (
655                "format('{{Hello}} {0}', 'world')",
656                Evaluation::String("{Hello} world".to_string()),
657            ),
658            (
659                "format('{0} {{1}}', 'Hello')",
660                Evaluation::String("Hello {1}".to_string()),
661            ),
662            (
663                "format('}}{{', 'test')",
664                Evaluation::String("}{".to_string()),
665            ),
666            (
667                "format('{{{{}}}}', 'test')",
668                Evaluation::String("{{}}".to_string()),
669            ),
670            // Multiple arguments
671            (
672                "format('{0} {1} {2}', 'a', 'b', 'c')",
673                Evaluation::String("a b c".to_string()),
674            ),
675            (
676                "format('{2} {1} {0}', 'a', 'b', 'c')",
677                Evaluation::String("c b a".to_string()),
678            ),
679            // Repeated arguments
680            (
681                "format('{0} {0} {0}', 'test')",
682                Evaluation::String("test test test".to_string()),
683            ),
684            // No arguments to replace
685            (
686                "format('Hello world')",
687                Evaluation::String("Hello world".to_string()),
688            ),
689            // Trailing fragments
690            ("format('abc {{')", Evaluation::String("abc {".to_string())),
691            ("format('abc }}')", Evaluation::String("abc }".to_string())),
692            (
693                "format('abc {{}}')",
694                Evaluation::String("abc {}".to_string()),
695            ),
696        ];
697
698        for (expr_str, expected) in test_cases {
699            let expr = Expr::parse(expr_str)?;
700            let result = expr.consteval().unwrap();
701            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
702        }
703
704        Ok(())
705    }
706
707    #[test]
708    fn test_consteval_format_error_cases() -> Result<(), Error> {
709        let error_cases = &[
710            // Invalid format strings
711            "format('{0', 'test')",        // Missing closing brace
712            "format('0}', 'test')",        // Missing opening brace
713            "format('{a}', 'test')",       // Non-numeric placeholder
714            "format('{1}', 'test')",       // Argument index out of bounds
715            "format('{0} {2}', 'a', 'b')", // Argument index out of bounds
716            "format('{}', 'test')",        // Empty braces
717            "format('{-1}', 'test')",      // Negative index (invalid)
718        ];
719
720        for expr_str in error_cases {
721            let expr = Expr::parse(expr_str)?;
722            let result = expr.consteval();
723            assert!(
724                result.is_none(),
725                "Expected None for invalid format string: {}",
726                expr_str
727            );
728        }
729
730        Ok(())
731    }
732
733    #[test]
734    fn test_consteval_contains() -> Result<(), Error> {
735        use crate::Evaluation;
736
737        let test_cases = &[
738            // Basic string contains (case-insensitive)
739            (
740                "contains('hello world', 'world')",
741                Evaluation::Boolean(true),
742            ),
743            (
744                "contains('hello world', 'WORLD')",
745                Evaluation::Boolean(true),
746            ),
747            (
748                "contains('HELLO WORLD', 'world')",
749                Evaluation::Boolean(true),
750            ),
751            ("contains('hello world', 'foo')", Evaluation::Boolean(false)),
752            ("contains('test', '')", Evaluation::Boolean(true)),
753            // Number to string conversion
754            ("contains('123', '2')", Evaluation::Boolean(true)),
755            ("contains(123, '2')", Evaluation::Boolean(true)),
756            ("contains('hello123', 123)", Evaluation::Boolean(true)),
757            // Boolean to string conversion
758            ("contains('true', true)", Evaluation::Boolean(true)),
759            ("contains('false', false)", Evaluation::Boolean(true)),
760            // Null handling
761            ("contains('null', null)", Evaluation::Boolean(true)),
762            ("contains(null, '')", Evaluation::Boolean(true)),
763            // Array contains - exact matches
764            (
765                "contains(fromJSON('[1, 2, 3]'), 2)",
766                Evaluation::Boolean(true),
767            ),
768            (
769                "contains(fromJSON('[1, 2, 3]'), 4)",
770                Evaluation::Boolean(false),
771            ),
772            (
773                "contains(fromJSON('[\"a\", \"b\", \"c\"]'), 'b')",
774                Evaluation::Boolean(true),
775            ),
776            (
777                "contains(fromJSON('[\"a\", \"b\", \"c\"]'), 'B')",
778                Evaluation::Boolean(true), // Array search uses == semantics, which is case-insensitive for strings
779            ),
780            (
781                "contains(fromJSON('[true, false, null]'), true)",
782                Evaluation::Boolean(true),
783            ),
784            (
785                "contains(fromJSON('[true, false, null]'), null)",
786                Evaluation::Boolean(true),
787            ),
788            // Empty array
789            (
790                "contains(fromJSON('[]'), 'anything')",
791                Evaluation::Boolean(false),
792            ),
793            // Mixed type array
794            (
795                "contains(fromJSON('[1, \"hello\", true, null]'), 'hello')",
796                Evaluation::Boolean(true),
797            ),
798            (
799                "contains(fromJSON('[1, \"hello\", true, null]'), 1)",
800                Evaluation::Boolean(true),
801            ),
802        ];
803
804        for (expr_str, expected) in test_cases {
805            let expr = Expr::parse(expr_str)?;
806            let result = expr.consteval().unwrap();
807            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
808        }
809
810        Ok(())
811    }
812
813    #[test]
814    fn test_consteval_join() -> Result<(), Error> {
815        use crate::Evaluation;
816
817        let test_cases = &[
818            // Basic array joining with default separator
819            (
820                "join(fromJSON('[\"a\", \"b\", \"c\"]'))",
821                Evaluation::String("a,b,c".to_string()),
822            ),
823            (
824                "join(fromJSON('[1, 2, 3]'))",
825                Evaluation::String("1,2,3".to_string()),
826            ),
827            (
828                "join(fromJSON('[true, false, null]'))",
829                Evaluation::String("true,false,".to_string()),
830            ),
831            // Array joining with custom separator
832            (
833                "join(fromJSON('[\"a\", \"b\", \"c\"]'), ' ')",
834                Evaluation::String("a b c".to_string()),
835            ),
836            (
837                "join(fromJSON('[1, 2, 3]'), '-')",
838                Evaluation::String("1-2-3".to_string()),
839            ),
840            (
841                "join(fromJSON('[\"hello\", \"world\"]'), ' | ')",
842                Evaluation::String("hello | world".to_string()),
843            ),
844            (
845                "join(fromJSON('[\"a\", \"b\", \"c\"]'), '')",
846                Evaluation::String("abc".to_string()),
847            ),
848            // Empty array
849            ("join(fromJSON('[]'))", Evaluation::String("".to_string())),
850            (
851                "join(fromJSON('[]'), '-')",
852                Evaluation::String("".to_string()),
853            ),
854            // Single element array
855            (
856                "join(fromJSON('[\"single\"]'))",
857                Evaluation::String("single".to_string()),
858            ),
859            (
860                "join(fromJSON('[\"single\"]'), '-')",
861                Evaluation::String("single".to_string()),
862            ),
863            // Primitive values (should return the value as string)
864            ("join('hello')", Evaluation::String("hello".to_string())),
865            (
866                "join('hello', '-')",
867                Evaluation::String("hello".to_string()),
868            ),
869            ("join(123)", Evaluation::String("123".to_string())),
870            ("join(true)", Evaluation::String("true".to_string())),
871            ("join(null)", Evaluation::String("".to_string())),
872            // Mixed type array
873            (
874                "join(fromJSON('[1, \"hello\", true, null]'))",
875                Evaluation::String("1,hello,true,".to_string()),
876            ),
877            (
878                "join(fromJSON('[1, \"hello\", true, null]'), ' | ')",
879                Evaluation::String("1 | hello | true | ".to_string()),
880            ),
881            // Special separator values
882            (
883                "join(fromJSON('[\"a\", \"b\", \"c\"]'), 123)",
884                Evaluation::String("a123b123c".to_string()),
885            ),
886            (
887                "join(fromJSON('[\"a\", \"b\", \"c\"]'), true)",
888                Evaluation::String("atruebtruec".to_string()),
889            ),
890        ];
891
892        for (expr_str, expected) in test_cases {
893            let expr = Expr::parse(expr_str)?;
894            let result = expr.consteval().unwrap();
895            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
896        }
897
898        Ok(())
899    }
900
901    #[test]
902    fn test_consteval_endswith() -> Result<(), Error> {
903        use crate::Evaluation;
904
905        let test_cases = &[
906            // Basic case-insensitive string endsWith
907            (
908                "endsWith('hello world', 'world')",
909                Evaluation::Boolean(true),
910            ),
911            (
912                "endsWith('hello world', 'WORLD')",
913                Evaluation::Boolean(true),
914            ),
915            (
916                "endsWith('HELLO WORLD', 'world')",
917                Evaluation::Boolean(true),
918            ),
919            (
920                "endsWith('hello world', 'hello')",
921                Evaluation::Boolean(false),
922            ),
923            ("endsWith('hello world', 'foo')", Evaluation::Boolean(false)),
924            // Empty string cases
925            ("endsWith('test', '')", Evaluation::Boolean(true)),
926            ("endsWith('', '')", Evaluation::Boolean(true)),
927            ("endsWith('', 'test')", Evaluation::Boolean(false)),
928            // Number to string conversion
929            ("endsWith('123', '3')", Evaluation::Boolean(true)),
930            ("endsWith(123, '3')", Evaluation::Boolean(true)),
931            ("endsWith('hello123', 123)", Evaluation::Boolean(true)),
932            ("endsWith(12345, 345)", Evaluation::Boolean(true)),
933            // Boolean to string conversion
934            ("endsWith('test true', true)", Evaluation::Boolean(true)),
935            ("endsWith('test false', false)", Evaluation::Boolean(true)),
936            ("endsWith(true, 'ue')", Evaluation::Boolean(true)),
937            // Null handling
938            ("endsWith('test null', null)", Evaluation::Boolean(true)),
939            ("endsWith(null, '')", Evaluation::Boolean(true)),
940            ("endsWith('something', null)", Evaluation::Boolean(true)), // null converts to empty string
941            // Non-primitive types should return false
942            (
943                "endsWith(fromJSON('[1, 2, 3]'), '3')",
944                Evaluation::Boolean(false),
945            ),
946            (
947                "endsWith('test', fromJSON('[1, 2, 3]'))",
948                Evaluation::Boolean(false),
949            ),
950            (
951                "endsWith(fromJSON('{\"key\": \"value\"}'), 'value')",
952                Evaluation::Boolean(false),
953            ),
954            (
955                "endsWith('test', fromJSON('{\"key\": \"value\"}'))",
956                Evaluation::Boolean(false),
957            ),
958            // Mixed case scenarios
959            (
960                "endsWith('TestString', 'STRING')",
961                Evaluation::Boolean(true),
962            ),
963            ("endsWith('CamelCase', 'case')", Evaluation::Boolean(true)),
964            // Exact match
965            ("endsWith('exact', 'exact')", Evaluation::Boolean(true)),
966            // Longer suffix than string
967            (
968                "endsWith('short', 'very long suffix')",
969                Evaluation::Boolean(false),
970            ),
971        ];
972
973        for (expr_str, expected) in test_cases {
974            let expr = Expr::parse(expr_str)?;
975            let result = expr.consteval().unwrap();
976            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
977        }
978
979        Ok(())
980    }
981
982    #[test]
983    fn test_consteval_startswith() -> Result<(), Error> {
984        use crate::Evaluation;
985
986        let test_cases = &[
987            // Basic case-insensitive string startsWith
988            (
989                "startsWith('hello world', 'hello')",
990                Evaluation::Boolean(true),
991            ),
992            (
993                "startsWith('hello world', 'HELLO')",
994                Evaluation::Boolean(true),
995            ),
996            (
997                "startsWith('HELLO WORLD', 'hello')",
998                Evaluation::Boolean(true),
999            ),
1000            (
1001                "startsWith('hello world', 'world')",
1002                Evaluation::Boolean(false),
1003            ),
1004            (
1005                "startsWith('hello world', 'foo')",
1006                Evaluation::Boolean(false),
1007            ),
1008            // Empty string cases
1009            ("startsWith('test', '')", Evaluation::Boolean(true)),
1010            ("startsWith('', '')", Evaluation::Boolean(true)),
1011            ("startsWith('', 'test')", Evaluation::Boolean(false)),
1012            // Number to string conversion
1013            ("startsWith('123', '1')", Evaluation::Boolean(true)),
1014            ("startsWith(123, '1')", Evaluation::Boolean(true)),
1015            ("startsWith('123hello', 123)", Evaluation::Boolean(true)),
1016            ("startsWith(12345, 123)", Evaluation::Boolean(true)),
1017            // Boolean to string conversion
1018            ("startsWith('true test', true)", Evaluation::Boolean(true)),
1019            ("startsWith('false test', false)", Evaluation::Boolean(true)),
1020            ("startsWith(true, 'tr')", Evaluation::Boolean(true)),
1021            // Null handling
1022            ("startsWith('null test', null)", Evaluation::Boolean(true)),
1023            ("startsWith(null, '')", Evaluation::Boolean(true)),
1024            (
1025                "startsWith('something', null)",
1026                Evaluation::Boolean(true), // null converts to empty string
1027            ),
1028            // Non-primitive types should return false
1029            (
1030                "startsWith(fromJSON('[1, 2, 3]'), '1')",
1031                Evaluation::Boolean(false),
1032            ),
1033            (
1034                "startsWith('test', fromJSON('[1, 2, 3]'))",
1035                Evaluation::Boolean(false),
1036            ),
1037            (
1038                "startsWith(fromJSON('{\"key\": \"value\"}'), 'key')",
1039                Evaluation::Boolean(false),
1040            ),
1041            (
1042                "startsWith('test', fromJSON('{\"key\": \"value\"}'))",
1043                Evaluation::Boolean(false),
1044            ),
1045            // Mixed case scenarios
1046            (
1047                "startsWith('TestString', 'TEST')",
1048                Evaluation::Boolean(true),
1049            ),
1050            (
1051                "startsWith('CamelCase', 'camel')",
1052                Evaluation::Boolean(true),
1053            ),
1054            // Exact match
1055            ("startsWith('exact', 'exact')", Evaluation::Boolean(true)),
1056            // Longer prefix than string
1057            (
1058                "startsWith('short', 'very long prefix')",
1059                Evaluation::Boolean(false),
1060            ),
1061            // Partial matches
1062            (
1063                "startsWith('prefix_suffix', 'prefix')",
1064                Evaluation::Boolean(true),
1065            ),
1066            (
1067                "startsWith('prefix_suffix', 'suffix')",
1068                Evaluation::Boolean(false),
1069            ),
1070        ];
1071
1072        for (expr_str, expected) in test_cases {
1073            let expr = Expr::parse(expr_str)?;
1074            let result = expr.consteval().unwrap();
1075            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1076        }
1077
1078        Ok(())
1079    }
1080
1081    #[test]
1082    fn test_consteval_case() -> Result<(), Error> {
1083        use crate::Evaluation;
1084
1085        let test_cases = &[
1086            // Basic `case` with two predicates
1087            (
1088                "case(true, 'yes', false, 'no', 'maybe')",
1089                Some(Evaluation::String("yes".to_string())),
1090            ),
1091            (
1092                "case(false, 'yes', true, 'no', 'maybe')",
1093                Some(Evaluation::String("no".to_string())),
1094            ),
1095            // `case` with multiple predicates
1096            (
1097                "case(false, 'first', false, 'second', true, 'third', 'default')",
1098                Some(Evaluation::String("third".to_string())),
1099            ),
1100            // `case` with default value
1101            (
1102                "case(false, 'first', false, 'second', false, 'third', 'default')",
1103                Some(Evaluation::String("default".to_string())),
1104            ),
1105            // Invalid: `case` with non-boolean predicate
1106            ("case(1, 'yes', 0, 'no', 'maybe')", None),
1107        ];
1108
1109        for (expr_str, expected) in test_cases {
1110            let expr = Expr::parse(expr_str)?;
1111            let result = expr.consteval();
1112            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1113        }
1114
1115        Ok(())
1116    }
1117
1118    #[test]
1119    fn test_evaluate_constant_functions() -> Result<(), Error> {
1120        use crate::Evaluation;
1121
1122        let test_cases = &[
1123            // format function
1124            (
1125                "format('{0}', 'hello')",
1126                Evaluation::String("hello".to_string()),
1127            ),
1128            (
1129                "format('{0} {1}', 'hello', 'world')",
1130                Evaluation::String("hello world".to_string()),
1131            ),
1132            (
1133                "format('Value: {0}', 42)",
1134                Evaluation::String("Value: 42".to_string()),
1135            ),
1136            // contains function
1137            (
1138                "contains('hello world', 'world')",
1139                Evaluation::Boolean(true),
1140            ),
1141            ("contains('hello world', 'foo')", Evaluation::Boolean(false)),
1142            ("contains('test', '')", Evaluation::Boolean(true)),
1143            // startsWith function
1144            (
1145                "startsWith('hello world', 'hello')",
1146                Evaluation::Boolean(true),
1147            ),
1148            (
1149                "startsWith('hello world', 'world')",
1150                Evaluation::Boolean(false),
1151            ),
1152            ("startsWith('test', '')", Evaluation::Boolean(true)),
1153            // endsWith function
1154            (
1155                "endsWith('hello world', 'world')",
1156                Evaluation::Boolean(true),
1157            ),
1158            (
1159                "endsWith('hello world', 'hello')",
1160                Evaluation::Boolean(false),
1161            ),
1162            ("endsWith('test', '')", Evaluation::Boolean(true)),
1163            // toJSON function
1164            (
1165                "toJSON('hello')",
1166                Evaluation::String("\"hello\"".to_string()),
1167            ),
1168            ("toJSON(42)", Evaluation::String("42".to_string())),
1169            ("toJSON(true)", Evaluation::String("true".to_string())),
1170            ("toJSON(null)", Evaluation::String("null".to_string())),
1171            // fromJSON function - primitives
1172            (
1173                "fromJSON('\"hello\"')",
1174                Evaluation::String("hello".to_string()),
1175            ),
1176            ("fromJSON('42')", Evaluation::Number(42.0)),
1177            ("fromJSON('true')", Evaluation::Boolean(true)),
1178            ("fromJSON('null')", Evaluation::Null),
1179            // fromJSON function - arrays and objects
1180            (
1181                "fromJSON('[1, 2, 3]')",
1182                Evaluation::Array(vec![
1183                    Evaluation::Number(1.0),
1184                    Evaluation::Number(2.0),
1185                    Evaluation::Number(3.0),
1186                ]),
1187            ),
1188            (
1189                "fromJSON('{\"key\": \"value\"}')",
1190                Evaluation::Object({
1191                    let mut map = std::collections::HashMap::new();
1192                    map.insert("key".to_string(), Evaluation::String("value".to_string()));
1193                    map
1194                }),
1195            ),
1196        ];
1197
1198        for (expr_str, expected) in test_cases {
1199            let expr = Expr::parse(expr_str)?;
1200            let result = expr.consteval().unwrap();
1201            assert_eq!(
1202                result, *expected,
1203                "Failed for expression: {} {result:?}",
1204                expr_str
1205            );
1206        }
1207
1208        Ok(())
1209    }
1210}