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