Skip to main content

github_actions_expressions/
lib.rs

1//! GitHub Actions expression parsing and analysis.
2
3#![deny(missing_docs)]
4
5use std::ops::Deref;
6
7use crate::{
8    call::{Call, Function},
9    context::Context,
10    identifier::Identifier,
11    literal::Literal,
12    op::{BinExpr, BinOp, UnOp},
13};
14
15pub mod call;
16pub mod context;
17pub mod identifier;
18mod lexer;
19pub mod literal;
20pub mod op;
21mod parser;
22
23/// Errors that can occur during expression parsing.
24#[derive(Debug, thiserror::Error)]
25pub enum Error {
26    /// The expression failed to parse according to the grammar.
27    #[error(transparent)]
28    Syntax(#[from] SyntaxError),
29    /// The expression contains an invalid function call.
30    #[error("Invalid function call")]
31    Call(#[from] call::Error),
32}
33
34/// A syntax error encountered while parsing an expression.
35#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
36#[error("invalid expression syntax: {message} (at offset {offset})")]
37pub struct SyntaxError {
38    /// A human-readable description of the error.
39    pub message: &'static str,
40    /// The byte offset within the expression at which the error occurred.
41    pub offset: usize,
42}
43
44/// Represents the origin of an expression, including its source span
45/// and unparsed form.
46#[derive(Copy, Clone, Debug, PartialEq)]
47pub struct Origin<'src> {
48    /// The expression's source span.
49    pub span: subfeature::Span,
50    /// The expression's unparsed form, as it appears in the source.
51    ///
52    /// This is recorded exactly as it appears in the source, *except*
53    /// that leading and trailing whitespace is stripped. This is stripped
54    /// because it's (1) non-semantic, and (2) can cause all kinds of issues
55    /// when attempting to map expressions back to YAML source features.
56    pub raw: &'src str,
57}
58
59impl<'a> Origin<'a> {
60    /// Create a new origin from the given span and raw form.
61    pub fn new(span: impl Into<subfeature::Span>, raw: &'a str) -> Self {
62        Self {
63            span: span.into(),
64            raw: raw.trim(),
65        }
66    }
67}
68
69/// An expression along with its source origin (span and unparsed form).
70///
71/// An expression's span covers exactly the bytes the expression
72/// was parsed from, with no surrounding whitespace. A parenthesized expression
73/// includes its parentheses, so every span is a balanced, self-contained slice
74/// of the source.
75///
76/// NOTE: `SpannedExpr` has a manual [`PartialEq`] implementation that only considers
77/// the underlying expression, not the span. In opther words, two `SpannedExpr` instances
78/// are equal if their ASTs are equal, even if their source spans are not.
79#[derive(Debug)]
80pub struct SpannedExpr<'src> {
81    /// The expression's source origin.
82    pub origin: Origin<'src>,
83    /// The expression itself.
84    pub inner: Expr<'src>,
85}
86
87impl<'a> SpannedExpr<'a> {
88    /// Creates a new `SpannedExpr` from an expression and its span.
89    pub(crate) fn new(origin: Origin<'a>, inner: Expr<'a>) -> Self {
90        Self { origin, inner }
91    }
92
93    /// Returns the contexts in this expression, along with their origins.
94    ///
95    /// This includes all contexts in the expression, even those that don't directly flow into
96    /// the evaluation. For example, `${{ foo.bar == 'abc' }}` returns `foo.bar` since it's a
97    /// context in the expression, even though it flows into a boolean evaluation rather than
98    /// directly into the output.
99    ///
100    /// For dataflow contexts, see [`SpannedExpr::dataflow_contexts`].
101    pub fn contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> {
102        let mut contexts = vec![];
103
104        match self.deref() {
105            Expr::Index(expr) => contexts.extend(expr.contexts()),
106            Expr::Call(Call { func: _, args }) => {
107                for arg in args {
108                    contexts.extend(arg.contexts());
109                }
110            }
111            Expr::Context(ctx) => {
112                // Record the context itself.
113                contexts.push((ctx, &self.origin));
114
115                // The context's parts can also contain independent contexts,
116                // e.g. computed indices like `bar.baz` in `foo[bar.baz]`.
117                ctx.parts
118                    .iter()
119                    .for_each(|part| contexts.extend(part.contexts()));
120            }
121            Expr::BinExpr(BinExpr { lhs, op: _, rhs }) => {
122                contexts.extend(lhs.contexts());
123                contexts.extend(rhs.contexts());
124            }
125            Expr::UnExpr { op: _, expr } => contexts.extend(expr.contexts()),
126            _ => (),
127        }
128
129        contexts
130    }
131
132    /// Returns the contexts in this expression that directly flow into the
133    /// expression's evaluation.
134    ///
135    /// For example `${{ foo.bar }}` returns `foo.bar` since the value
136    /// of `foo.bar` flows into the evaluation. On the other hand,
137    /// `${{ foo.bar == 'abc' }}` returns no expanded contexts,
138    /// since the value of `foo.bar` flows into a boolean evaluation
139    /// that gets expanded.
140    pub fn dataflow_contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> {
141        let mut contexts = vec![];
142
143        match self.deref() {
144            Expr::Call(Call { func, args }) => {
145                // These functions, when evaluated, produce an evaluation
146                // that includes some or all of the contexts listed in
147                // their arguments.
148                if matches!(func, Function::ToJSON | Function::Format | Function::Join) {
149                    for arg in args {
150                        contexts.extend(arg.dataflow_contexts());
151                    }
152                }
153            }
154            // NOTE: We intentionally don't handle the `func(...).foo.bar`
155            // case differently here, since a call followed by a
156            // context access *can* flow into the evaluation.
157            // For example, `${{ fromJSON(something) }}` evaluates to
158            // `Object` but `${{ fromJSON(something).foo }}` evaluates
159            // to the contents of `something.foo`.
160            Expr::Context(ctx) => contexts.push((ctx, &self.origin)),
161            Expr::BinExpr(BinExpr { lhs, op, rhs }) => match op {
162                // With && only the RHS can flow into the evaluation as a context
163                // (rather than a boolean).
164                BinOp::And => {
165                    contexts.extend(rhs.dataflow_contexts());
166                }
167                // With || either the LHS or RHS can flow into the evaluation as a context.
168                BinOp::Or => {
169                    contexts.extend(lhs.dataflow_contexts());
170                    contexts.extend(rhs.dataflow_contexts());
171                }
172                _ => (),
173            },
174            _ => (),
175        }
176
177        contexts
178    }
179
180    /// Returns all possible leaf expressions that could be the result
181    /// of evaluating this expression.
182    ///
183    /// Uses GitHub Actions' short-circuit semantics:
184    /// - `A && B`: only B can flow into the result (A is a condition)
185    /// - `A || B`: either A or B can be the result
186    ///
187    /// Leaf expressions are any non-`BinOp` expressions: literals, contexts,
188    /// function calls, etc.
189    ///
190    /// For example, `${{ foo.bar == 'true' && 'hello' || '' }}` returns
191    /// `['hello', '']` since those are the two possible evaluated values.
192    /// `${{ foo.abc || foo.def }}` returns `[foo.abc, foo.def]`.
193    pub fn leaf_expressions(&self) -> Vec<&SpannedExpr<'a>> {
194        let mut leaves = vec![];
195
196        match self.deref() {
197            Expr::BinExpr(BinExpr { lhs, op, rhs }) => match op {
198                BinOp::And => {
199                    leaves.extend(rhs.leaf_expressions());
200                }
201                BinOp::Or => {
202                    leaves.extend(lhs.leaf_expressions());
203                    leaves.extend(rhs.leaf_expressions());
204                }
205                // Comparison operators produce booleans, not their operands.
206                _ => leaves.push(self),
207            },
208            _ => leaves.push(self),
209        }
210
211        leaves
212    }
213
214    /// Returns any computed indices in this expression.
215    ///
216    /// A computed index is any index operation with a non-literal
217    /// evaluation, e.g. `foo[a.b.c]`.
218    pub fn computed_indices(&self) -> Vec<&SpannedExpr<'a>> {
219        let mut index_exprs = vec![];
220
221        match self.deref() {
222            Expr::Call(Call { func: _, args }) => {
223                for arg in args {
224                    index_exprs.extend(arg.computed_indices());
225                }
226            }
227            Expr::Index(spanned_expr)
228                // NOTE: We consider any non-literal, non-star index computed.
229                if !spanned_expr.is_literal() && !matches!(spanned_expr.inner, Expr::Star) => {
230                    index_exprs.push(self);
231                }
232            Expr::Context(context) => {
233                for part in &context.parts {
234                    index_exprs.extend(part.computed_indices());
235                }
236            }
237            Expr::BinExpr(BinExpr { lhs, op: _, rhs }) => {
238                index_exprs.extend(lhs.computed_indices());
239                index_exprs.extend(rhs.computed_indices());
240            }
241            Expr::UnExpr { op: _, expr } => {
242                index_exprs.extend(expr.computed_indices());
243            }
244            _ => {}
245        }
246
247        index_exprs
248    }
249
250    /// Like [`Expr::constant_reducible`], but for all subexpressions
251    /// rather than the top-level expression.
252    ///
253    /// This has slightly different semantics than `constant_reducible`:
254    /// it doesn't include "trivially" reducible expressions like literals,
255    /// since flagging these as reducible within a larger expression
256    /// would be misleading.
257    pub fn constant_reducible_subexprs(&self) -> Vec<&SpannedExpr<'a>> {
258        if !self.is_literal() && self.constant_reducible() {
259            return vec![self];
260        }
261
262        let mut subexprs = vec![];
263
264        match self.deref() {
265            Expr::Call(Call { func: _, args }) => {
266                for arg in args {
267                    subexprs.extend(arg.constant_reducible_subexprs());
268                }
269            }
270            Expr::Context(ctx) => {
271                // contexts themselves are never reducible, but they might
272                // contains reducible index subexpressions.
273                for part in &ctx.parts {
274                    subexprs.extend(part.constant_reducible_subexprs());
275                }
276            }
277            Expr::BinExpr(BinExpr { lhs, op: _, rhs }) => {
278                subexprs.extend(lhs.constant_reducible_subexprs());
279                subexprs.extend(rhs.constant_reducible_subexprs());
280            }
281            Expr::UnExpr { op: _, expr } => subexprs.extend(expr.constant_reducible_subexprs()),
282
283            Expr::Index(expr) => subexprs.extend(expr.constant_reducible_subexprs()),
284            _ => {}
285        }
286
287        subexprs
288    }
289}
290
291impl<'a> Deref for SpannedExpr<'a> {
292    type Target = Expr<'a>;
293
294    fn deref(&self) -> &Self::Target {
295        &self.inner
296    }
297}
298
299impl<'doc> From<&SpannedExpr<'doc>> for subfeature::Fragment<'doc> {
300    fn from(expr: &SpannedExpr<'doc>) -> Self {
301        Self::new(expr.origin.raw)
302    }
303}
304
305impl PartialEq for SpannedExpr<'_> {
306    fn eq(&self, other: &Self) -> bool {
307        self.inner == other.inner
308    }
309}
310
311/// Represents a GitHub Actions expression.
312#[derive(Debug, PartialEq)]
313pub enum Expr<'src> {
314    /// A literal value.
315    Literal(Literal<'src>),
316    /// The `*` literal within an index or context.
317    Star,
318    /// A function call.
319    Call(Call<'src>),
320    /// A context identifier component, e.g. `github` in `github.actor`.
321    Identifier(Identifier<'src>),
322    /// A context index component, e.g. `[0]` in `foo[0]`.
323    Index(Box<SpannedExpr<'src>>),
324    /// A full context reference.
325    Context(Context<'src>),
326    /// A binary expression, either logical or arithmetic.
327    BinExpr(BinExpr<'src>),
328    /// A unary expression. Negation (`!`) is currently the only `UnOp`.
329    UnExpr {
330        /// The unary operator.
331        op: UnOp,
332        /// The expression to apply the operator to.
333        expr: Box<SpannedExpr<'src>>,
334    },
335}
336
337impl<'src> Expr<'src> {
338    /// Convenience API for making an [`Expr::Identifier`].
339    fn ident(i: &'src str) -> Self {
340        Self::Identifier(Identifier(i))
341    }
342
343    /// Convenience API for making an [`Expr::Context`].
344    fn context(components: impl Into<Vec<SpannedExpr<'src>>>) -> Self {
345        Self::Context(Context::new(components))
346    }
347
348    /// Returns whether the expression is a literal.
349    pub fn is_literal(&self) -> bool {
350        matches!(self, Expr::Literal(_))
351    }
352
353    /// Returns whether the expression is constant reducible.
354    ///
355    /// "Constant reducible" is similar to "constant foldable" but with
356    /// meta-evaluation semantics: the expression `5` would not be
357    /// constant foldable in a normal program (because it's already
358    /// an atom), but is "constant reducible" in a GitHub Actions expression
359    /// because an expression containing it (e.g. `${{ 5 }}`) can be elided
360    /// entirely and replaced with `5`.
361    ///
362    /// There are three kinds of reducible expressions:
363    ///
364    /// 1. Literals, which reduce to their literal value;
365    /// 2. Binops/unops with reducible subexpressions, which reduce
366    ///    to their evaluation;
367    /// 3. Select function calls where the semantics of the function
368    ///    mean that reducible arguments make the call itself reducible.
369    ///
370    /// NOTE: This implementation is sound but not complete.
371    pub fn constant_reducible(&self) -> bool {
372        match self {
373            // Literals are always reducible.
374            Expr::Literal(_) => true,
375            // Binops are reducible if their LHS and RHS are reducible.
376            Expr::BinExpr(BinExpr { lhs, op: _, rhs }) => {
377                lhs.constant_reducible() && rhs.constant_reducible()
378            }
379            // Unops are reducible if their interior expression is reducible.
380            Expr::UnExpr { op: _, expr } => expr.constant_reducible(),
381            Expr::Call(Call { func, args }) => {
382                // These functions are reducible if their arguments are reducible.
383                // TODO(ww): `fromJSON` *is* frequently reducible, but
384                // doing so soundly with subexpressions is annoying.
385                // We overapproximate for now and consider it non-reducible.
386                if matches!(
387                    func,
388                    Function::Contains
389                        | Function::StartsWith
390                        | Function::EndsWith
391                        | Function::Format
392                        | Function::ToJSON
393                        | Function::Join // | Function::FromJSON
394                ) {
395                    args.iter().all(|e| e.constant_reducible())
396                } else {
397                    false
398                }
399            }
400            // Everything else is presumed non-reducible.
401            _ => false,
402        }
403    }
404
405    /// Parses the given string into an expression.
406    pub fn parse(expr: &'src str) -> Result<SpannedExpr<'src>, Error> {
407        parser::parse(expr)
408    }
409
410    /// Returns whether this expression 'commutatively matches' the given expression.
411    ///
412    /// For most expressions, this is the same as equivalence (i.e. `==`).
413    /// For binary expressions that are also commutative, this takes commutivity into account.
414    /// For example, `a == b` is considered to match `b == a`, but `a > b` is not
415    /// considered to match `b > a`. This check is recusive, i.e. two nested binary expressions
416    /// will be fully checked for commutative equivalence.
417    pub fn commutative_matches(&self, other: &Expr<'src>) -> bool {
418        match (self, other) {
419            (Self::BinExpr(sb), Self::BinExpr(ob)) => {
420                if sb.op != ob.op {
421                    return false;
422                }
423
424                // Same-position match (lhs/lhs, rhs/rhs); commutative ops
425                // additionally accept the swapped pairing below.
426                let positional = sb.lhs.inner.commutative_matches(&ob.lhs.inner)
427                    && sb.rhs.inner.commutative_matches(&ob.rhs.inner);
428
429                match sb.op {
430                    BinOp::And | BinOp::Or | BinOp::Eq | BinOp::Neq => {
431                        positional
432                            || (sb.lhs.inner.commutative_matches(&ob.rhs.inner)
433                                && sb.rhs.inner.commutative_matches(&ob.lhs.inner))
434                    }
435                    _ => positional,
436                }
437            }
438            _ => self == other,
439        }
440    }
441}
442
443impl<'src> From<&'src str> for Expr<'src> {
444    fn from(s: &'src str) -> Self {
445        Expr::Literal(Literal::String(s.into()))
446    }
447}
448
449impl From<String> for Expr<'_> {
450    fn from(s: String) -> Self {
451        Expr::Literal(Literal::String(s.into()))
452    }
453}
454
455impl From<f64> for Expr<'_> {
456    fn from(n: f64) -> Self {
457        Expr::Literal(Literal::Number(n))
458    }
459}
460
461impl From<bool> for Expr<'_> {
462    fn from(b: bool) -> Self {
463        Expr::Literal(Literal::Boolean(b))
464    }
465}
466
467/// The result of evaluating a GitHub Actions expression.
468///
469/// This type represents the possible values that can result from evaluating
470/// GitHub Actions expressions.
471#[derive(Debug, Clone, PartialEq)]
472pub enum Evaluation {
473    /// A string value (includes both string literals and stringified other types).
474    String(String),
475    /// A numeric value.
476    Number(f64),
477    /// A boolean value.
478    Boolean(bool),
479    /// The null value.
480    Null,
481    /// An array value. Array evaluations can only be realized through `fromJSON`.
482    Array(Vec<Evaluation>),
483    /// An object value. Object evaluations can only be realized through `fromJSON`.
484    Object(std::collections::HashMap<String, Evaluation>),
485}
486
487impl TryFrom<serde_json::Value> for Evaluation {
488    type Error = ();
489
490    fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
491        match value {
492            serde_json::Value::Null => Ok(Evaluation::Null),
493            serde_json::Value::Bool(b) => Ok(Evaluation::Boolean(b)),
494            serde_json::Value::Number(n) => {
495                if let Some(f) = n.as_f64() {
496                    Ok(Evaluation::Number(f))
497                } else {
498                    Err(())
499                }
500            }
501            serde_json::Value::String(s) => Ok(Evaluation::String(s)),
502            serde_json::Value::Array(arr) => {
503                let elements = arr
504                    .into_iter()
505                    .map(|elem| elem.try_into())
506                    .collect::<Result<_, _>>()?;
507                Ok(Evaluation::Array(elements))
508            }
509            serde_json::Value::Object(obj) => {
510                let mut map = std::collections::HashMap::new();
511                for (key, value) in obj {
512                    map.insert(key, value.try_into()?);
513                }
514                Ok(Evaluation::Object(map))
515            }
516        }
517    }
518}
519
520impl TryInto<serde_json::Value> for Evaluation {
521    type Error = ();
522
523    fn try_into(self) -> Result<serde_json::Value, Self::Error> {
524        match self {
525            Evaluation::Null => Ok(serde_json::Value::Null),
526            Evaluation::Boolean(b) => Ok(serde_json::Value::Bool(b)),
527            Evaluation::Number(n) => {
528                // NOTE: serde_json has different internal representations
529                // for integers and floats, so we need to handle both cases
530                // to ensure we serialize integers without a decimal point.
531                if n.fract() == 0.0 {
532                    Ok(serde_json::Value::Number(serde_json::Number::from(
533                        n as i64,
534                    )))
535                } else if let Some(num) = serde_json::Number::from_f64(n) {
536                    Ok(serde_json::Value::Number(num))
537                } else {
538                    Err(())
539                }
540            }
541            Evaluation::String(s) => Ok(serde_json::Value::String(s)),
542            Evaluation::Array(arr) => {
543                let elements = arr
544                    .into_iter()
545                    .map(|elem| elem.try_into())
546                    .collect::<Result<_, _>>()?;
547                Ok(serde_json::Value::Array(elements))
548            }
549            Evaluation::Object(obj) => {
550                let mut map = serde_json::Map::new();
551                for (key, value) in obj {
552                    map.insert(key, value.try_into()?);
553                }
554                Ok(serde_json::Value::Object(map))
555            }
556        }
557    }
558}
559
560impl Evaluation {
561    /// Convert to a boolean following GitHub Actions truthiness rules.
562    ///
563    /// GitHub Actions truthiness:
564    /// - false and null are falsy
565    /// - Numbers: 0 and NaN are falsy, everything else is truthy
566    /// - Strings: empty string is falsy, everything else is truthy
567    /// - Arrays and dictionaries are always truthy (non-empty objects)
568    pub fn as_boolean(&self) -> bool {
569        match self {
570            Evaluation::Boolean(b) => *b,
571            Evaluation::Null => false,
572            Evaluation::Number(n) => *n != 0.0 && !n.is_nan(),
573            Evaluation::String(s) => !s.is_empty(),
574            // Arrays and objects are always truthy, even if empty.
575            Evaluation::Array(_) | Evaluation::Object(_) => true,
576        }
577    }
578
579    /// Convert to a number following GitHub Actions conversion rules.
580    ///
581    /// See: <https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators>
582    pub fn as_number(&self) -> f64 {
583        match self {
584            Evaluation::String(s) => parse_number(s),
585            Evaluation::Number(n) => *n,
586            Evaluation::Boolean(b) => {
587                if *b {
588                    1.0
589                } else {
590                    0.0
591                }
592            }
593            Evaluation::Null => 0.0,
594            Evaluation::Array(_) | Evaluation::Object(_) => f64::NAN,
595        }
596    }
597
598    /// Returns a wrapper around this evaluation that implements
599    /// GitHub Actions evaluation semantics.
600    pub fn sema(&self) -> EvaluationSema<'_> {
601        EvaluationSema(self)
602    }
603}
604
605/// Parse a string into a number following GitHub Actions coercion rules.
606///
607/// The string is trimmed and then parsed following the rules from the
608/// GitHub Action Runner:
609/// https://github.com/actions/runner/blob/9426c35fdaf2b2e00c3ef751a15c04fa8e2a9582/src/Sdk/Expressions/Sdk/ExpressionUtility.cs#L223
610fn parse_number(s: &str) -> f64 {
611    let trimmed = s.trim();
612    if trimmed.is_empty() {
613        return 0.0;
614    }
615
616    // Decimal / scientific notation first
617    // Only accept finite results; infinity/NaN literals fall through.
618    if let Ok(value) = trimmed.parse::<f64>()
619        && value.is_finite()
620    {
621        return value;
622    }
623
624    // Hex: signed 32-bit.
625    // Values 0x80000000–0xFFFFFFFF wrap negative via two's complement.
626    if let Some(hex_digits) = trimmed.strip_prefix("0x") {
627        return u32::from_str_radix(hex_digits, 16)
628            .map(|n| (n as i32) as f64)
629            .unwrap_or(f64::NAN);
630    }
631
632    // Octal: signed 32-bit.
633    if let Some(oct_digits) = trimmed.strip_prefix("0o") {
634        return u32::from_str_radix(oct_digits, 8)
635            .map(|n| (n as i32) as f64)
636            .unwrap_or(f64::NAN);
637    }
638
639    // Explicit Infinity check — GH runner accepts full "infinity"
640    // (case-insensitive) but NOT the "inf" abbreviation.
641    let after_sign = trimmed
642        .strip_prefix(['+', '-'].as_slice())
643        .unwrap_or(trimmed);
644    if after_sign.eq_ignore_ascii_case("infinity") {
645        return if trimmed.starts_with('-') {
646            f64::NEG_INFINITY
647        } else {
648            f64::INFINITY
649        };
650    }
651
652    f64::NAN
653}
654
655/// A wrapper around `Evaluation` that implements GitHub Actions
656/// various evaluation semantics (comparison, stringification, etc.).
657pub struct EvaluationSema<'a>(&'a Evaluation);
658
659impl EvaluationSema<'_> {
660    /// Converts a string to its uppercase form using GitHub Actions'
661    /// special rules.
662    /// See `toUpperSpecial`:
663    /// <https://github.com/actions/languageservices/blob/cc316ab/expressions/src/result.ts#L209>
664    fn upper_special(value: &str) -> String {
665        // Uppercase everything except the small dotless-ı (U+0131),
666        // which GitHub Actions preserves as-is.
667        let mut result = String::with_capacity(value.len());
668        let mut parts = value.split('ı');
669        if let Some(first) = parts.next() {
670            result.extend(first.chars().flat_map(char::to_uppercase));
671        }
672        for part in parts {
673            result.push('ı');
674            result.extend(part.chars().flat_map(char::to_uppercase));
675        }
676        result
677    }
678}
679
680impl PartialEq for EvaluationSema<'_> {
681    fn eq(&self, other: &Self) -> bool {
682        match (self.0, other.0) {
683            (Evaluation::Null, Evaluation::Null) => true,
684            (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a == b,
685            (Evaluation::Number(a), Evaluation::Number(b)) => a == b,
686            // GitHub Actions string comparisons are case-insensitive.
687            (Evaluation::String(a), Evaluation::String(b)) => {
688                Self::upper_special(a) == Self::upper_special(b)
689            }
690            // Coercion rules: all others convert to number and compare.
691            (a, b) => a.as_number() == b.as_number(),
692        }
693    }
694}
695
696impl PartialOrd for EvaluationSema<'_> {
697    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
698        match (self.0, other.0) {
699            (Evaluation::Null, Evaluation::Null) => Some(std::cmp::Ordering::Equal),
700            (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a.partial_cmp(b),
701            (Evaluation::Number(a), Evaluation::Number(b)) => a.partial_cmp(b),
702            (Evaluation::String(a), Evaluation::String(b)) => {
703                // GitHub Actions string comparisons are case-insensitive.
704                Self::upper_special(a).partial_cmp(&Self::upper_special(b))
705            }
706            // Coercion rules: all others convert to number and compare.
707            (a, b) => a.as_number().partial_cmp(&b.as_number()),
708        }
709    }
710}
711
712impl std::fmt::Display for EvaluationSema<'_> {
713    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
714        match self.0 {
715            Evaluation::String(s) => write!(f, "{}", s),
716            Evaluation::Number(n) => {
717                // Format numbers like GitHub Actions does
718                if n == &f64::INFINITY {
719                    write!(f, "Infinity")
720                } else if n == &f64::NEG_INFINITY {
721                    write!(f, "-Infinity")
722                } else {
723                    // Format with 15 decimal places, parse back to f64 to
724                    // clean up trailing noise, then format normally.
725                    // See: https://github.com/actions/languageservices/blob/cc316ab/expressions/src/data/number.ts#L10
726                    let rounded: f64 = format!("{:.15}", n)
727                        .parse()
728                        .expect("impossible f64 round-trip error");
729                    if rounded.fract() == 0.0 {
730                        write!(f, "{}", rounded as i64)
731                    } else {
732                        write!(f, "{}", rounded)
733                    }
734                }
735            }
736            Evaluation::Boolean(b) => write!(f, "{}", b),
737            Evaluation::Null => write!(f, ""),
738            Evaluation::Array(_) => write!(f, "Array"),
739            Evaluation::Object(_) => write!(f, "Object"),
740        }
741    }
742}
743
744impl<'src> Expr<'src> {
745    /// Evaluates a constant-reducible expression to its literal value.
746    ///
747    /// Returns `Some(Evaluation)` if the expression can be constant-evaluated,
748    /// or `None` if the expression contains non-constant elements (like contexts or
749    /// non-reducible function calls).
750    ///
751    /// This implementation follows GitHub Actions' evaluation semantics as documented at:
752    /// https://docs.github.com/en/actions/reference/workflows-and-actions/expressions
753    ///
754    /// # Examples
755    ///
756    /// ```
757    /// use github_actions_expressions::{Expr, Evaluation};
758    ///
759    /// let expr = Expr::parse("'hello'").unwrap();
760    /// let result = expr.consteval().unwrap();
761    /// assert_eq!(result.sema().to_string(), "hello");
762    ///
763    /// let expr = Expr::parse("true && false").unwrap();
764    /// let result = expr.consteval().unwrap();
765    /// assert_eq!(result, Evaluation::Boolean(false));
766    /// ```
767    pub fn consteval(&self) -> Option<Evaluation> {
768        match self {
769            Expr::Literal(literal) => Some(literal.consteval()),
770
771            Expr::BinExpr(BinExpr { lhs, op, rhs }) => {
772                let lhs_val = lhs.consteval()?;
773                let rhs_val = rhs.consteval()?;
774
775                match op {
776                    BinOp::And => {
777                        // GitHub Actions && semantics: if LHS is falsy, return LHS, else return RHS
778                        if lhs_val.as_boolean() {
779                            Some(rhs_val)
780                        } else {
781                            Some(lhs_val)
782                        }
783                    }
784                    BinOp::Or => {
785                        // GitHub Actions || semantics: if LHS is truthy, return LHS, else return RHS
786                        if lhs_val.as_boolean() {
787                            Some(lhs_val)
788                        } else {
789                            Some(rhs_val)
790                        }
791                    }
792                    BinOp::Eq => Some(Evaluation::Boolean(lhs_val.sema() == rhs_val.sema())),
793                    BinOp::Neq => Some(Evaluation::Boolean(lhs_val.sema() != rhs_val.sema())),
794                    BinOp::Lt => Some(Evaluation::Boolean(lhs_val.sema() < rhs_val.sema())),
795                    BinOp::Le => Some(Evaluation::Boolean(lhs_val.sema() <= rhs_val.sema())),
796                    BinOp::Gt => Some(Evaluation::Boolean(lhs_val.sema() > rhs_val.sema())),
797                    BinOp::Ge => Some(Evaluation::Boolean(lhs_val.sema() >= rhs_val.sema())),
798                }
799            }
800
801            Expr::UnExpr { op, expr } => {
802                let val = expr.consteval()?;
803                match op {
804                    UnOp::Not => Some(Evaluation::Boolean(!val.as_boolean())),
805                }
806            }
807
808            Expr::Call(call) => call.consteval(),
809
810            // Non-constant expressions
811            _ => None,
812        }
813    }
814}
815
816#[cfg(test)]
817mod tests {
818    use std::borrow::Cow;
819
820    use crate::{Error, Literal, context::Context};
821
822    use super::Expr;
823
824    #[test]
825    fn test_literal_string_borrows() {
826        let cases = &[
827            ("'foo'", true),
828            ("'foo bar'", true),
829            ("'foo '' bar'", false),
830            ("'foo''bar'", false),
831            ("'foo''''bar'", false),
832        ];
833
834        for (expr, borrows) in cases {
835            let Expr::Literal(Literal::String(s)) = &*Expr::parse(expr).unwrap() else {
836                panic!("expected a literal string expression for {expr}");
837            };
838
839            assert!(matches!(
840                (s, borrows),
841                (Cow::Borrowed(_), true) | (Cow::Owned(_), false)
842            ));
843        }
844    }
845
846    #[test]
847    fn test_literal_as_str() {
848        let cases = &[
849            ("'foo'", "foo"),
850            ("'foo '' bar'", "foo ' bar"),
851            ("123", "123"),
852            ("123.000", "123"),
853            ("0.0", "0"),
854            ("0.1", "0.1"),
855            ("0.12345", "0.12345"),
856            ("true", "true"),
857            ("false", "false"),
858            ("null", "null"),
859        ];
860
861        for (expr, expected) in cases {
862            let Expr::Literal(expr) = &*Expr::parse(expr).unwrap() else {
863                panic!("expected a literal expression for {expr}");
864            };
865
866            assert_eq!(expr.as_str(), *expected);
867        }
868    }
869
870    #[test]
871    fn test_parse_string_rule() {
872        // Each case maps a source literal to its unescaped value.
873        let cases = &[
874            ("''", ""),
875            ("' '", " "),
876            ("''''", "'"),
877            ("'test'", "test"),
878            ("'spaces are ok'", "spaces are ok"),
879            ("'escaping '' works'", "escaping ' works"),
880        ];
881
882        for (case, expected) in cases {
883            let Expr::Literal(Literal::String(s)) = &*Expr::parse(case).unwrap() else {
884                panic!("expected a literal string expression for {case}");
885            };
886
887            assert_eq!(s, expected);
888        }
889    }
890
891    #[test]
892    fn test_parse_context_rule() {
893        let cases = &[
894            "foo.bar",
895            "github.action_path",
896            "inputs.foo-bar",
897            "inputs.also--valid",
898            "inputs.this__too",
899            "inputs.this__too",
900            "secrets.GH_TOKEN",
901            "foo.*.bar",
902            "github.event.issue.labels.*.name",
903        ];
904
905        for case in cases {
906            assert!(
907                Context::parse(case).is_some(),
908                "{case:?} should parse as a context"
909            );
910        }
911    }
912
913    #[test]
914    fn test_parse_call_rule() {
915        // Function call syntax, exercised with real (known) functions so
916        // that `Expr::parse`'s arity/name validation is satisfied.
917        let cases = &[
918            "success()",
919            "fromJSON(bar)",
920            "toJSON(fromJSON(bar))",
921            "fromJSON(1.23)",
922            "contains(1,2)",
923            "contains(1, 2)",
924            "format('{0} {1}', 1, secret.GH_TOKEN)",
925            "success(   )",
926            "fromJSON(inputs.free-threading)",
927        ];
928
929        for case in cases {
930            assert!(Expr::parse(case).is_ok(), "{case:?} should parse");
931        }
932    }
933
934    #[test]
935    fn test_parse_expr_rule() -> Result<(), Error> {
936        // Ensures that we parse multi-line expressions correctly.
937        let multiline = "github.repository_owner == 'Homebrew' &&
938        ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
939        (github.event_name == 'pull_request_target' &&
940        (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
941
942        let multiline2 = "foo.bar.baz[
943        0
944        ]";
945
946        let cases = &[
947            "true",
948            "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
949            "foo || bar || baz",
950            "foo || bar && baz || foo && 1 && 2 && 3 || 4",
951            "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
952            "(true || false) == true",
953            "!(!true || false)",
954            "!(!true || false) == true",
955            "(true == false) == true",
956            "(true == (false || true && (true || false))) == true",
957            "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
958            "fromJSON(bar)[0]",
959            "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
960            multiline,
961            "'a' == 'b' && 'c' || 'd'",
962            "github.event['a']",
963            "github.event['a' == 'b']",
964            "github.event['a' == 'b' && 'c' || 'd']",
965            "github['event']['inputs']['dry-run']",
966            "github[format('{0}', 'event')]",
967            "github['event']['inputs'][github.event.inputs.magic]",
968            "github['event']['inputs'].*",
969            "1 == 1",
970            "1 > 1",
971            "1 >= 1",
972            "matrix.node_version >= 20",
973            "true||false",
974            // Hex literals
975            "0xFF",
976            "0xff",
977            "0x0",
978            "0xFF == 255",
979            // Octal literals
980            "0o10",
981            "0o77",
982            "0o0",
983            // Scientific notation
984            "1e2",
985            "1.5E-3",
986            "1.2e+2",
987            "5e0",
988            // NaN and Infinity literals
989            "NaN",
990            "Infinity",
991            "+Infinity",
992            "-Infinity",
993            "NaN == NaN",
994            "Infinity == Infinity",
995            // Parenthesized compound expressions
996            "2 <= (3 == true)",
997            "0 > (0 < 1)",
998            "(foo || bar) == baz",
999            // Signed numbers
1000            "+42",
1001            "-42",
1002            // Leading/trailing dot
1003            ".5",
1004            "123.",
1005            // Whitespace handling
1006            multiline2,
1007            "fromJSON( github.event.inputs.hmm ) [ 0 ]",
1008            // Parens around a call
1009            "(fromJson('{\"one\": \"one val\"}')).one",
1010            "(fromJson('[\"one\", \"two\"]'))[1]",
1011        ];
1012
1013        for case in cases {
1014            Expr::parse(case).unwrap_or_else(|e| panic!("{case:?} should parse, but failed: {e}"));
1015        }
1016
1017        Ok(())
1018    }
1019
1020    #[test]
1021    fn test_parse_expr_rule_rejects() {
1022        let cases = &[
1023            // "Inf" is not a valid number form; only "Infinity" is accepted.
1024            "-Inf", "+Inf",
1025        ];
1026
1027        for case in cases {
1028            assert!(
1029                Expr::parse(case).is_err(),
1030                "{case:?} should not parse as a valid expression"
1031            );
1032        }
1033    }
1034
1035    #[test]
1036    fn test_parse_snapshot() -> Result<(), Error> {
1037        // These cases pin the parser's exact AST shape and byte-range origins.
1038
1039        insta::assert_debug_snapshot!(Expr::parse("!true || false || true")?, @r#"
1040        SpannedExpr {
1041            origin: Origin {
1042                span: Span {
1043                    start: 0,
1044                    end: 22,
1045                },
1046                raw: "!true || false || true",
1047            },
1048            inner: BinExpr(
1049                BinExpr {
1050                    lhs: SpannedExpr {
1051                        origin: Origin {
1052                            span: Span {
1053                                start: 0,
1054                                end: 14,
1055                            },
1056                            raw: "!true || false",
1057                        },
1058                        inner: BinExpr(
1059                            BinExpr {
1060                                lhs: SpannedExpr {
1061                                    origin: Origin {
1062                                        span: Span {
1063                                            start: 0,
1064                                            end: 5,
1065                                        },
1066                                        raw: "!true",
1067                                    },
1068                                    inner: UnExpr {
1069                                        op: Not,
1070                                        expr: SpannedExpr {
1071                                            origin: Origin {
1072                                                span: Span {
1073                                                    start: 1,
1074                                                    end: 5,
1075                                                },
1076                                                raw: "true",
1077                                            },
1078                                            inner: Literal(
1079                                                Boolean(
1080                                                    true,
1081                                                ),
1082                                            ),
1083                                        },
1084                                    },
1085                                },
1086                                op: Or,
1087                                rhs: SpannedExpr {
1088                                    origin: Origin {
1089                                        span: Span {
1090                                            start: 9,
1091                                            end: 14,
1092                                        },
1093                                        raw: "false",
1094                                    },
1095                                    inner: Literal(
1096                                        Boolean(
1097                                            false,
1098                                        ),
1099                                    ),
1100                                },
1101                            },
1102                        ),
1103                    },
1104                    op: Or,
1105                    rhs: SpannedExpr {
1106                        origin: Origin {
1107                            span: Span {
1108                                start: 18,
1109                                end: 22,
1110                            },
1111                            raw: "true",
1112                        },
1113                        inner: Literal(
1114                            Boolean(
1115                                true,
1116                            ),
1117                        ),
1118                    },
1119                },
1120            ),
1121        }
1122        "#);
1123
1124        insta::assert_debug_snapshot!(Expr::parse("'foo '' bar'")?, @r#"
1125        SpannedExpr {
1126            origin: Origin {
1127                span: Span {
1128                    start: 0,
1129                    end: 12,
1130                },
1131                raw: "'foo '' bar'",
1132            },
1133            inner: Literal(
1134                String(
1135                    "foo ' bar",
1136                ),
1137            ),
1138        }
1139        "#);
1140
1141        insta::assert_debug_snapshot!(Expr::parse("('foo '' bar')")?, @r#"
1142        SpannedExpr {
1143            origin: Origin {
1144                span: Span {
1145                    start: 0,
1146                    end: 14,
1147                },
1148                raw: "('foo '' bar')",
1149            },
1150            inner: Literal(
1151                String(
1152                    "foo ' bar",
1153                ),
1154            ),
1155        }
1156        "#);
1157
1158        insta::assert_debug_snapshot!(Expr::parse("((('foo '' bar')))")?, @r#"
1159        SpannedExpr {
1160            origin: Origin {
1161                span: Span {
1162                    start: 0,
1163                    end: 18,
1164                },
1165                raw: "((('foo '' bar')))",
1166            },
1167            inner: Literal(
1168                String(
1169                    "foo ' bar",
1170                ),
1171            ),
1172        }
1173        "#);
1174
1175        insta::assert_debug_snapshot!(Expr::parse("format('{0} {1}', 2, 3)")?, @r#"
1176        SpannedExpr {
1177            origin: Origin {
1178                span: Span {
1179                    start: 0,
1180                    end: 23,
1181                },
1182                raw: "format('{0} {1}', 2, 3)",
1183            },
1184            inner: Call(
1185                Call {
1186                    func: Format,
1187                    args: [
1188                        SpannedExpr {
1189                            origin: Origin {
1190                                span: Span {
1191                                    start: 7,
1192                                    end: 16,
1193                                },
1194                                raw: "'{0} {1}'",
1195                            },
1196                            inner: Literal(
1197                                String(
1198                                    "{0} {1}",
1199                                ),
1200                            ),
1201                        },
1202                        SpannedExpr {
1203                            origin: Origin {
1204                                span: Span {
1205                                    start: 18,
1206                                    end: 19,
1207                                },
1208                                raw: "2",
1209                            },
1210                            inner: Literal(
1211                                Number(
1212                                    2.0,
1213                                ),
1214                            ),
1215                        },
1216                        SpannedExpr {
1217                            origin: Origin {
1218                                span: Span {
1219                                    start: 21,
1220                                    end: 22,
1221                                },
1222                                raw: "3",
1223                            },
1224                            inner: Literal(
1225                                Number(
1226                                    3.0,
1227                                ),
1228                            ),
1229                        },
1230                    ],
1231                },
1232            ),
1233        }
1234        "#);
1235
1236        insta::assert_debug_snapshot!(Expr::parse("foo.bar.baz")?, @r#"
1237        SpannedExpr {
1238            origin: Origin {
1239                span: Span {
1240                    start: 0,
1241                    end: 11,
1242                },
1243                raw: "foo.bar.baz",
1244            },
1245            inner: Context(
1246                Context {
1247                    parts: [
1248                        SpannedExpr {
1249                            origin: Origin {
1250                                span: Span {
1251                                    start: 0,
1252                                    end: 3,
1253                                },
1254                                raw: "foo",
1255                            },
1256                            inner: Identifier(
1257                                Identifier(
1258                                    "foo",
1259                                ),
1260                            ),
1261                        },
1262                        SpannedExpr {
1263                            origin: Origin {
1264                                span: Span {
1265                                    start: 4,
1266                                    end: 7,
1267                                },
1268                                raw: "bar",
1269                            },
1270                            inner: Identifier(
1271                                Identifier(
1272                                    "bar",
1273                                ),
1274                            ),
1275                        },
1276                        SpannedExpr {
1277                            origin: Origin {
1278                                span: Span {
1279                                    start: 8,
1280                                    end: 11,
1281                                },
1282                                raw: "baz",
1283                            },
1284                            inner: Identifier(
1285                                Identifier(
1286                                    "baz",
1287                                ),
1288                            ),
1289                        },
1290                    ],
1291                },
1292            ),
1293        }
1294        "#);
1295
1296        insta::assert_debug_snapshot!(Expr::parse("foo.bar.baz[1][2]"), @r#"
1297        Ok(
1298            SpannedExpr {
1299                origin: Origin {
1300                    span: Span {
1301                        start: 0,
1302                        end: 17,
1303                    },
1304                    raw: "foo.bar.baz[1][2]",
1305                },
1306                inner: Context(
1307                    Context {
1308                        parts: [
1309                            SpannedExpr {
1310                                origin: Origin {
1311                                    span: Span {
1312                                        start: 0,
1313                                        end: 3,
1314                                    },
1315                                    raw: "foo",
1316                                },
1317                                inner: Identifier(
1318                                    Identifier(
1319                                        "foo",
1320                                    ),
1321                                ),
1322                            },
1323                            SpannedExpr {
1324                                origin: Origin {
1325                                    span: Span {
1326                                        start: 4,
1327                                        end: 7,
1328                                    },
1329                                    raw: "bar",
1330                                },
1331                                inner: Identifier(
1332                                    Identifier(
1333                                        "bar",
1334                                    ),
1335                                ),
1336                            },
1337                            SpannedExpr {
1338                                origin: Origin {
1339                                    span: Span {
1340                                        start: 8,
1341                                        end: 11,
1342                                    },
1343                                    raw: "baz",
1344                                },
1345                                inner: Identifier(
1346                                    Identifier(
1347                                        "baz",
1348                                    ),
1349                                ),
1350                            },
1351                            SpannedExpr {
1352                                origin: Origin {
1353                                    span: Span {
1354                                        start: 11,
1355                                        end: 14,
1356                                    },
1357                                    raw: "[1]",
1358                                },
1359                                inner: Index(
1360                                    SpannedExpr {
1361                                        origin: Origin {
1362                                            span: Span {
1363                                                start: 12,
1364                                                end: 13,
1365                                            },
1366                                            raw: "1",
1367                                        },
1368                                        inner: Literal(
1369                                            Number(
1370                                                1.0,
1371                                            ),
1372                                        ),
1373                                    },
1374                                ),
1375                            },
1376                            SpannedExpr {
1377                                origin: Origin {
1378                                    span: Span {
1379                                        start: 14,
1380                                        end: 17,
1381                                    },
1382                                    raw: "[2]",
1383                                },
1384                                inner: Index(
1385                                    SpannedExpr {
1386                                        origin: Origin {
1387                                            span: Span {
1388                                                start: 15,
1389                                                end: 16,
1390                                            },
1391                                            raw: "2",
1392                                        },
1393                                        inner: Literal(
1394                                            Number(
1395                                                2.0,
1396                                            ),
1397                                        ),
1398                                    },
1399                                ),
1400                            },
1401                        ],
1402                    },
1403                ),
1404            },
1405        )
1406        "#);
1407
1408        insta::assert_debug_snapshot!(Expr::parse("foo.bar.baz[*]"), @r#"
1409        Ok(
1410            SpannedExpr {
1411                origin: Origin {
1412                    span: Span {
1413                        start: 0,
1414                        end: 14,
1415                    },
1416                    raw: "foo.bar.baz[*]",
1417                },
1418                inner: Context(
1419                    Context {
1420                        parts: [
1421                            SpannedExpr {
1422                                origin: Origin {
1423                                    span: Span {
1424                                        start: 0,
1425                                        end: 3,
1426                                    },
1427                                    raw: "foo",
1428                                },
1429                                inner: Identifier(
1430                                    Identifier(
1431                                        "foo",
1432                                    ),
1433                                ),
1434                            },
1435                            SpannedExpr {
1436                                origin: Origin {
1437                                    span: Span {
1438                                        start: 4,
1439                                        end: 7,
1440                                    },
1441                                    raw: "bar",
1442                                },
1443                                inner: Identifier(
1444                                    Identifier(
1445                                        "bar",
1446                                    ),
1447                                ),
1448                            },
1449                            SpannedExpr {
1450                                origin: Origin {
1451                                    span: Span {
1452                                        start: 8,
1453                                        end: 11,
1454                                    },
1455                                    raw: "baz",
1456                                },
1457                                inner: Identifier(
1458                                    Identifier(
1459                                        "baz",
1460                                    ),
1461                                ),
1462                            },
1463                            SpannedExpr {
1464                                origin: Origin {
1465                                    span: Span {
1466                                        start: 11,
1467                                        end: 14,
1468                                    },
1469                                    raw: "[*]",
1470                                },
1471                                inner: Index(
1472                                    SpannedExpr {
1473                                        origin: Origin {
1474                                            span: Span {
1475                                                start: 12,
1476                                                end: 13,
1477                                            },
1478                                            raw: "*",
1479                                        },
1480                                        inner: Star,
1481                                    },
1482                                ),
1483                            },
1484                        ],
1485                    },
1486                ),
1487            },
1488        )
1489        "#);
1490
1491        insta::assert_debug_snapshot!(Expr::parse("vegetables.*.ediblePortions"), @r#"
1492        Ok(
1493            SpannedExpr {
1494                origin: Origin {
1495                    span: Span {
1496                        start: 0,
1497                        end: 27,
1498                    },
1499                    raw: "vegetables.*.ediblePortions",
1500                },
1501                inner: Context(
1502                    Context {
1503                        parts: [
1504                            SpannedExpr {
1505                                origin: Origin {
1506                                    span: Span {
1507                                        start: 0,
1508                                        end: 10,
1509                                    },
1510                                    raw: "vegetables",
1511                                },
1512                                inner: Identifier(
1513                                    Identifier(
1514                                        "vegetables",
1515                                    ),
1516                                ),
1517                            },
1518                            SpannedExpr {
1519                                origin: Origin {
1520                                    span: Span {
1521                                        start: 11,
1522                                        end: 12,
1523                                    },
1524                                    raw: "*",
1525                                },
1526                                inner: Star,
1527                            },
1528                            SpannedExpr {
1529                                origin: Origin {
1530                                    span: Span {
1531                                        start: 13,
1532                                        end: 27,
1533                                    },
1534                                    raw: "ediblePortions",
1535                                },
1536                                inner: Identifier(
1537                                    Identifier(
1538                                        "ediblePortions",
1539                                    ),
1540                                ),
1541                            },
1542                        ],
1543                    },
1544                ),
1545            },
1546        )
1547        "#);
1548
1549        insta::assert_debug_snapshot!(Expr::parse("github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'"), @r#"
1550        Ok(
1551            SpannedExpr {
1552                origin: Origin {
1553                    span: Span {
1554                        start: 0,
1555                        end: 88,
1556                    },
1557                    raw: "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1558                },
1559                inner: BinExpr(
1560                    BinExpr {
1561                        lhs: SpannedExpr {
1562                            origin: Origin {
1563                                span: Span {
1564                                    start: 0,
1565                                    end: 58,
1566                                },
1567                                raw: "github.ref == 'refs/heads/main' && 'value_for_main_branch'",
1568                            },
1569                            inner: BinExpr(
1570                                BinExpr {
1571                                    lhs: SpannedExpr {
1572                                        origin: Origin {
1573                                            span: Span {
1574                                                start: 0,
1575                                                end: 31,
1576                                            },
1577                                            raw: "github.ref == 'refs/heads/main'",
1578                                        },
1579                                        inner: BinExpr(
1580                                            BinExpr {
1581                                                lhs: SpannedExpr {
1582                                                    origin: Origin {
1583                                                        span: Span {
1584                                                            start: 0,
1585                                                            end: 10,
1586                                                        },
1587                                                        raw: "github.ref",
1588                                                    },
1589                                                    inner: Context(
1590                                                        Context {
1591                                                            parts: [
1592                                                                SpannedExpr {
1593                                                                    origin: Origin {
1594                                                                        span: Span {
1595                                                                            start: 0,
1596                                                                            end: 6,
1597                                                                        },
1598                                                                        raw: "github",
1599                                                                    },
1600                                                                    inner: Identifier(
1601                                                                        Identifier(
1602                                                                            "github",
1603                                                                        ),
1604                                                                    ),
1605                                                                },
1606                                                                SpannedExpr {
1607                                                                    origin: Origin {
1608                                                                        span: Span {
1609                                                                            start: 7,
1610                                                                            end: 10,
1611                                                                        },
1612                                                                        raw: "ref",
1613                                                                    },
1614                                                                    inner: Identifier(
1615                                                                        Identifier(
1616                                                                            "ref",
1617                                                                        ),
1618                                                                    ),
1619                                                                },
1620                                                            ],
1621                                                        },
1622                                                    ),
1623                                                },
1624                                                op: Eq,
1625                                                rhs: SpannedExpr {
1626                                                    origin: Origin {
1627                                                        span: Span {
1628                                                            start: 14,
1629                                                            end: 31,
1630                                                        },
1631                                                        raw: "'refs/heads/main'",
1632                                                    },
1633                                                    inner: Literal(
1634                                                        String(
1635                                                            "refs/heads/main",
1636                                                        ),
1637                                                    ),
1638                                                },
1639                                            },
1640                                        ),
1641                                    },
1642                                    op: And,
1643                                    rhs: SpannedExpr {
1644                                        origin: Origin {
1645                                            span: Span {
1646                                                start: 35,
1647                                                end: 58,
1648                                            },
1649                                            raw: "'value_for_main_branch'",
1650                                        },
1651                                        inner: Literal(
1652                                            String(
1653                                                "value_for_main_branch",
1654                                            ),
1655                                        ),
1656                                    },
1657                                },
1658                            ),
1659                        },
1660                        op: Or,
1661                        rhs: SpannedExpr {
1662                            origin: Origin {
1663                                span: Span {
1664                                    start: 62,
1665                                    end: 88,
1666                                },
1667                                raw: "'value_for_other_branches'",
1668                            },
1669                            inner: Literal(
1670                                String(
1671                                    "value_for_other_branches",
1672                                ),
1673                            ),
1674                        },
1675                    },
1676                ),
1677            },
1678        )
1679        "#);
1680
1681        insta::assert_debug_snapshot!(Expr::parse("(true || false) == true"), @r#"
1682        Ok(
1683            SpannedExpr {
1684                origin: Origin {
1685                    span: Span {
1686                        start: 0,
1687                        end: 23,
1688                    },
1689                    raw: "(true || false) == true",
1690                },
1691                inner: BinExpr(
1692                    BinExpr {
1693                        lhs: SpannedExpr {
1694                            origin: Origin {
1695                                span: Span {
1696                                    start: 0,
1697                                    end: 15,
1698                                },
1699                                raw: "(true || false)",
1700                            },
1701                            inner: BinExpr(
1702                                BinExpr {
1703                                    lhs: SpannedExpr {
1704                                        origin: Origin {
1705                                            span: Span {
1706                                                start: 1,
1707                                                end: 5,
1708                                            },
1709                                            raw: "true",
1710                                        },
1711                                        inner: Literal(
1712                                            Boolean(
1713                                                true,
1714                                            ),
1715                                        ),
1716                                    },
1717                                    op: Or,
1718                                    rhs: SpannedExpr {
1719                                        origin: Origin {
1720                                            span: Span {
1721                                                start: 9,
1722                                                end: 14,
1723                                            },
1724                                            raw: "false",
1725                                        },
1726                                        inner: Literal(
1727                                            Boolean(
1728                                                false,
1729                                            ),
1730                                        ),
1731                                    },
1732                                },
1733                            ),
1734                        },
1735                        op: Eq,
1736                        rhs: SpannedExpr {
1737                            origin: Origin {
1738                                span: Span {
1739                                    start: 19,
1740                                    end: 23,
1741                                },
1742                                raw: "true",
1743                            },
1744                            inner: Literal(
1745                                Boolean(
1746                                    true,
1747                                ),
1748                            ),
1749                        },
1750                    },
1751                ),
1752            },
1753        )
1754        "#);
1755
1756        insta::assert_debug_snapshot!(Expr::parse("!(!true || false)"), @r#"
1757        Ok(
1758            SpannedExpr {
1759                origin: Origin {
1760                    span: Span {
1761                        start: 0,
1762                        end: 17,
1763                    },
1764                    raw: "!(!true || false)",
1765                },
1766                inner: UnExpr {
1767                    op: Not,
1768                    expr: SpannedExpr {
1769                        origin: Origin {
1770                            span: Span {
1771                                start: 1,
1772                                end: 17,
1773                            },
1774                            raw: "(!true || false)",
1775                        },
1776                        inner: BinExpr(
1777                            BinExpr {
1778                                lhs: SpannedExpr {
1779                                    origin: Origin {
1780                                        span: Span {
1781                                            start: 2,
1782                                            end: 7,
1783                                        },
1784                                        raw: "!true",
1785                                    },
1786                                    inner: UnExpr {
1787                                        op: Not,
1788                                        expr: SpannedExpr {
1789                                            origin: Origin {
1790                                                span: Span {
1791                                                    start: 3,
1792                                                    end: 7,
1793                                                },
1794                                                raw: "true",
1795                                            },
1796                                            inner: Literal(
1797                                                Boolean(
1798                                                    true,
1799                                                ),
1800                                            ),
1801                                        },
1802                                    },
1803                                },
1804                                op: Or,
1805                                rhs: SpannedExpr {
1806                                    origin: Origin {
1807                                        span: Span {
1808                                            start: 11,
1809                                            end: 16,
1810                                        },
1811                                        raw: "false",
1812                                    },
1813                                    inner: Literal(
1814                                        Boolean(
1815                                            false,
1816                                        ),
1817                                    ),
1818                                },
1819                            },
1820                        ),
1821                    },
1822                },
1823            },
1824        )
1825        "#);
1826
1827        insta::assert_debug_snapshot!(Expr::parse("foobar[format('{0}', 'event')]"), @r#"
1828        Ok(
1829            SpannedExpr {
1830                origin: Origin {
1831                    span: Span {
1832                        start: 0,
1833                        end: 30,
1834                    },
1835                    raw: "foobar[format('{0}', 'event')]",
1836                },
1837                inner: Context(
1838                    Context {
1839                        parts: [
1840                            SpannedExpr {
1841                                origin: Origin {
1842                                    span: Span {
1843                                        start: 0,
1844                                        end: 6,
1845                                    },
1846                                    raw: "foobar",
1847                                },
1848                                inner: Identifier(
1849                                    Identifier(
1850                                        "foobar",
1851                                    ),
1852                                ),
1853                            },
1854                            SpannedExpr {
1855                                origin: Origin {
1856                                    span: Span {
1857                                        start: 6,
1858                                        end: 30,
1859                                    },
1860                                    raw: "[format('{0}', 'event')]",
1861                                },
1862                                inner: Index(
1863                                    SpannedExpr {
1864                                        origin: Origin {
1865                                            span: Span {
1866                                                start: 7,
1867                                                end: 29,
1868                                            },
1869                                            raw: "format('{0}', 'event')",
1870                                        },
1871                                        inner: Call(
1872                                            Call {
1873                                                func: Format,
1874                                                args: [
1875                                                    SpannedExpr {
1876                                                        origin: Origin {
1877                                                            span: Span {
1878                                                                start: 14,
1879                                                                end: 19,
1880                                                            },
1881                                                            raw: "'{0}'",
1882                                                        },
1883                                                        inner: Literal(
1884                                                            String(
1885                                                                "{0}",
1886                                                            ),
1887                                                        ),
1888                                                    },
1889                                                    SpannedExpr {
1890                                                        origin: Origin {
1891                                                            span: Span {
1892                                                                start: 21,
1893                                                                end: 28,
1894                                                            },
1895                                                            raw: "'event'",
1896                                                        },
1897                                                        inner: Literal(
1898                                                            String(
1899                                                                "event",
1900                                                            ),
1901                                                        ),
1902                                                    },
1903                                                ],
1904                                            },
1905                                        ),
1906                                    },
1907                                ),
1908                            },
1909                        ],
1910                    },
1911                ),
1912            },
1913        )
1914        "#);
1915
1916        insta::assert_debug_snapshot!(Expr::parse("github.actor_id == '49699333'"), @r#"
1917        Ok(
1918            SpannedExpr {
1919                origin: Origin {
1920                    span: Span {
1921                        start: 0,
1922                        end: 29,
1923                    },
1924                    raw: "github.actor_id == '49699333'",
1925                },
1926                inner: BinExpr(
1927                    BinExpr {
1928                        lhs: SpannedExpr {
1929                            origin: Origin {
1930                                span: Span {
1931                                    start: 0,
1932                                    end: 15,
1933                                },
1934                                raw: "github.actor_id",
1935                            },
1936                            inner: Context(
1937                                Context {
1938                                    parts: [
1939                                        SpannedExpr {
1940                                            origin: Origin {
1941                                                span: Span {
1942                                                    start: 0,
1943                                                    end: 6,
1944                                                },
1945                                                raw: "github",
1946                                            },
1947                                            inner: Identifier(
1948                                                Identifier(
1949                                                    "github",
1950                                                ),
1951                                            ),
1952                                        },
1953                                        SpannedExpr {
1954                                            origin: Origin {
1955                                                span: Span {
1956                                                    start: 7,
1957                                                    end: 15,
1958                                                },
1959                                                raw: "actor_id",
1960                                            },
1961                                            inner: Identifier(
1962                                                Identifier(
1963                                                    "actor_id",
1964                                                ),
1965                                            ),
1966                                        },
1967                                    ],
1968                                },
1969                            ),
1970                        },
1971                        op: Eq,
1972                        rhs: SpannedExpr {
1973                            origin: Origin {
1974                                span: Span {
1975                                    start: 19,
1976                                    end: 29,
1977                                },
1978                                raw: "'49699333'",
1979                            },
1980                            inner: Literal(
1981                                String(
1982                                    "49699333",
1983                                ),
1984                            ),
1985                        },
1986                    },
1987                ),
1988            },
1989        )
1990        "#);
1991
1992        insta::assert_debug_snapshot!(Expr::parse("(fromJSON('[]'))[1]"), @r#"
1993        Ok(
1994            SpannedExpr {
1995                origin: Origin {
1996                    span: Span {
1997                        start: 0,
1998                        end: 19,
1999                    },
2000                    raw: "(fromJSON('[]'))[1]",
2001                },
2002                inner: Context(
2003                    Context {
2004                        parts: [
2005                            SpannedExpr {
2006                                origin: Origin {
2007                                    span: Span {
2008                                        start: 0,
2009                                        end: 16,
2010                                    },
2011                                    raw: "(fromJSON('[]'))",
2012                                },
2013                                inner: Call(
2014                                    Call {
2015                                        func: FromJSON,
2016                                        args: [
2017                                            SpannedExpr {
2018                                                origin: Origin {
2019                                                    span: Span {
2020                                                        start: 10,
2021                                                        end: 14,
2022                                                    },
2023                                                    raw: "'[]'",
2024                                                },
2025                                                inner: Literal(
2026                                                    String(
2027                                                        "[]",
2028                                                    ),
2029                                                ),
2030                                            },
2031                                        ],
2032                                    },
2033                                ),
2034                            },
2035                            SpannedExpr {
2036                                origin: Origin {
2037                                    span: Span {
2038                                        start: 16,
2039                                        end: 19,
2040                                    },
2041                                    raw: "[1]",
2042                                },
2043                                inner: Index(
2044                                    SpannedExpr {
2045                                        origin: Origin {
2046                                            span: Span {
2047                                                start: 17,
2048                                                end: 18,
2049                                            },
2050                                            raw: "1",
2051                                        },
2052                                        inner: Literal(
2053                                            Number(
2054                                                1.0,
2055                                            ),
2056                                        ),
2057                                    },
2058                                ),
2059                            },
2060                        ],
2061                    },
2062                ),
2063            },
2064        )
2065        "#);
2066
2067        Ok(())
2068    }
2069
2070    #[test]
2071    fn test_expr_constant_reducible() -> Result<(), Error> {
2072        for (expr, reducible) in &[
2073            ("'foo'", true),
2074            ("1", true),
2075            ("true", true),
2076            ("null", true),
2077            // boolean and unary expressions of all literals are
2078            // always reducible.
2079            ("!true", true),
2080            ("!null", true),
2081            ("true && false", true),
2082            ("true || false", true),
2083            ("null && !null && true", true),
2084            // formats/contains/startsWith/endsWith are reducible
2085            // if all of their arguments are reducible.
2086            ("format('{0} {1}', 'foo', 'bar')", true),
2087            ("format('{0} {1}', 1, 2)", true),
2088            ("format('{0} {1}', 1, '2')", true),
2089            ("contains('foo', 'bar')", true),
2090            ("startsWith('foo', 'bar')", true),
2091            ("endsWith('foo', 'bar')", true),
2092            ("startsWith(some.context, 'bar')", false),
2093            ("endsWith(some.context, 'bar')", false),
2094            // Nesting works as long as the nested call is also reducible.
2095            ("format('{0} {1}', '1', format('{0}', null))", true),
2096            ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
2097            ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
2098            ("foo", false),
2099            ("foo.bar", false),
2100            ("foo.bar[1]", false),
2101            ("foo.bar == 'bar'", false),
2102            ("foo.bar || bar || baz", false),
2103            ("foo.bar && bar && baz", false),
2104        ] {
2105            let expr = Expr::parse(expr)?;
2106            assert_eq!(expr.constant_reducible(), *reducible);
2107        }
2108
2109        Ok(())
2110    }
2111
2112    #[test]
2113    fn test_evaluate_constant_complex_expressions() -> Result<(), Error> {
2114        use crate::Evaluation;
2115
2116        let test_cases = &[
2117            // Nested operations
2118            ("!false", Evaluation::Boolean(true)),
2119            ("!true", Evaluation::Boolean(false)),
2120            ("!(true && false)", Evaluation::Boolean(true)),
2121            // Complex boolean logic
2122            ("true && (false || true)", Evaluation::Boolean(true)),
2123            ("false || (true && false)", Evaluation::Boolean(false)),
2124            // Mixed function calls
2125            (
2126                "contains(format('{0} {1}', 'hello', 'world'), 'world')",
2127                Evaluation::Boolean(true),
2128            ),
2129            (
2130                "startsWith(format('prefix_{0}', 'test'), 'prefix')",
2131                Evaluation::Boolean(true),
2132            ),
2133        ];
2134
2135        for (expr_str, expected) in test_cases {
2136            let expr = Expr::parse(expr_str)?;
2137            let result = expr.consteval().unwrap();
2138            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
2139        }
2140
2141        Ok(())
2142    }
2143
2144    #[test]
2145    fn test_case_insensitive_string_comparison() -> Result<(), Error> {
2146        use crate::Evaluation;
2147
2148        let test_cases = &[
2149            // == is case-insensitive for strings
2150            ("'hello' == 'hello'", Evaluation::Boolean(true)),
2151            ("'hello' == 'HELLO'", Evaluation::Boolean(true)),
2152            ("'Hello' == 'hELLO'", Evaluation::Boolean(true)),
2153            ("'abc' == 'def'", Evaluation::Boolean(false)),
2154            // != is case-insensitive for strings
2155            ("'hello' != 'HELLO'", Evaluation::Boolean(false)),
2156            ("'abc' != 'def'", Evaluation::Boolean(true)),
2157            // Comparison operators are case-insensitive for strings
2158            ("'abc' < 'DEF'", Evaluation::Boolean(true)),
2159            ("'ABC' < 'def'", Evaluation::Boolean(true)),
2160            ("'abc' >= 'ABC'", Evaluation::Boolean(true)),
2161            ("'ABC' <= 'abc'", Evaluation::Boolean(true)),
2162            // Greek sigma: ς (final) and σ (non-final) both uppercase to Σ.
2163            // This is why we use to_uppercase() instead of to_lowercase().
2164            ("'\u{03C3}' == '\u{03C2}'", Evaluation::Boolean(true)), // σ == ς
2165            ("'\u{03A3}' == '\u{03C3}'", Evaluation::Boolean(true)), // Σ == σ
2166            ("'\u{03A3}' == '\u{03C2}'", Evaluation::Boolean(true)), // Σ == ς
2167            // Array contains with case-insensitive string matching
2168            (
2169                "contains(fromJSON('[\"Hello\", \"World\"]'), 'hello')",
2170                Evaluation::Boolean(true),
2171            ),
2172            (
2173                "contains(fromJSON('[\"hello\", \"world\"]'), 'WORLD')",
2174                Evaluation::Boolean(true),
2175            ),
2176            (
2177                "contains(fromJSON('[\"ABC\"]'), 'abc')",
2178                Evaluation::Boolean(true),
2179            ),
2180            (
2181                "contains(fromJSON('[\"abc\"]'), 'def')",
2182                Evaluation::Boolean(false),
2183            ),
2184        ];
2185
2186        for (expr_str, expected) in test_cases {
2187            let expr = Expr::parse(expr_str)?;
2188            let result = expr.consteval().unwrap();
2189            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
2190        }
2191
2192        Ok(())
2193    }
2194
2195    #[test]
2196    fn test_evaluation_sema_display() {
2197        use crate::Evaluation;
2198
2199        let test_cases = &[
2200            (Evaluation::String("hello".to_string()), "hello"),
2201            (Evaluation::Number(42.0), "42"),
2202            (Evaluation::Number(3.14), "3.14"),
2203            (Evaluation::Boolean(true), "true"),
2204            (Evaluation::Boolean(false), "false"),
2205            (Evaluation::Null, ""),
2206        ];
2207
2208        for (result, expected) in test_cases {
2209            assert_eq!(result.sema().to_string(), *expected);
2210        }
2211    }
2212
2213    #[test]
2214    fn test_evaluation_result_to_boolean() {
2215        use crate::Evaluation;
2216
2217        let test_cases = &[
2218            (Evaluation::Boolean(true), true),
2219            (Evaluation::Boolean(false), false),
2220            (Evaluation::Null, false),
2221            (Evaluation::Number(0.0), false),
2222            (Evaluation::Number(1.0), true),
2223            (Evaluation::Number(-1.0), true),
2224            (Evaluation::Number(f64::NAN), false), // NaN is falsy in GitHub Actions
2225            (Evaluation::String("".to_string()), false),
2226            (Evaluation::String("hello".to_string()), true),
2227            (Evaluation::Array(vec![]), true), // Arrays are always truthy
2228            (Evaluation::Object(std::collections::HashMap::new()), true), // Dictionaries are always truthy
2229        ];
2230
2231        for (result, expected) in test_cases {
2232            assert_eq!(result.as_boolean(), *expected);
2233        }
2234    }
2235
2236    #[test]
2237    fn test_evaluation_result_to_number() {
2238        use crate::Evaluation;
2239
2240        // Non-string types
2241        let test_cases = &[
2242            (Evaluation::Number(42.0), 42.0),
2243            (Evaluation::Number(0.0), 0.0),
2244            (Evaluation::Boolean(true), 1.0),
2245            (Evaluation::Boolean(false), 0.0),
2246            (Evaluation::Null, 0.0),
2247        ];
2248
2249        for (eval, expected) in test_cases {
2250            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", eval);
2251        }
2252
2253        let string_cases: &[(&str, f64)] = &[
2254            // Empty / whitespace-only
2255            ("", 0.0),
2256            ("   ", 0.0),
2257            ("\t", 0.0),
2258            // Whitespace trimming
2259            ("   123   ", 123.0),
2260            (" 42 ", 42.0),
2261            ("   1   ", 1.0),
2262            ("\t5\n", 5.0),
2263            ("  \t123\t  ", 123.0),
2264            // Basic decimal
2265            ("42", 42.0),
2266            ("3.14", 3.14),
2267            // Hex
2268            ("0xff", 255.0),
2269            ("0xfF", 255.0),
2270            ("0xFF", 255.0),
2271            (" 0xff ", 255.0),
2272            ("0x0", 0.0),
2273            ("0x11", 17.0),
2274            // Hex: signed 32-bit two's complement wrapping
2275            ("0x7FFFFFFF", 2147483647.0),
2276            ("0x80000000", -2147483648.0),
2277            ("0xFFFFFFFF", -1.0),
2278            // Octal
2279            ("0o10", 8.0),
2280            (" 0o10 ", 8.0),
2281            ("0o0", 0.0),
2282            ("0o11", 9.0),
2283            // Octal: signed 32-bit two's complement wrapping
2284            ("0o17777777777", 2147483647.0),
2285            ("0o20000000000", -2147483648.0),
2286            // Scientific notation
2287            ("1.2e2", 120.0),
2288            ("1.2E2", 120.0),
2289            ("1.2e-2", 0.012),
2290            (" 1.2e2 ", 120.0),
2291            ("1.2e+2", 120.0),
2292            ("5e0", 5.0),
2293            ("1e3", 1000.0),
2294            ("123e-1", 12.3),
2295            (" +1.2e2 ", 120.0),
2296            (" -1.2E+2 ", -120.0),
2297            // Signs
2298            ("+42", 42.0),
2299            ("  -42  ", -42.0),
2300            ("  3.14  ", 3.14),
2301            ("+0", 0.0),
2302            ("-0", 0.0),
2303            (" +123456.789 ", 123456.789),
2304            (" -123456.789 ", -123456.789),
2305            // Leading zeros -> decimal
2306            ("0123", 123.0),
2307            ("00", 0.0),
2308            ("007", 7.0),
2309            ("010", 10.0),
2310            // Trailing/leading dot
2311            ("123.", 123.0),
2312            (".5", 0.5),
2313        ];
2314
2315        for (input, expected) in string_cases {
2316            let eval = Evaluation::String(input.to_string());
2317            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", input);
2318        }
2319
2320        // Infinity cases
2321        let infinity_cases: &[(&str, f64)] = &[
2322            ("Infinity", f64::INFINITY),
2323            (" Infinity ", f64::INFINITY),
2324            ("+Infinity", f64::INFINITY),
2325            ("-Infinity", f64::NEG_INFINITY),
2326            (" -Infinity ", f64::NEG_INFINITY),
2327        ];
2328
2329        for (input, expected) in infinity_cases {
2330            let eval = Evaluation::String(input.to_string());
2331            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", input);
2332        }
2333
2334        // NaN cases: all verified against GitHub Actions CI.
2335        let nan_cases: &[&str] = &[
2336            // Invalid strings
2337            "hello",
2338            "abc",
2339            " abc ",
2340            " NaN ",
2341            // Partial/malformed numerics
2342            "123abc",
2343            "abc123",
2344            "100a",
2345            "12.3.4",
2346            "1e2e3",
2347            "1 2",
2348            "1_000",
2349            "+",
2350            "-",
2351            ".",
2352            // Binary notation
2353            "0b1010",
2354            "0B1010",
2355            "0b0",
2356            "0b1",
2357            "0b11",
2358            " 0b11 ",
2359            // Uppercase prefixes are NOT supported
2360            "0XFF",
2361            "0O10",
2362            // Signed prefixed numbers are NOT supported
2363            "-0xff",
2364            "+0xff",
2365            "-0o10",
2366            "+0o10",
2367            "-0b11",
2368            // Empty prefixes (no digits after prefix)
2369            "0x",
2370            "0o",
2371            "0b",
2372            // Invalid digits for the base
2373            "0xZZ",
2374            "0o89",
2375            "0b23",
2376            // Hex/octal values exceeding 32-bit
2377            "0x100000000",
2378            "0o40000000000",
2379            // "inf" abbreviation rejected by GH runner
2380            "inf",
2381            "Inf",
2382            "INF",
2383            "+inf",
2384            "-inf",
2385            " inf ",
2386        ];
2387
2388        for input in nan_cases {
2389            let eval = Evaluation::String(input.to_string());
2390            assert!(
2391                eval.as_number().is_nan(),
2392                "as_number() for {:?} should be NaN",
2393                input
2394            );
2395        }
2396    }
2397
2398    #[test]
2399    fn test_github_actions_logical_semantics() -> Result<(), Error> {
2400        use crate::Evaluation;
2401
2402        // Test GitHub Actions-specific && and || semantics
2403        let test_cases = &[
2404            // && returns the first falsy value, or the last value if all are truthy
2405            ("false && 'hello'", Evaluation::Boolean(false)),
2406            ("null && 'hello'", Evaluation::Null),
2407            ("'' && 'hello'", Evaluation::String("".to_string())),
2408            (
2409                "'hello' && 'world'",
2410                Evaluation::String("world".to_string()),
2411            ),
2412            ("true && 42", Evaluation::Number(42.0)),
2413            // || returns the first truthy value, or the last value if all are falsy
2414            ("true || 'hello'", Evaluation::Boolean(true)),
2415            (
2416                "'hello' || 'world'",
2417                Evaluation::String("hello".to_string()),
2418            ),
2419            ("false || 'hello'", Evaluation::String("hello".to_string())),
2420            ("null || false", Evaluation::Boolean(false)),
2421            ("'' || null", Evaluation::Null),
2422            ("!NaN", Evaluation::Boolean(true)),
2423            ("!!NaN", Evaluation::Boolean(false)),
2424        ];
2425
2426        for (expr_str, expected) in test_cases {
2427            let expr = Expr::parse(expr_str)?;
2428            let result = expr.consteval().unwrap();
2429            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
2430        }
2431
2432        Ok(())
2433    }
2434
2435    #[test]
2436    fn test_expr_has_constant_reducible_subexpr() -> Result<(), Error> {
2437        for (expr, reducible) in &[
2438            // Literals are not considered reducible subexpressions.
2439            ("'foo'", false),
2440            ("1", false),
2441            ("true", false),
2442            ("null", false),
2443            // Non-reducible expressions with reducible subexpressions
2444            (
2445                "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
2446                true,
2447            ),
2448            ("foobar[format('{0}', 'event')]", true),
2449        ] {
2450            let expr = Expr::parse(expr)?;
2451            assert_eq!(!expr.constant_reducible_subexprs().is_empty(), *reducible);
2452        }
2453        Ok(())
2454    }
2455
2456    #[test]
2457    fn test_expr_contexts() -> Result<(), Error> {
2458        // A single context.
2459        let expr = Expr::parse("foo.bar.baz[1].qux")?;
2460        assert_eq!(
2461            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
2462            ["foo.bar.baz[1].qux",]
2463        );
2464
2465        // Multiple contexts.
2466        let expr = Expr::parse("foo.bar[1].baz || abc.def")?;
2467        assert_eq!(
2468            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
2469            ["foo.bar[1].baz", "abc.def",]
2470        );
2471
2472        // Two contexts, one as part of a computed index.
2473        let expr = Expr::parse("foo.bar[abc.def]")?;
2474        assert_eq!(
2475            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
2476            ["foo.bar[abc.def]", "abc.def",]
2477        );
2478
2479        Ok(())
2480    }
2481
2482    #[test]
2483    fn test_expr_dataflow_contexts() -> Result<(), Error> {
2484        // Trivial cases.
2485        let expr = Expr::parse("foo.bar")?;
2486        assert_eq!(
2487            expr.dataflow_contexts()
2488                .iter()
2489                .map(|t| t.1.raw)
2490                .collect::<Vec<_>>(),
2491            ["foo.bar"]
2492        );
2493
2494        let expr = Expr::parse("foo.bar[1]")?;
2495        assert_eq!(
2496            expr.dataflow_contexts()
2497                .iter()
2498                .map(|t| t.1.raw)
2499                .collect::<Vec<_>>(),
2500            ["foo.bar[1]"]
2501        );
2502
2503        // No dataflow due to a boolean expression.
2504        let expr = Expr::parse("foo.bar == 'bar'")?;
2505        assert!(expr.dataflow_contexts().is_empty());
2506
2507        // ||: all contexts potentially expand into the evaluation.
2508        let expr = Expr::parse("foo.bar || abc || d.e.f")?;
2509        assert_eq!(
2510            expr.dataflow_contexts()
2511                .iter()
2512                .map(|t| t.1.raw)
2513                .collect::<Vec<_>>(),
2514            ["foo.bar", "abc", "d.e.f"]
2515        );
2516
2517        // &&: only the RHS context(s) expand into the evaluation.
2518        let expr = Expr::parse("foo.bar && abc && d.e.f")?;
2519        assert_eq!(
2520            expr.dataflow_contexts()
2521                .iter()
2522                .map(|t| t.1.raw)
2523                .collect::<Vec<_>>(),
2524            ["d.e.f"]
2525        );
2526
2527        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
2528        assert_eq!(
2529            expr.dataflow_contexts()
2530                .iter()
2531                .map(|t| t.1.raw)
2532                .collect::<Vec<_>>(),
2533            ["foo.bar"]
2534        );
2535
2536        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
2537        assert_eq!(
2538            expr.dataflow_contexts()
2539                .iter()
2540                .map(|t| t.1.raw)
2541                .collect::<Vec<_>>(),
2542            ["foo.bar", "foo.baz"]
2543        );
2544
2545        let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
2546        assert_eq!(
2547            expr.dataflow_contexts()
2548                .iter()
2549                .map(|t| t.1.raw)
2550                .collect::<Vec<_>>(),
2551            ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
2552        );
2553
2554        let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
2555        assert_eq!(
2556            expr.dataflow_contexts()
2557                .iter()
2558                .map(|t| t.1.raw)
2559                .collect::<Vec<_>>(),
2560            ["foo.bar", "github", "github"]
2561        );
2562
2563        Ok(())
2564    }
2565
2566    #[test]
2567    fn test_spannedexpr_computed_indices() -> Result<(), Error> {
2568        for (expr, computed_indices) in &[
2569            ("foo.bar", vec![]),
2570            ("foo.bar[1]", vec![]),
2571            ("foo.bar[*]", vec![]),
2572            ("foo.bar[abc]", vec!["[abc]"]),
2573            (
2574                "foo.bar[format('{0}', 'foo')]",
2575                vec!["[format('{0}', 'foo')]"],
2576            ),
2577            ("foo.bar[abc].def[efg]", vec!["[abc]", "[efg]"]),
2578        ] {
2579            let expr = Expr::parse(expr)?;
2580
2581            assert_eq!(
2582                expr.computed_indices()
2583                    .iter()
2584                    .map(|e| e.origin.raw)
2585                    .collect::<Vec<_>>(),
2586                *computed_indices
2587            );
2588        }
2589
2590        Ok(())
2591    }
2592
2593    #[test]
2594    fn test_fragment_from_expr() {
2595        for (expr, expected) in &[
2596            ("foo==bar", "foo==bar"),
2597            ("foo    ==   bar", r"foo\s+==\s+bar"),
2598            ("foo == bar", r"foo\s+==\s+bar"),
2599            ("fromJSON('{}')", "fromJSON('{}')"),
2600            ("fromJSON('{ }')", r"fromJSON\('\{\s+\}'\)"),
2601            ("fromJSON ('{ }')", r"fromJSON\s+\('\{\s+\}'\)"),
2602            ("a . b . c . d", r"a\s+\.\s+b\s+\.\s+c\s+\.\s+d"),
2603            ("true \n && \n false", r"true\s+\&\&\s+false"),
2604        ] {
2605            let expr = Expr::parse(expr).unwrap();
2606            match subfeature::Fragment::from(&expr) {
2607                subfeature::Fragment::Raw(actual) => assert_eq!(actual, *expected),
2608                subfeature::Fragment::Regex(actual) => assert_eq!(actual.as_str(), *expected),
2609            };
2610        }
2611    }
2612
2613    #[test]
2614    fn test_leaf_expressions() -> Result<(), Error> {
2615        // A single literal is its own leaf.
2616        let expr = Expr::parse("'hello'")?;
2617        let leaves = expr.leaf_expressions();
2618        assert_eq!(leaves.len(), 1);
2619        assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "hello"));
2620
2621        // A single context is its own leaf.
2622        let expr = Expr::parse("foo.bar")?;
2623        let leaves = expr.leaf_expressions();
2624        assert_eq!(leaves.len(), 1);
2625        assert!(matches!(&leaves[0].inner, Expr::Context(_)));
2626
2627        // `A || B` returns both sides.
2628        let expr = Expr::parse("foo.abc || foo.def")?;
2629        let leaves = expr.leaf_expressions();
2630        assert_eq!(leaves.len(), 2);
2631        assert!(matches!(&leaves[0].inner, Expr::Context(_)));
2632        assert!(matches!(&leaves[1].inner, Expr::Context(_)));
2633
2634        // `A && B` returns only B.
2635        let expr = Expr::parse("foo.bar && 'hello'")?;
2636        let leaves = expr.leaf_expressions();
2637        assert_eq!(leaves.len(), 1);
2638        assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "hello"));
2639
2640        // Conditional pattern: `cond && 'value' || 'fallback'`
2641        let expr = Expr::parse("foo.bar == 'true' && 'redis:7' || ''")?;
2642        let leaves = expr.leaf_expressions();
2643        assert_eq!(leaves.len(), 2);
2644        assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "redis:7"));
2645        assert!(matches!(&leaves[1].inner, Expr::Literal(Literal::String(s)) if s == ""));
2646
2647        // Comparison operators are leaves themselves (they produce booleans).
2648        let expr = Expr::parse("foo.bar == 'abc'")?;
2649        let leaves = expr.leaf_expressions();
2650        assert_eq!(leaves.len(), 1);
2651        assert!(matches!(&leaves[0].inner, Expr::BinExpr { .. }));
2652
2653        Ok(())
2654    }
2655
2656    #[test]
2657    fn test_upper_special() {
2658        use super::EvaluationSema;
2659
2660        let cases = &[
2661            ("", ""),
2662            ("abc", "ABC"),
2663            ("ıabc", "ıABC"),
2664            ("ııabc", "ııABC"),
2665            ("abcı", "ABCı"),
2666            ("abcıı", "ABCıı"),
2667            ("abcıdef", "ABCıDEF"),
2668            ("abcııdef", "ABCııDEF"),
2669            ("abcıdefıghi", "ABCıDEFıGHI"),
2670        ];
2671
2672        for (input, want) in cases {
2673            assert_eq!(
2674                EvaluationSema::upper_special(input),
2675                *want,
2676                "input: {input}"
2677            );
2678        }
2679    }
2680
2681    #[test]
2682    fn test_expr_commutative_matches() -> Result<(), Error> {
2683        let cases = &[
2684            // Identical expressions always match.
2685            ("a == b", "a == b", true),
2686            // Commutative operators match when swapped.
2687            ("a == b", "b == a", true),
2688            ("a != b", "b != a", true),
2689            ("a && b", "b && a", true),
2690            ("a || b", "b || a", true),
2691            // Non-commutative operators don't match when swapped.
2692            ("a > b", "b > a", false),
2693            ("a >= b", "b >= a", false),
2694            ("a < b", "b < a", false),
2695            ("a <= b", "b <= a", false),
2696            // Non-commutative operators still match positionally.
2697            ("a > b", "a > b", true),
2698            // Different operators never match.
2699            ("a == b", "a != b", false),
2700            ("a > b", "a < b", false),
2701            // Recursive commutative matching through nested commutative ops.
2702            ("(a == b) && (c == d)", "(d == c) && (b == a)", true),
2703            ("(a == b) && (c == d)", "(c == d) && (a == b)", true),
2704            // Recursion descends into non-commutative ops positionally.
2705            ("(a == b) > (c == d)", "(b == a) > (d == c)", true),
2706            ("(a == b) > (c == d)", "(c == d) > (a == b)", false),
2707            // Non-binexpr fall-through uses equality.
2708            ("'foo'", "'foo'", true),
2709            ("'foo'", "'bar'", false),
2710            // Mixed binexpr vs non-binexpr never matches.
2711            ("a == b", "'foo'", false),
2712        ];
2713
2714        for (lhs, rhs, expected) in cases {
2715            let lhs_expr = Expr::parse(lhs)?;
2716            let rhs_expr = Expr::parse(rhs)?;
2717            assert_eq!(
2718                lhs_expr.inner.commutative_matches(&rhs_expr.inner),
2719                *expected,
2720                "{lhs} <=> {rhs}",
2721            );
2722        }
2723
2724        Ok(())
2725    }
2726}