Skip to main content

github_actions_expressions/
lib.rs

1//! GitHub Actions expression parsing and analysis.
2
3#![forbid(unsafe_code)]
4#![deny(missing_docs)]
5
6use std::ops::Deref;
7
8use crate::{
9    call::{Call, Function},
10    context::Context,
11    identifier::Identifier,
12    literal::Literal,
13    op::{BinOp, UnOp},
14};
15
16use self::parser::{ExprParser, Rule};
17use itertools::Itertools;
18use pest::{Parser, iterators::Pair};
19
20pub mod call;
21pub mod context;
22pub mod identifier;
23pub mod literal;
24pub mod op;
25
26/// Errors that can occur during expression parsing.
27#[derive(Debug, thiserror::Error)]
28pub enum Error {
29    /// The expression failed to parse according to the grammar.
30    #[error("Parse error: {0}")]
31    Pest(#[from] pest::error::Error<Rule>),
32    /// The expression contains an invalid function call.
33    #[error("Invalid function call")]
34    Call(#[from] call::Error),
35}
36
37// Isolates the ExprParser, Rule and other generated types
38// so that we can do `missing_docs` at the top-level.
39// See: https://github.com/pest-parser/pest/issues/326
40mod parser {
41    use pest_derive::Parser;
42
43    /// A parser for GitHub Actions' expression language.
44    #[derive(Parser)]
45    #[grammar = "expr.pest"]
46    pub struct ExprParser;
47}
48
49/// Represents the origin of an expression, including its source span
50/// and unparsed form.
51#[derive(Copy, Clone, Debug, PartialEq)]
52pub struct Origin<'src> {
53    /// The expression's source span.
54    pub span: subfeature::Span,
55    /// The expression's unparsed form, as it appears in the source.
56    ///
57    /// This is recorded exactly as it appears in the source, *except*
58    /// that leading and trailing whitespace is stripped. This is stripped
59    /// because it's (1) non-semantic, and (2) can cause all kinds of issues
60    /// when attempting to map expressions back to YAML source features.
61    pub raw: &'src str,
62}
63
64impl<'a> Origin<'a> {
65    /// Create a new origin from the given span and raw form.
66    pub fn new(span: impl Into<subfeature::Span>, raw: &'a str) -> Self {
67        Self {
68            span: span.into(),
69            raw: raw.trim(),
70        }
71    }
72}
73
74/// An expression along with its source origin (span and unparsed form).
75///
76/// Important: Because of how our parser works internally, an expression's
77/// span is its *rule*'s span, which can be larger than the expression itself.
78/// For example, `foo || bar || baz` is covered by a single rule, so each
79/// decomposed `Expr::BinOp` within it will have the same span despite
80/// logically having different sub-spans of the parent rule's span.
81#[derive(Debug, PartialEq)]
82pub struct SpannedExpr<'src> {
83    /// The expression's source origin.
84    pub origin: Origin<'src>,
85    /// The expression itself.
86    pub inner: Expr<'src>,
87}
88
89impl<'a> SpannedExpr<'a> {
90    /// Creates a new `SpannedExpr` from an expression and its span.
91    pub(crate) fn new(origin: Origin<'a>, inner: Expr<'a>) -> Self {
92        Self { origin, inner }
93    }
94
95    /// Returns the contexts in this expression, along with their origins.
96    ///
97    /// This includes all contexts in the expression, even those that don't directly flow into
98    /// the evaluation. For example, `${{ foo.bar == 'abc' }}` returns `foo.bar` since it's a
99    /// context in the expression, even though it flows into a boolean evaluation rather than
100    /// directly into the output.
101    ///
102    /// For dataflow contexts, see [`SpannedExpr::dataflow_contexts`].
103    pub fn contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> {
104        let mut contexts = vec![];
105
106        match self.deref() {
107            Expr::Index(expr) => contexts.extend(expr.contexts()),
108            Expr::Call(Call { func: _, args }) => {
109                for arg in args {
110                    contexts.extend(arg.contexts());
111                }
112            }
113            Expr::Context(ctx) => {
114                // Record the context itself.
115                contexts.push((ctx, &self.origin));
116
117                // The context's parts can also contain independent contexts,
118                // e.g. computed indices like `bar.baz` in `foo[bar.baz]`.
119                ctx.parts
120                    .iter()
121                    .for_each(|part| contexts.extend(part.contexts()));
122            }
123            Expr::BinOp { lhs, op: _, rhs } => {
124                contexts.extend(lhs.contexts());
125                contexts.extend(rhs.contexts());
126            }
127            Expr::UnOp { op: _, expr } => contexts.extend(expr.contexts()),
128            _ => (),
129        }
130
131        contexts
132    }
133
134    /// Returns the contexts in this expression that directly flow into the
135    /// expression's evaluation.
136    ///
137    /// For example `${{ foo.bar }}` returns `foo.bar` since the value
138    /// of `foo.bar` flows into the evaluation. On the other hand,
139    /// `${{ foo.bar == 'abc' }}` returns no expanded contexts,
140    /// since the value of `foo.bar` flows into a boolean evaluation
141    /// that gets expanded.
142    pub fn dataflow_contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> {
143        let mut contexts = vec![];
144
145        match self.deref() {
146            Expr::Call(Call { func, args }) => {
147                // These functions, when evaluated, produce an evaluation
148                // that includes some or all of the contexts listed in
149                // their arguments.
150                if matches!(func, Function::ToJSON | Function::Format | Function::Join) {
151                    for arg in args {
152                        contexts.extend(arg.dataflow_contexts());
153                    }
154                }
155            }
156            // NOTE: We intentionally don't handle the `func(...).foo.bar`
157            // case differently here, since a call followed by a
158            // context access *can* flow into the evaluation.
159            // For example, `${{ fromJSON(something) }}` evaluates to
160            // `Object` but `${{ fromJSON(something).foo }}` evaluates
161            // to the contents of `something.foo`.
162            Expr::Context(ctx) => contexts.push((ctx, &self.origin)),
163            Expr::BinOp { lhs, op, rhs } => match op {
164                // With && only the RHS can flow into the evaluation as a context
165                // (rather than a boolean).
166                BinOp::And => {
167                    contexts.extend(rhs.dataflow_contexts());
168                }
169                // With || either the LHS or RHS can flow into the evaluation as a context.
170                BinOp::Or => {
171                    contexts.extend(lhs.dataflow_contexts());
172                    contexts.extend(rhs.dataflow_contexts());
173                }
174                _ => (),
175            },
176            _ => (),
177        }
178
179        contexts
180    }
181
182    /// Returns all possible leaf expressions that could be the result
183    /// of evaluating this expression.
184    ///
185    /// Uses GitHub Actions' short-circuit semantics:
186    /// - `A && B`: only B can flow into the result (A is a condition)
187    /// - `A || B`: either A or B can be the result
188    ///
189    /// Leaf expressions are any non-`BinOp` expressions: literals, contexts,
190    /// function calls, etc.
191    ///
192    /// For example, `${{ foo.bar == 'true' && 'hello' || '' }}` returns
193    /// `['hello', '']` since those are the two possible evaluated values.
194    /// `${{ foo.abc || foo.def }}` returns `[foo.abc, foo.def]`.
195    pub fn leaf_expressions(&self) -> Vec<&SpannedExpr<'a>> {
196        let mut leaves = vec![];
197
198        match self.deref() {
199            Expr::BinOp { lhs, op, rhs } => match op {
200                BinOp::And => {
201                    leaves.extend(rhs.leaf_expressions());
202                }
203                BinOp::Or => {
204                    leaves.extend(lhs.leaf_expressions());
205                    leaves.extend(rhs.leaf_expressions());
206                }
207                // Comparison operators produce booleans, not their operands.
208                _ => leaves.push(self),
209            },
210            _ => leaves.push(self),
211        }
212
213        leaves
214    }
215
216    /// Returns any computed indices in this expression.
217    ///
218    /// A computed index is any index operation with a non-literal
219    /// evaluation, e.g. `foo[a.b.c]`.
220    pub fn computed_indices(&self) -> Vec<&SpannedExpr<'a>> {
221        let mut index_exprs = vec![];
222
223        match self.deref() {
224            Expr::Call(Call { func: _, args }) => {
225                for arg in args {
226                    index_exprs.extend(arg.computed_indices());
227                }
228            }
229            Expr::Index(spanned_expr) => {
230                // NOTE: We consider any non-literal, non-star index computed.
231                if !spanned_expr.is_literal() && !matches!(spanned_expr.inner, Expr::Star) {
232                    index_exprs.push(self);
233                }
234            }
235            Expr::Context(context) => {
236                for part in &context.parts {
237                    index_exprs.extend(part.computed_indices());
238                }
239            }
240            Expr::BinOp { lhs, op: _, rhs } => {
241                index_exprs.extend(lhs.computed_indices());
242                index_exprs.extend(rhs.computed_indices());
243            }
244            Expr::UnOp { 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<&SpannedExpr<'a>> {
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::BinOp { lhs, op: _, rhs } => {
281                subexprs.extend(lhs.constant_reducible_subexprs());
282                subexprs.extend(rhs.constant_reducible_subexprs());
283            }
284            Expr::UnOp { 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
308/// Represents a GitHub Actions expression.
309#[derive(Debug, PartialEq)]
310pub enum Expr<'src> {
311    /// A literal value.
312    Literal(Literal<'src>),
313    /// The `*` literal within an index or context.
314    Star,
315    /// A function call.
316    Call(Call<'src>),
317    /// A context identifier component, e.g. `github` in `github.actor`.
318    Identifier(Identifier<'src>),
319    /// A context index component, e.g. `[0]` in `foo[0]`.
320    Index(Box<SpannedExpr<'src>>),
321    /// A full context reference.
322    Context(Context<'src>),
323    /// A binary operation, either logical or arithmetic.
324    BinOp {
325        /// The LHS of the binop.
326        lhs: Box<SpannedExpr<'src>>,
327        /// The binary operator.
328        op: BinOp,
329        /// The RHS of the binop.
330        rhs: Box<SpannedExpr<'src>>,
331    },
332    /// A unary operation. Negation (`!`) is currently the only `UnOp`.
333    UnOp {
334        /// The unary operator.
335        op: UnOp,
336        /// The expression to apply the operator to.
337        expr: Box<SpannedExpr<'src>>,
338    },
339}
340
341impl<'src> Expr<'src> {
342    /// Convenience API for making an [`Expr::Identifier`].
343    fn ident(i: &'src str) -> Self {
344        Self::Identifier(Identifier(i))
345    }
346
347    /// Convenience API for making an [`Expr::Context`].
348    fn context(components: impl Into<Vec<SpannedExpr<'src>>>) -> Self {
349        Self::Context(Context::new(components))
350    }
351
352    /// Returns whether the expression is a literal.
353    pub fn is_literal(&self) -> bool {
354        matches!(self, Expr::Literal(_))
355    }
356
357    /// Returns whether the expression is constant reducible.
358    ///
359    /// "Constant reducible" is similar to "constant foldable" but with
360    /// meta-evaluation semantics: the expression `5` would not be
361    /// constant foldable in a normal program (because it's already
362    /// an atom), but is "constant reducible" in a GitHub Actions expression
363    /// because an expression containing it (e.g. `${{ 5 }}`) can be elided
364    /// entirely and replaced with `5`.
365    ///
366    /// There are three kinds of reducible expressions:
367    ///
368    /// 1. Literals, which reduce to their literal value;
369    /// 2. Binops/unops with reducible subexpressions, which reduce
370    ///    to their evaluation;
371    /// 3. Select function calls where the semantics of the function
372    ///    mean that reducible arguments make the call itself reducible.
373    ///
374    /// NOTE: This implementation is sound but not complete.
375    pub fn constant_reducible(&self) -> bool {
376        match self {
377            // Literals are always reducible.
378            Expr::Literal(_) => true,
379            // Binops are reducible if their LHS and RHS are reducible.
380            Expr::BinOp { lhs, op: _, rhs } => lhs.constant_reducible() && rhs.constant_reducible(),
381            // Unops are reducible if their interior expression is reducible.
382            Expr::UnOp { op: _, expr } => expr.constant_reducible(),
383            Expr::Call(Call { func, args }) => {
384                // These functions are reducible if their arguments are reducible.
385                // TODO(ww): `fromJSON` *is* frequently reducible, but
386                // doing so soundly with subexpressions is annoying.
387                // We overapproximate for now and consider it non-reducible.
388                if matches!(
389                    func,
390                    Function::Contains
391                        | Function::StartsWith
392                        | Function::EndsWith
393                        | Function::Format
394                        | Function::ToJSON
395                        | Function::Join // | Function::FromJSON
396                ) {
397                    args.iter().all(|e| e.constant_reducible())
398                } else {
399                    false
400                }
401            }
402            // Everything else is presumed non-reducible.
403            _ => false,
404        }
405    }
406
407    /// Parses the given string into an expression.
408    #[allow(clippy::unwrap_used)]
409    pub fn parse(expr: &'src str) -> Result<SpannedExpr<'src>, Error> {
410        // Top level `expression` is a single `or_expr`.
411        let or_expr = ExprParser::parse(Rule::expression, expr)?
412            .next()
413            .unwrap()
414            .into_inner()
415            .next()
416            .unwrap();
417
418        fn parse_pair(pair: Pair<'_, Rule>) -> Result<Box<SpannedExpr<'_>>, Error> {
419            // We're parsing a pest grammar, which isn't left-recursive.
420            // As a result, we have constructions like
421            // `or_expr = { and_expr ~ ("||" ~ and_expr)* }`, which
422            // result in wonky ASTs like one or many (>2) headed ORs.
423            // We turn these into sane looking ASTs by punching the single
424            // pairs down to their primitive type and folding the
425            // many-headed pairs appropriately.
426            // For example, `or_expr` matches the `1` one but punches through
427            // to `Number(1)`, and also matches `true || true || true` which
428            // becomes `BinOp(BinOp(true, true), true)`.
429
430            match pair.as_rule() {
431                Rule::or_expr => {
432                    let (span, raw) = (pair.as_span(), pair.as_str());
433                    let mut pairs = pair.into_inner();
434                    let lhs = parse_pair(pairs.next().unwrap())?;
435                    pairs.try_fold(lhs, |expr, next| {
436                        Ok(SpannedExpr::new(
437                            Origin::new(span.start()..span.end(), raw),
438                            Expr::BinOp {
439                                lhs: expr,
440                                op: BinOp::Or,
441                                rhs: parse_pair(next)?,
442                            },
443                        )
444                        .into())
445                    })
446                }
447                Rule::and_expr => {
448                    let (span, raw) = (pair.as_span(), pair.as_str());
449                    let mut pairs = pair.into_inner();
450                    let lhs = parse_pair(pairs.next().unwrap())?;
451                    pairs.try_fold(lhs, |expr, next| {
452                        Ok(SpannedExpr::new(
453                            Origin::new(span.start()..span.end(), raw),
454                            Expr::BinOp {
455                                lhs: expr,
456                                op: BinOp::And,
457                                rhs: parse_pair(next)?,
458                            },
459                        )
460                        .into())
461                    })
462                }
463                Rule::eq_expr => {
464                    // eq_expr matches both `==` and `!=` and captures
465                    // them in the `eq_op` capture, so we fold with
466                    // two-tuples of (eq_op, comp_expr).
467                    let (span, raw) = (pair.as_span(), pair.as_str());
468                    let mut pairs = pair.into_inner();
469                    let lhs = parse_pair(pairs.next().unwrap())?;
470
471                    let pair_chunks = pairs.chunks(2);
472                    pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
473                        let eq_op = next.next().unwrap();
474                        let comp_expr = next.next().unwrap();
475
476                        let eq_op = match eq_op.as_str() {
477                            "==" => BinOp::Eq,
478                            "!=" => BinOp::Neq,
479                            _ => unreachable!(),
480                        };
481
482                        Ok(SpannedExpr::new(
483                            Origin::new(span.start()..span.end(), raw),
484                            Expr::BinOp {
485                                lhs: expr,
486                                op: eq_op,
487                                rhs: parse_pair(comp_expr)?,
488                            },
489                        )
490                        .into())
491                    })
492                }
493                Rule::comp_expr => {
494                    // Same as eq_expr, but with comparison operators.
495                    let (span, raw) = (pair.as_span(), pair.as_str());
496                    let mut pairs = pair.into_inner();
497                    let lhs = parse_pair(pairs.next().unwrap())?;
498
499                    let pair_chunks = pairs.chunks(2);
500                    pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
501                        let comp_op = next.next().unwrap();
502                        let unary_expr = next.next().unwrap();
503
504                        let eq_op = match comp_op.as_str() {
505                            ">" => BinOp::Gt,
506                            ">=" => BinOp::Ge,
507                            "<" => BinOp::Lt,
508                            "<=" => BinOp::Le,
509                            _ => unreachable!(),
510                        };
511
512                        Ok(SpannedExpr::new(
513                            Origin::new(span.start()..span.end(), raw),
514                            Expr::BinOp {
515                                lhs: expr,
516                                op: eq_op,
517                                rhs: parse_pair(unary_expr)?,
518                            },
519                        )
520                        .into())
521                    })
522                }
523                Rule::unary_expr => {
524                    let (span, raw) = (pair.as_span(), pair.as_str());
525                    let mut pairs = pair.into_inner();
526                    let inner_pair = pairs.next().unwrap();
527
528                    match inner_pair.as_rule() {
529                        Rule::unary_op => Ok(SpannedExpr::new(
530                            Origin::new(span.start()..span.end(), raw),
531                            Expr::UnOp {
532                                op: UnOp::Not,
533                                expr: parse_pair(pairs.next().unwrap())?,
534                            },
535                        )
536                        .into()),
537                        Rule::primary_expr => parse_pair(inner_pair),
538                        _ => unreachable!(),
539                    }
540                }
541                Rule::primary_expr => {
542                    // Punt back to the top level match to keep things simple.
543                    parse_pair(pair.into_inner().next().unwrap())
544                }
545                Rule::number => Ok(SpannedExpr::new(
546                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
547                    parse_number(pair.as_str()).into(),
548                )
549                .into()),
550                Rule::string => {
551                    let (span, raw) = (pair.as_span(), pair.as_str());
552                    // string -> string_inner
553                    let string_inner = pair.into_inner().next().unwrap().as_str();
554
555                    // Optimization: if our string literal doesn't have any
556                    // escaped quotes in it, we can save ourselves a clone.
557                    if !string_inner.contains('\'') {
558                        Ok(SpannedExpr::new(
559                            Origin::new(span.start()..span.end(), raw),
560                            string_inner.into(),
561                        )
562                        .into())
563                    } else {
564                        Ok(SpannedExpr::new(
565                            Origin::new(span.start()..span.end(), raw),
566                            string_inner.replace("''", "'").into(),
567                        )
568                        .into())
569                    }
570                }
571                Rule::boolean => Ok(SpannedExpr::new(
572                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
573                    pair.as_str().parse::<bool>().unwrap().into(),
574                )
575                .into()),
576                Rule::null => Ok(SpannedExpr::new(
577                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
578                    Expr::Literal(Literal::Null),
579                )
580                .into()),
581                Rule::star => Ok(SpannedExpr::new(
582                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
583                    Expr::Star,
584                )
585                .into()),
586                Rule::function_call => {
587                    let (span, raw) = (pair.as_span(), pair.as_str());
588                    let mut pairs = pair.into_inner();
589
590                    let identifier = pairs.next().unwrap();
591                    let args = pairs
592                        .map(|pair| parse_pair(pair).map(|e| *e))
593                        .collect::<Result<_, _>>()?;
594
595                    let call = Call::new(identifier.as_str(), args)?;
596
597                    Ok(SpannedExpr::new(
598                        Origin::new(span.start()..span.end(), raw),
599                        Expr::Call(call),
600                    )
601                    .into())
602                }
603                Rule::identifier => Ok(SpannedExpr::new(
604                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
605                    Expr::ident(pair.as_str()),
606                )
607                .into()),
608                Rule::index => Ok(SpannedExpr::new(
609                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
610                    Expr::Index(parse_pair(pair.into_inner().next().unwrap())?),
611                )
612                .into()),
613                Rule::context => {
614                    let (span, raw) = (pair.as_span(), pair.as_str());
615                    let pairs = pair.into_inner();
616
617                    let mut inner: Vec<SpannedExpr> = pairs
618                        .map(|pair| parse_pair(pair).map(|e| *e))
619                        .collect::<Result<_, _>>()?;
620
621                    // The `context` rule wholly encloses `function_call`
622                    // and parenthesized expressions, so unwrap single-element
623                    // contexts for those to avoid unnecessary nesting.
624                    // Bare identifiers are kept as `Context` since they
625                    // represent genuine context references (e.g. `github`).
626                    if inner.len() == 1 && !matches!(inner[0].inner, Expr::Identifier(_)) {
627                        Ok(inner.remove(0).into())
628                    } else {
629                        Ok(SpannedExpr::new(
630                            Origin::new(span.start()..span.end(), raw),
631                            Expr::context(inner),
632                        )
633                        .into())
634                    }
635                }
636                r => panic!("unrecognized rule: {r:?}"),
637            }
638        }
639
640        parse_pair(or_expr).map(|e| *e)
641    }
642}
643
644impl<'src> From<&'src str> for Expr<'src> {
645    fn from(s: &'src str) -> Self {
646        Expr::Literal(Literal::String(s.into()))
647    }
648}
649
650impl From<String> for Expr<'_> {
651    fn from(s: String) -> Self {
652        Expr::Literal(Literal::String(s.into()))
653    }
654}
655
656impl From<f64> for Expr<'_> {
657    fn from(n: f64) -> Self {
658        Expr::Literal(Literal::Number(n))
659    }
660}
661
662impl From<bool> for Expr<'_> {
663    fn from(b: bool) -> Self {
664        Expr::Literal(Literal::Boolean(b))
665    }
666}
667
668/// The result of evaluating a GitHub Actions expression.
669///
670/// This type represents the possible values that can result from evaluating
671/// GitHub Actions expressions.
672#[derive(Debug, Clone, PartialEq)]
673pub enum Evaluation {
674    /// A string value (includes both string literals and stringified other types).
675    String(String),
676    /// A numeric value.
677    Number(f64),
678    /// A boolean value.
679    Boolean(bool),
680    /// The null value.
681    Null,
682    /// An array value. Array evaluations can only be realized through `fromJSON`.
683    Array(Vec<Evaluation>),
684    /// An object value. Object evaluations can only be realized through `fromJSON`.
685    Object(std::collections::HashMap<String, Evaluation>),
686}
687
688impl TryFrom<serde_json::Value> for Evaluation {
689    type Error = ();
690
691    fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
692        match value {
693            serde_json::Value::Null => Ok(Evaluation::Null),
694            serde_json::Value::Bool(b) => Ok(Evaluation::Boolean(b)),
695            serde_json::Value::Number(n) => {
696                if let Some(f) = n.as_f64() {
697                    Ok(Evaluation::Number(f))
698                } else {
699                    Err(())
700                }
701            }
702            serde_json::Value::String(s) => Ok(Evaluation::String(s)),
703            serde_json::Value::Array(arr) => {
704                let elements = arr
705                    .into_iter()
706                    .map(|elem| elem.try_into())
707                    .collect::<Result<_, _>>()?;
708                Ok(Evaluation::Array(elements))
709            }
710            serde_json::Value::Object(obj) => {
711                let mut map = std::collections::HashMap::new();
712                for (key, value) in obj {
713                    map.insert(key, value.try_into()?);
714                }
715                Ok(Evaluation::Object(map))
716            }
717        }
718    }
719}
720
721impl TryInto<serde_json::Value> for Evaluation {
722    type Error = ();
723
724    fn try_into(self) -> Result<serde_json::Value, Self::Error> {
725        match self {
726            Evaluation::Null => Ok(serde_json::Value::Null),
727            Evaluation::Boolean(b) => Ok(serde_json::Value::Bool(b)),
728            Evaluation::Number(n) => {
729                // NOTE: serde_json has different internal representations
730                // for integers and floats, so we need to handle both cases
731                // to ensure we serialize integers without a decimal point.
732                if n.fract() == 0.0 {
733                    Ok(serde_json::Value::Number(serde_json::Number::from(
734                        n as i64,
735                    )))
736                } else if let Some(num) = serde_json::Number::from_f64(n) {
737                    Ok(serde_json::Value::Number(num))
738                } else {
739                    Err(())
740                }
741            }
742            Evaluation::String(s) => Ok(serde_json::Value::String(s)),
743            Evaluation::Array(arr) => {
744                let elements = arr
745                    .into_iter()
746                    .map(|elem| elem.try_into())
747                    .collect::<Result<_, _>>()?;
748                Ok(serde_json::Value::Array(elements))
749            }
750            Evaluation::Object(obj) => {
751                let mut map = serde_json::Map::new();
752                for (key, value) in obj {
753                    map.insert(key, value.try_into()?);
754                }
755                Ok(serde_json::Value::Object(map))
756            }
757        }
758    }
759}
760
761impl Evaluation {
762    /// Convert to a boolean following GitHub Actions truthiness rules.
763    ///
764    /// GitHub Actions truthiness:
765    /// - false and null are falsy
766    /// - Numbers: 0 and NaN are falsy, everything else is truthy
767    /// - Strings: empty string is falsy, everything else is truthy
768    /// - Arrays and dictionaries are always truthy (non-empty objects)
769    pub fn as_boolean(&self) -> bool {
770        match self {
771            Evaluation::Boolean(b) => *b,
772            Evaluation::Null => false,
773            Evaluation::Number(n) => *n != 0.0 && !n.is_nan(),
774            Evaluation::String(s) => !s.is_empty(),
775            // Arrays and objects are always truthy, even if empty.
776            Evaluation::Array(_) | Evaluation::Object(_) => true,
777        }
778    }
779
780    /// Convert to a number following GitHub Actions conversion rules.
781    ///
782    /// See: <https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators>
783    pub fn as_number(&self) -> f64 {
784        match self {
785            Evaluation::String(s) => parse_number(s),
786            Evaluation::Number(n) => *n,
787            Evaluation::Boolean(b) => {
788                if *b {
789                    1.0
790                } else {
791                    0.0
792                }
793            }
794            Evaluation::Null => 0.0,
795            Evaluation::Array(_) | Evaluation::Object(_) => f64::NAN,
796        }
797    }
798
799    /// Returns a wrapper around this evaluation that implements
800    /// GitHub Actions evaluation semantics.
801    pub fn sema(&self) -> EvaluationSema<'_> {
802        EvaluationSema(self)
803    }
804}
805
806/// Parse a string into a number following GitHub Actions coercion rules.
807///
808/// The string is trimmed and then parsed following the rules from the
809/// GitHub Action Runner:
810/// https://github.com/actions/runner/blob/9426c35fdaf2b2e00c3ef751a15c04fa8e2a9582/src/Sdk/Expressions/Sdk/ExpressionUtility.cs#L223
811fn parse_number(s: &str) -> f64 {
812    let trimmed = s.trim();
813    if trimmed.is_empty() {
814        return 0.0;
815    }
816
817    // Decimal / scientific notation first
818    // Only accept finite results; infinity/NaN literals fall through.
819    if let Ok(value) = trimmed.parse::<f64>()
820        && value.is_finite()
821    {
822        return value;
823    }
824
825    // Hex: signed 32-bit.
826    // Values 0x80000000–0xFFFFFFFF wrap negative via two's complement.
827    if let Some(hex_digits) = trimmed.strip_prefix("0x") {
828        return u32::from_str_radix(hex_digits, 16)
829            .map(|n| (n as i32) as f64)
830            .unwrap_or(f64::NAN);
831    }
832
833    // Octal: signed 32-bit.
834    if let Some(oct_digits) = trimmed.strip_prefix("0o") {
835        return u32::from_str_radix(oct_digits, 8)
836            .map(|n| (n as i32) as f64)
837            .unwrap_or(f64::NAN);
838    }
839
840    // Explicit Infinity check — GH runner accepts full "infinity"
841    // (case-insensitive) but NOT the "inf" abbreviation.
842    let after_sign = trimmed
843        .strip_prefix(['+', '-'].as_slice())
844        .unwrap_or(trimmed);
845    if after_sign.eq_ignore_ascii_case("infinity") {
846        return if trimmed.starts_with('-') {
847            f64::NEG_INFINITY
848        } else {
849            f64::INFINITY
850        };
851    }
852
853    f64::NAN
854}
855
856/// A wrapper around `Evaluation` that implements GitHub Actions
857/// various evaluation semantics (comparison, stringification, etc.).
858pub struct EvaluationSema<'a>(&'a Evaluation);
859
860impl EvaluationSema<'_> {
861    /// Converts a string to its uppercase form using GitHub Actions'
862    /// special rules.
863    /// See `toUpperSpecial`:
864    /// <https://github.com/actions/languageservices/blob/cc316ab/expressions/src/result.ts#L209>
865    fn upper_special(value: &str) -> String {
866        // Uppercase everything except the small dotless-ı (U+0131),
867        // which GitHub Actions preserves as-is.
868        let mut result = String::with_capacity(value.len());
869        let mut parts = value.split('ı');
870        if let Some(first) = parts.next() {
871            result.extend(first.chars().flat_map(char::to_uppercase));
872        }
873        for part in parts {
874            result.push('ı');
875            result.extend(part.chars().flat_map(char::to_uppercase));
876        }
877        result
878    }
879}
880
881impl PartialEq for EvaluationSema<'_> {
882    fn eq(&self, other: &Self) -> bool {
883        match (self.0, other.0) {
884            (Evaluation::Null, Evaluation::Null) => true,
885            (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a == b,
886            (Evaluation::Number(a), Evaluation::Number(b)) => a == b,
887            // GitHub Actions string comparisons are case-insensitive.
888            (Evaluation::String(a), Evaluation::String(b)) => {
889                Self::upper_special(a) == Self::upper_special(b)
890            }
891            // Coercion rules: all others convert to number and compare.
892            (a, b) => a.as_number() == b.as_number(),
893        }
894    }
895}
896
897impl PartialOrd for EvaluationSema<'_> {
898    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
899        match (self.0, other.0) {
900            (Evaluation::Null, Evaluation::Null) => Some(std::cmp::Ordering::Equal),
901            (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a.partial_cmp(b),
902            (Evaluation::Number(a), Evaluation::Number(b)) => a.partial_cmp(b),
903            (Evaluation::String(a), Evaluation::String(b)) => {
904                // GitHub Actions string comparisons are case-insensitive.
905                Self::upper_special(a).partial_cmp(&Self::upper_special(b))
906            }
907            // Coercion rules: all others convert to number and compare.
908            (a, b) => a.as_number().partial_cmp(&b.as_number()),
909        }
910    }
911}
912
913impl std::fmt::Display for EvaluationSema<'_> {
914    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
915        match self.0 {
916            Evaluation::String(s) => write!(f, "{}", s),
917            Evaluation::Number(n) => {
918                // Format numbers like GitHub Actions does
919                if n == &f64::INFINITY {
920                    write!(f, "Infinity")
921                } else if n == &f64::NEG_INFINITY {
922                    write!(f, "-Infinity")
923                } else {
924                    // Format with 15 decimal places, parse back to f64 to
925                    // clean up trailing noise, then format normally.
926                    // See: https://github.com/actions/languageservices/blob/cc316ab/expressions/src/data/number.ts#L10
927                    let rounded: f64 = format!("{:.15}", n)
928                        .parse()
929                        .expect("impossible f64 round-trip error");
930                    if rounded.fract() == 0.0 {
931                        write!(f, "{}", rounded as i64)
932                    } else {
933                        write!(f, "{}", rounded)
934                    }
935                }
936            }
937            Evaluation::Boolean(b) => write!(f, "{}", b),
938            Evaluation::Null => write!(f, ""),
939            Evaluation::Array(_) => write!(f, "Array"),
940            Evaluation::Object(_) => write!(f, "Object"),
941        }
942    }
943}
944
945impl<'src> Expr<'src> {
946    /// Evaluates a constant-reducible expression to its literal value.
947    ///
948    /// Returns `Some(Evaluation)` if the expression can be constant-evaluated,
949    /// or `None` if the expression contains non-constant elements (like contexts or
950    /// non-reducible function calls).
951    ///
952    /// This implementation follows GitHub Actions' evaluation semantics as documented at:
953    /// https://docs.github.com/en/actions/reference/workflows-and-actions/expressions
954    ///
955    /// # Examples
956    ///
957    /// ```
958    /// use github_actions_expressions::{Expr, Evaluation};
959    ///
960    /// let expr = Expr::parse("'hello'").unwrap();
961    /// let result = expr.consteval().unwrap();
962    /// assert_eq!(result.sema().to_string(), "hello");
963    ///
964    /// let expr = Expr::parse("true && false").unwrap();
965    /// let result = expr.consteval().unwrap();
966    /// assert_eq!(result, Evaluation::Boolean(false));
967    /// ```
968    pub fn consteval(&self) -> Option<Evaluation> {
969        match self {
970            Expr::Literal(literal) => Some(literal.consteval()),
971
972            Expr::BinOp { lhs, op, rhs } => {
973                let lhs_val = lhs.consteval()?;
974                let rhs_val = rhs.consteval()?;
975
976                match op {
977                    BinOp::And => {
978                        // GitHub Actions && semantics: if LHS is falsy, return LHS, else return RHS
979                        if lhs_val.as_boolean() {
980                            Some(rhs_val)
981                        } else {
982                            Some(lhs_val)
983                        }
984                    }
985                    BinOp::Or => {
986                        // GitHub Actions || semantics: if LHS is truthy, return LHS, else return RHS
987                        if lhs_val.as_boolean() {
988                            Some(lhs_val)
989                        } else {
990                            Some(rhs_val)
991                        }
992                    }
993                    BinOp::Eq => Some(Evaluation::Boolean(lhs_val.sema() == rhs_val.sema())),
994                    BinOp::Neq => Some(Evaluation::Boolean(lhs_val.sema() != rhs_val.sema())),
995                    BinOp::Lt => Some(Evaluation::Boolean(lhs_val.sema() < rhs_val.sema())),
996                    BinOp::Le => Some(Evaluation::Boolean(lhs_val.sema() <= rhs_val.sema())),
997                    BinOp::Gt => Some(Evaluation::Boolean(lhs_val.sema() > rhs_val.sema())),
998                    BinOp::Ge => Some(Evaluation::Boolean(lhs_val.sema() >= rhs_val.sema())),
999                }
1000            }
1001
1002            Expr::UnOp { op, expr } => {
1003                let val = expr.consteval()?;
1004                match op {
1005                    UnOp::Not => Some(Evaluation::Boolean(!val.as_boolean())),
1006                }
1007            }
1008
1009            Expr::Call(call) => call.consteval(),
1010
1011            // Non-constant expressions
1012            _ => None,
1013        }
1014    }
1015}
1016
1017#[cfg(test)]
1018mod tests {
1019    use std::borrow::Cow;
1020
1021    use pest::Parser as _;
1022    use pretty_assertions::assert_eq;
1023
1024    use crate::{Call, Error, Literal, Origin, SpannedExpr};
1025
1026    use super::{BinOp, Expr, ExprParser, Function, Rule, UnOp};
1027
1028    #[test]
1029    fn test_literal_string_borrows() {
1030        let cases = &[
1031            ("'foo'", true),
1032            ("'foo bar'", true),
1033            ("'foo '' bar'", false),
1034            ("'foo''bar'", false),
1035            ("'foo''''bar'", false),
1036        ];
1037
1038        for (expr, borrows) in cases {
1039            let Expr::Literal(Literal::String(s)) = &*Expr::parse(expr).unwrap() else {
1040                panic!("expected a literal string expression for {expr}");
1041            };
1042
1043            assert!(matches!(
1044                (s, borrows),
1045                (Cow::Borrowed(_), true) | (Cow::Owned(_), false)
1046            ));
1047        }
1048    }
1049
1050    #[test]
1051    fn test_literal_as_str() {
1052        let cases = &[
1053            ("'foo'", "foo"),
1054            ("'foo '' bar'", "foo ' bar"),
1055            ("123", "123"),
1056            ("123.000", "123"),
1057            ("0.0", "0"),
1058            ("0.1", "0.1"),
1059            ("0.12345", "0.12345"),
1060            ("true", "true"),
1061            ("false", "false"),
1062            ("null", "null"),
1063        ];
1064
1065        for (expr, expected) in cases {
1066            let Expr::Literal(expr) = &*Expr::parse(expr).unwrap() else {
1067                panic!("expected a literal expression for {expr}");
1068            };
1069
1070            assert_eq!(expr.as_str(), *expected);
1071        }
1072    }
1073
1074    #[test]
1075    fn test_parse_string_rule() {
1076        let cases = &[
1077            ("''", ""),
1078            ("' '", " "),
1079            ("''''", "''"),
1080            ("'test'", "test"),
1081            ("'spaces are ok'", "spaces are ok"),
1082            ("'escaping '' works'", "escaping '' works"),
1083        ];
1084
1085        for (case, expected) in cases {
1086            let s = ExprParser::parse(Rule::string, case)
1087                .unwrap()
1088                .next()
1089                .unwrap();
1090
1091            assert_eq!(s.into_inner().next().unwrap().as_str(), *expected);
1092        }
1093    }
1094
1095    #[test]
1096    fn test_parse_context_rule() {
1097        let cases = &[
1098            "foo.bar",
1099            "github.action_path",
1100            "inputs.foo-bar",
1101            "inputs.also--valid",
1102            "inputs.this__too",
1103            "inputs.this__too",
1104            "secrets.GH_TOKEN",
1105            "foo.*.bar",
1106            "github.event.issue.labels.*.name",
1107        ];
1108
1109        for case in cases {
1110            assert_eq!(
1111                ExprParser::parse(Rule::context, case)
1112                    .unwrap()
1113                    .next()
1114                    .unwrap()
1115                    .as_str(),
1116                *case
1117            );
1118        }
1119    }
1120
1121    #[test]
1122    fn test_parse_call_rule() {
1123        let cases = &[
1124            "foo()",
1125            "foo(bar)",
1126            "foo(bar())",
1127            "foo(1.23)",
1128            "foo(1,2)",
1129            "foo(1, 2)",
1130            "foo(1, 2, secret.GH_TOKEN)",
1131            "foo(   )",
1132            "fromJSON(inputs.free-threading)",
1133        ];
1134
1135        for case in cases {
1136            assert_eq!(
1137                ExprParser::parse(Rule::function_call, case)
1138                    .unwrap()
1139                    .next()
1140                    .unwrap()
1141                    .as_str(),
1142                *case
1143            );
1144        }
1145    }
1146
1147    #[test]
1148    fn test_parse_expr_rule() -> Result<(), Error> {
1149        // Ensures that we parse multi-line expressions correctly.
1150        let multiline = "github.repository_owner == 'Homebrew' &&
1151        ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
1152        (github.event_name == 'pull_request_target' &&
1153        (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
1154
1155        let multiline2 = "foo.bar.baz[
1156        0
1157        ]";
1158
1159        let cases = &[
1160            "true",
1161            "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
1162            "foo || bar || baz",
1163            "foo || bar && baz || foo && 1 && 2 && 3 || 4",
1164            "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
1165            "(true || false) == true",
1166            "!(!true || false)",
1167            "!(!true || false) == true",
1168            "(true == false) == true",
1169            "(true == (false || true && (true || false))) == true",
1170            "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
1171            "foo()[0]",
1172            "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
1173            multiline,
1174            "'a' == 'b' && 'c' || 'd'",
1175            "github.event['a']",
1176            "github.event['a' == 'b']",
1177            "github.event['a' == 'b' && 'c' || 'd']",
1178            "github['event']['inputs']['dry-run']",
1179            "github[format('{0}', 'event')]",
1180            "github['event']['inputs'][github.event.inputs.magic]",
1181            "github['event']['inputs'].*",
1182            "1 == 1",
1183            "1 > 1",
1184            "1 >= 1",
1185            "matrix.node_version >= 20",
1186            "true||false",
1187            // Hex literals
1188            "0xFF",
1189            "0xff",
1190            "0x0",
1191            "0xFF == 255",
1192            // Octal literals
1193            "0o10",
1194            "0o77",
1195            "0o0",
1196            // Scientific notation
1197            "1e2",
1198            "1.5E-3",
1199            "1.2e+2",
1200            "5e0",
1201            // NaN and Infinity literals
1202            "NaN",
1203            "Infinity",
1204            "+Infinity",
1205            "-Infinity",
1206            "NaN == NaN",
1207            "Infinity == Infinity",
1208            // Parenthesized compound expressions
1209            "2 <= (3 == true)",
1210            "0 > (0 < 1)",
1211            "(foo || bar) == baz",
1212            // Signed numbers
1213            "+42",
1214            "-42",
1215            // Leading/trailing dot
1216            ".5",
1217            "123.",
1218            // Whitespace handling
1219            multiline2,
1220            "fromJSON( github.event.inputs.hmm ) [ 0 ]",
1221            // Parens around a call
1222            "(fromJson('{\"one\": \"one val\"}')).one",
1223            "(fromJson('[\"one\", \"two\"]'))[1]",
1224        ];
1225
1226        for case in cases {
1227            assert_eq!(
1228                ExprParser::parse(Rule::expression, case)?
1229                    .next()
1230                    .unwrap()
1231                    .as_str(),
1232                *case
1233            );
1234        }
1235
1236        Ok(())
1237    }
1238
1239    #[test]
1240    fn test_parse_expr_rule_rejects() {
1241        let cases = &[
1242            // "Inf" is not a valid number form; only "Infinity" is accepted.
1243            "-Inf", "+Inf",
1244        ];
1245
1246        for case in cases {
1247            assert!(
1248                ExprParser::parse(Rule::expression, case).is_err(),
1249                "{case:?} should not parse as a valid expression"
1250            );
1251        }
1252    }
1253
1254    #[test]
1255    fn test_parse() {
1256        let cases = &[
1257            (
1258                "!true || false || true",
1259                SpannedExpr::new(
1260                    Origin::new(0..22, "!true || false || true"),
1261                    Expr::BinOp {
1262                        lhs: SpannedExpr::new(
1263                            Origin::new(0..22, "!true || false || true"),
1264                            Expr::BinOp {
1265                                lhs: SpannedExpr::new(
1266                                    Origin::new(0..5, "!true"),
1267                                    Expr::UnOp {
1268                                        op: UnOp::Not,
1269                                        expr: SpannedExpr::new(
1270                                            Origin::new(1..5, "true"),
1271                                            true.into(),
1272                                        )
1273                                        .into(),
1274                                    },
1275                                )
1276                                .into(),
1277                                op: BinOp::Or,
1278                                rhs: SpannedExpr::new(Origin::new(9..14, "false"), false.into())
1279                                    .into(),
1280                            },
1281                        )
1282                        .into(),
1283                        op: BinOp::Or,
1284                        rhs: SpannedExpr::new(Origin::new(18..22, "true"), true.into()).into(),
1285                    },
1286                ),
1287            ),
1288            (
1289                "'foo '' bar'",
1290                SpannedExpr::new(
1291                    Origin::new(0..12, "'foo '' bar'"),
1292                    Expr::Literal(Literal::String("foo ' bar".into())),
1293                ),
1294            ),
1295            (
1296                "('foo '' bar')",
1297                SpannedExpr::new(
1298                    Origin::new(1..13, "'foo '' bar'"),
1299                    Expr::Literal(Literal::String("foo ' bar".into())),
1300                ),
1301            ),
1302            (
1303                "((('foo '' bar')))",
1304                SpannedExpr::new(
1305                    Origin::new(3..15, "'foo '' bar'"),
1306                    Expr::Literal(Literal::String("foo ' bar".into())),
1307                ),
1308            ),
1309            (
1310                "format('{0} {1}', 2, 3)",
1311                SpannedExpr::new(
1312                    Origin::new(0..23, "format('{0} {1}', 2, 3)"),
1313                    Expr::Call(Call {
1314                        func: Function::Format,
1315                        args: vec![
1316                            SpannedExpr::new(Origin::new(7..16, "'{0} {1}'"), "{0} {1}".into()),
1317                            SpannedExpr::new(Origin::new(18..19, "2"), 2.0.into()),
1318                            SpannedExpr::new(Origin::new(21..22, "3"), 3.0.into()),
1319                        ],
1320                    }),
1321                ),
1322            ),
1323            (
1324                "foo.bar.baz",
1325                SpannedExpr::new(
1326                    Origin::new(0..11, "foo.bar.baz"),
1327                    Expr::context(vec![
1328                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1329                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1330                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1331                    ]),
1332                ),
1333            ),
1334            (
1335                "foo.bar.baz[1][2]",
1336                SpannedExpr::new(
1337                    Origin::new(0..17, "foo.bar.baz[1][2]"),
1338                    Expr::context(vec![
1339                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1340                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1341                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1342                        SpannedExpr::new(
1343                            Origin::new(11..14, "[1]"),
1344                            Expr::Index(Box::new(SpannedExpr::new(
1345                                Origin::new(12..13, "1"),
1346                                1.0.into(),
1347                            ))),
1348                        ),
1349                        SpannedExpr::new(
1350                            Origin::new(14..17, "[2]"),
1351                            Expr::Index(Box::new(SpannedExpr::new(
1352                                Origin::new(15..16, "2"),
1353                                2.0.into(),
1354                            ))),
1355                        ),
1356                    ]),
1357                ),
1358            ),
1359            (
1360                "foo.bar.baz[*]",
1361                SpannedExpr::new(
1362                    Origin::new(0..14, "foo.bar.baz[*]"),
1363                    Expr::context([
1364                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1365                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1366                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1367                        SpannedExpr::new(
1368                            Origin::new(11..14, "[*]"),
1369                            Expr::Index(Box::new(SpannedExpr::new(
1370                                Origin::new(12..13, "*"),
1371                                Expr::Star,
1372                            ))),
1373                        ),
1374                    ]),
1375                ),
1376            ),
1377            (
1378                "vegetables.*.ediblePortions",
1379                SpannedExpr::new(
1380                    Origin::new(0..27, "vegetables.*.ediblePortions"),
1381                    Expr::context(vec![
1382                        SpannedExpr::new(
1383                            Origin::new(0..10, "vegetables"),
1384                            Expr::ident("vegetables"),
1385                        ),
1386                        SpannedExpr::new(Origin::new(11..12, "*"), Expr::Star),
1387                        SpannedExpr::new(
1388                            Origin::new(13..27, "ediblePortions"),
1389                            Expr::ident("ediblePortions"),
1390                        ),
1391                    ]),
1392                ),
1393            ),
1394            (
1395                // Sanity check for our associativity: the top level Expr here
1396                // should be `BinOp::Or`.
1397                "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1398                SpannedExpr::new(
1399                    Origin::new(
1400                        0..88,
1401                        "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1402                    ),
1403                    Expr::BinOp {
1404                        lhs: Box::new(SpannedExpr::new(
1405                            Origin::new(
1406                                0..59,
1407                                "github.ref == 'refs/heads/main' && 'value_for_main_branch'",
1408                            ),
1409                            Expr::BinOp {
1410                                lhs: Box::new(SpannedExpr::new(
1411                                    Origin::new(0..32, "github.ref == 'refs/heads/main'"),
1412                                    Expr::BinOp {
1413                                        lhs: Box::new(SpannedExpr::new(
1414                                            Origin::new(0..10, "github.ref"),
1415                                            Expr::context(vec![
1416                                                SpannedExpr::new(
1417                                                    Origin::new(0..6, "github"),
1418                                                    Expr::ident("github"),
1419                                                ),
1420                                                SpannedExpr::new(
1421                                                    Origin::new(7..10, "ref"),
1422                                                    Expr::ident("ref"),
1423                                                ),
1424                                            ]),
1425                                        )),
1426                                        op: BinOp::Eq,
1427                                        rhs: Box::new(SpannedExpr::new(
1428                                            Origin::new(14..31, "'refs/heads/main'"),
1429                                            Expr::Literal(Literal::String(
1430                                                "refs/heads/main".into(),
1431                                            )),
1432                                        )),
1433                                    },
1434                                )),
1435                                op: BinOp::And,
1436                                rhs: Box::new(SpannedExpr::new(
1437                                    Origin::new(35..58, "'value_for_main_branch'"),
1438                                    Expr::Literal(Literal::String("value_for_main_branch".into())),
1439                                )),
1440                            },
1441                        )),
1442                        op: BinOp::Or,
1443                        rhs: Box::new(SpannedExpr::new(
1444                            Origin::new(62..88, "'value_for_other_branches'"),
1445                            Expr::Literal(Literal::String("value_for_other_branches".into())),
1446                        )),
1447                    },
1448                ),
1449            ),
1450            (
1451                "(true || false) == true",
1452                SpannedExpr::new(
1453                    Origin::new(0..23, "(true || false) == true"),
1454                    Expr::BinOp {
1455                        lhs: Box::new(SpannedExpr::new(
1456                            Origin::new(1..14, "true || false"),
1457                            Expr::BinOp {
1458                                lhs: Box::new(SpannedExpr::new(
1459                                    Origin::new(1..5, "true"),
1460                                    true.into(),
1461                                )),
1462                                op: BinOp::Or,
1463                                rhs: Box::new(SpannedExpr::new(
1464                                    Origin::new(9..14, "false"),
1465                                    false.into(),
1466                                )),
1467                            },
1468                        )),
1469                        op: BinOp::Eq,
1470                        rhs: Box::new(SpannedExpr::new(Origin::new(19..23, "true"), true.into())),
1471                    },
1472                ),
1473            ),
1474            (
1475                "!(!true || false)",
1476                SpannedExpr::new(
1477                    Origin::new(0..17, "!(!true || false)"),
1478                    Expr::UnOp {
1479                        op: UnOp::Not,
1480                        expr: Box::new(SpannedExpr::new(
1481                            Origin::new(2..16, "!true || false"),
1482                            Expr::BinOp {
1483                                lhs: Box::new(SpannedExpr::new(
1484                                    Origin::new(2..7, "!true"),
1485                                    Expr::UnOp {
1486                                        op: UnOp::Not,
1487                                        expr: Box::new(SpannedExpr::new(
1488                                            Origin::new(3..7, "true"),
1489                                            true.into(),
1490                                        )),
1491                                    },
1492                                )),
1493                                op: BinOp::Or,
1494                                rhs: Box::new(SpannedExpr::new(
1495                                    Origin::new(11..16, "false"),
1496                                    false.into(),
1497                                )),
1498                            },
1499                        )),
1500                    },
1501                ),
1502            ),
1503            (
1504                "foobar[format('{0}', 'event')]",
1505                SpannedExpr::new(
1506                    Origin::new(0..30, "foobar[format('{0}', 'event')]"),
1507                    Expr::context([
1508                        SpannedExpr::new(Origin::new(0..6, "foobar"), Expr::ident("foobar")),
1509                        SpannedExpr::new(
1510                            Origin::new(6..30, "[format('{0}', 'event')]"),
1511                            Expr::Index(Box::new(SpannedExpr::new(
1512                                Origin::new(7..29, "format('{0}', 'event')"),
1513                                Expr::Call(Call {
1514                                    func: Function::Format,
1515                                    args: vec![
1516                                        SpannedExpr::new(
1517                                            Origin::new(14..19, "'{0}'"),
1518                                            Expr::from("{0}"),
1519                                        ),
1520                                        SpannedExpr::new(
1521                                            Origin::new(21..28, "'event'"),
1522                                            Expr::from("event"),
1523                                        ),
1524                                    ],
1525                                }),
1526                            ))),
1527                        ),
1528                    ]),
1529                ),
1530            ),
1531            (
1532                "github.actor_id == '49699333'",
1533                SpannedExpr::new(
1534                    Origin::new(0..29, "github.actor_id == '49699333'"),
1535                    Expr::BinOp {
1536                        lhs: SpannedExpr::new(
1537                            Origin::new(0..15, "github.actor_id"),
1538                            Expr::context(vec![
1539                                SpannedExpr::new(
1540                                    Origin::new(0..6, "github"),
1541                                    Expr::ident("github"),
1542                                ),
1543                                SpannedExpr::new(
1544                                    Origin::new(7..15, "actor_id"),
1545                                    Expr::ident("actor_id"),
1546                                ),
1547                            ]),
1548                        )
1549                        .into(),
1550                        op: BinOp::Eq,
1551                        rhs: Box::new(SpannedExpr::new(
1552                            Origin::new(19..29, "'49699333'"),
1553                            Expr::from("49699333"),
1554                        )),
1555                    },
1556                ),
1557            ),
1558            // Parenthesized call with index access
1559            (
1560                "(fromJSON('[]'))[1]",
1561                SpannedExpr::new(
1562                    Origin::new(0..19, "(fromJSON('[]'))[1]"),
1563                    Expr::context(vec![
1564                        SpannedExpr::new(
1565                            Origin::new(1..15, "fromJSON('[]')"),
1566                            Expr::Call(Call {
1567                                func: Function::FromJSON,
1568                                args: vec![SpannedExpr::new(
1569                                    Origin::new(10..14, "'[]'"),
1570                                    Expr::from("[]"),
1571                                )],
1572                            }),
1573                        ),
1574                        SpannedExpr::new(
1575                            Origin::new(16..19, "[1]"),
1576                            Expr::Index(Box::new(SpannedExpr::new(
1577                                Origin::new(17..18, "1"),
1578                                1.0.into(),
1579                            ))),
1580                        ),
1581                    ]),
1582                ),
1583            ),
1584        ];
1585
1586        for (case, expr) in cases {
1587            assert_eq!(*expr, Expr::parse(case).unwrap());
1588        }
1589    }
1590
1591    #[test]
1592    fn test_expr_constant_reducible() -> Result<(), Error> {
1593        for (expr, reducible) in &[
1594            ("'foo'", true),
1595            ("1", true),
1596            ("true", true),
1597            ("null", true),
1598            // boolean and unary expressions of all literals are
1599            // always reducible.
1600            ("!true", true),
1601            ("!null", true),
1602            ("true && false", true),
1603            ("true || false", true),
1604            ("null && !null && true", true),
1605            // formats/contains/startsWith/endsWith are reducible
1606            // if all of their arguments are reducible.
1607            ("format('{0} {1}', 'foo', 'bar')", true),
1608            ("format('{0} {1}', 1, 2)", true),
1609            ("format('{0} {1}', 1, '2')", true),
1610            ("contains('foo', 'bar')", true),
1611            ("startsWith('foo', 'bar')", true),
1612            ("endsWith('foo', 'bar')", true),
1613            ("startsWith(some.context, 'bar')", false),
1614            ("endsWith(some.context, 'bar')", false),
1615            // Nesting works as long as the nested call is also reducible.
1616            ("format('{0} {1}', '1', format('{0}', null))", true),
1617            ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
1618            ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
1619            ("foo", false),
1620            ("foo.bar", false),
1621            ("foo.bar[1]", false),
1622            ("foo.bar == 'bar'", false),
1623            ("foo.bar || bar || baz", false),
1624            ("foo.bar && bar && baz", false),
1625        ] {
1626            let expr = Expr::parse(expr)?;
1627            assert_eq!(expr.constant_reducible(), *reducible);
1628        }
1629
1630        Ok(())
1631    }
1632
1633    #[test]
1634    fn test_evaluate_constant_complex_expressions() -> Result<(), Error> {
1635        use crate::Evaluation;
1636
1637        let test_cases = &[
1638            // Nested operations
1639            ("!false", Evaluation::Boolean(true)),
1640            ("!true", Evaluation::Boolean(false)),
1641            ("!(true && false)", Evaluation::Boolean(true)),
1642            // Complex boolean logic
1643            ("true && (false || true)", Evaluation::Boolean(true)),
1644            ("false || (true && false)", Evaluation::Boolean(false)),
1645            // Mixed function calls
1646            (
1647                "contains(format('{0} {1}', 'hello', 'world'), 'world')",
1648                Evaluation::Boolean(true),
1649            ),
1650            (
1651                "startsWith(format('prefix_{0}', 'test'), 'prefix')",
1652                Evaluation::Boolean(true),
1653            ),
1654        ];
1655
1656        for (expr_str, expected) in test_cases {
1657            let expr = Expr::parse(expr_str)?;
1658            let result = expr.consteval().unwrap();
1659            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1660        }
1661
1662        Ok(())
1663    }
1664
1665    #[test]
1666    fn test_case_insensitive_string_comparison() -> Result<(), Error> {
1667        use crate::Evaluation;
1668
1669        let test_cases = &[
1670            // == is case-insensitive for strings
1671            ("'hello' == 'hello'", Evaluation::Boolean(true)),
1672            ("'hello' == 'HELLO'", Evaluation::Boolean(true)),
1673            ("'Hello' == 'hELLO'", Evaluation::Boolean(true)),
1674            ("'abc' == 'def'", Evaluation::Boolean(false)),
1675            // != is case-insensitive for strings
1676            ("'hello' != 'HELLO'", Evaluation::Boolean(false)),
1677            ("'abc' != 'def'", Evaluation::Boolean(true)),
1678            // Comparison operators are case-insensitive for strings
1679            ("'abc' < 'DEF'", Evaluation::Boolean(true)),
1680            ("'ABC' < 'def'", Evaluation::Boolean(true)),
1681            ("'abc' >= 'ABC'", Evaluation::Boolean(true)),
1682            ("'ABC' <= 'abc'", Evaluation::Boolean(true)),
1683            // Greek sigma: ς (final) and σ (non-final) both uppercase to Σ.
1684            // This is why we use to_uppercase() instead of to_lowercase().
1685            ("'\u{03C3}' == '\u{03C2}'", Evaluation::Boolean(true)), // σ == ς
1686            ("'\u{03A3}' == '\u{03C3}'", Evaluation::Boolean(true)), // Σ == σ
1687            ("'\u{03A3}' == '\u{03C2}'", Evaluation::Boolean(true)), // Σ == ς
1688            // Array contains with case-insensitive string matching
1689            (
1690                "contains(fromJSON('[\"Hello\", \"World\"]'), 'hello')",
1691                Evaluation::Boolean(true),
1692            ),
1693            (
1694                "contains(fromJSON('[\"hello\", \"world\"]'), 'WORLD')",
1695                Evaluation::Boolean(true),
1696            ),
1697            (
1698                "contains(fromJSON('[\"ABC\"]'), 'abc')",
1699                Evaluation::Boolean(true),
1700            ),
1701            (
1702                "contains(fromJSON('[\"abc\"]'), 'def')",
1703                Evaluation::Boolean(false),
1704            ),
1705        ];
1706
1707        for (expr_str, expected) in test_cases {
1708            let expr = Expr::parse(expr_str)?;
1709            let result = expr.consteval().unwrap();
1710            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1711        }
1712
1713        Ok(())
1714    }
1715
1716    #[test]
1717    fn test_evaluation_sema_display() {
1718        use crate::Evaluation;
1719
1720        let test_cases = &[
1721            (Evaluation::String("hello".to_string()), "hello"),
1722            (Evaluation::Number(42.0), "42"),
1723            (Evaluation::Number(3.14), "3.14"),
1724            (Evaluation::Boolean(true), "true"),
1725            (Evaluation::Boolean(false), "false"),
1726            (Evaluation::Null, ""),
1727        ];
1728
1729        for (result, expected) in test_cases {
1730            assert_eq!(result.sema().to_string(), *expected);
1731        }
1732    }
1733
1734    #[test]
1735    fn test_evaluation_result_to_boolean() {
1736        use crate::Evaluation;
1737
1738        let test_cases = &[
1739            (Evaluation::Boolean(true), true),
1740            (Evaluation::Boolean(false), false),
1741            (Evaluation::Null, false),
1742            (Evaluation::Number(0.0), false),
1743            (Evaluation::Number(1.0), true),
1744            (Evaluation::Number(-1.0), true),
1745            (Evaluation::Number(f64::NAN), false), // NaN is falsy in GitHub Actions
1746            (Evaluation::String("".to_string()), false),
1747            (Evaluation::String("hello".to_string()), true),
1748            (Evaluation::Array(vec![]), true), // Arrays are always truthy
1749            (Evaluation::Object(std::collections::HashMap::new()), true), // Dictionaries are always truthy
1750        ];
1751
1752        for (result, expected) in test_cases {
1753            assert_eq!(result.as_boolean(), *expected);
1754        }
1755    }
1756
1757    #[test]
1758    fn test_evaluation_result_to_number() {
1759        use crate::Evaluation;
1760
1761        // Non-string types
1762        let test_cases = &[
1763            (Evaluation::Number(42.0), 42.0),
1764            (Evaluation::Number(0.0), 0.0),
1765            (Evaluation::Boolean(true), 1.0),
1766            (Evaluation::Boolean(false), 0.0),
1767            (Evaluation::Null, 0.0),
1768        ];
1769
1770        for (eval, expected) in test_cases {
1771            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", eval);
1772        }
1773
1774        let string_cases: &[(&str, f64)] = &[
1775            // Empty / whitespace-only
1776            ("", 0.0),
1777            ("   ", 0.0),
1778            ("\t", 0.0),
1779            // Whitespace trimming
1780            ("   123   ", 123.0),
1781            (" 42 ", 42.0),
1782            ("   1   ", 1.0),
1783            ("\t5\n", 5.0),
1784            ("  \t123\t  ", 123.0),
1785            // Basic decimal
1786            ("42", 42.0),
1787            ("3.14", 3.14),
1788            // Hex
1789            ("0xff", 255.0),
1790            ("0xfF", 255.0),
1791            ("0xFF", 255.0),
1792            (" 0xff ", 255.0),
1793            ("0x0", 0.0),
1794            ("0x11", 17.0),
1795            // Hex: signed 32-bit two's complement wrapping
1796            ("0x7FFFFFFF", 2147483647.0),
1797            ("0x80000000", -2147483648.0),
1798            ("0xFFFFFFFF", -1.0),
1799            // Octal
1800            ("0o10", 8.0),
1801            (" 0o10 ", 8.0),
1802            ("0o0", 0.0),
1803            ("0o11", 9.0),
1804            // Octal: signed 32-bit two's complement wrapping
1805            ("0o17777777777", 2147483647.0),
1806            ("0o20000000000", -2147483648.0),
1807            // Scientific notation
1808            ("1.2e2", 120.0),
1809            ("1.2E2", 120.0),
1810            ("1.2e-2", 0.012),
1811            (" 1.2e2 ", 120.0),
1812            ("1.2e+2", 120.0),
1813            ("5e0", 5.0),
1814            ("1e3", 1000.0),
1815            ("123e-1", 12.3),
1816            (" +1.2e2 ", 120.0),
1817            (" -1.2E+2 ", -120.0),
1818            // Signs
1819            ("+42", 42.0),
1820            ("  -42  ", -42.0),
1821            ("  3.14  ", 3.14),
1822            ("+0", 0.0),
1823            ("-0", 0.0),
1824            (" +123456.789 ", 123456.789),
1825            (" -123456.789 ", -123456.789),
1826            // Leading zeros -> decimal
1827            ("0123", 123.0),
1828            ("00", 0.0),
1829            ("007", 7.0),
1830            ("010", 10.0),
1831            // Trailing/leading dot
1832            ("123.", 123.0),
1833            (".5", 0.5),
1834        ];
1835
1836        for (input, expected) in string_cases {
1837            let eval = Evaluation::String(input.to_string());
1838            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", input);
1839        }
1840
1841        // Infinity cases
1842        let infinity_cases: &[(&str, f64)] = &[
1843            ("Infinity", f64::INFINITY),
1844            (" Infinity ", f64::INFINITY),
1845            ("+Infinity", f64::INFINITY),
1846            ("-Infinity", f64::NEG_INFINITY),
1847            (" -Infinity ", f64::NEG_INFINITY),
1848        ];
1849
1850        for (input, expected) in infinity_cases {
1851            let eval = Evaluation::String(input.to_string());
1852            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", input);
1853        }
1854
1855        // NaN cases: all verified against GitHub Actions CI.
1856        let nan_cases: &[&str] = &[
1857            // Invalid strings
1858            "hello",
1859            "abc",
1860            " abc ",
1861            " NaN ",
1862            // Partial/malformed numerics
1863            "123abc",
1864            "abc123",
1865            "100a",
1866            "12.3.4",
1867            "1e2e3",
1868            "1 2",
1869            "1_000",
1870            "+",
1871            "-",
1872            ".",
1873            // Binary notation
1874            "0b1010",
1875            "0B1010",
1876            "0b0",
1877            "0b1",
1878            "0b11",
1879            " 0b11 ",
1880            // Uppercase prefixes are NOT supported
1881            "0XFF",
1882            "0O10",
1883            // Signed prefixed numbers are NOT supported
1884            "-0xff",
1885            "+0xff",
1886            "-0o10",
1887            "+0o10",
1888            "-0b11",
1889            // Empty prefixes (no digits after prefix)
1890            "0x",
1891            "0o",
1892            "0b",
1893            // Invalid digits for the base
1894            "0xZZ",
1895            "0o89",
1896            "0b23",
1897            // Hex/octal values exceeding 32-bit
1898            "0x100000000",
1899            "0o40000000000",
1900            // "inf" abbreviation rejected by GH runner
1901            "inf",
1902            "Inf",
1903            "INF",
1904            "+inf",
1905            "-inf",
1906            " inf ",
1907        ];
1908
1909        for input in nan_cases {
1910            let eval = Evaluation::String(input.to_string());
1911            assert!(
1912                eval.as_number().is_nan(),
1913                "as_number() for {:?} should be NaN",
1914                input
1915            );
1916        }
1917    }
1918
1919    #[test]
1920    fn test_github_actions_logical_semantics() -> Result<(), Error> {
1921        use crate::Evaluation;
1922
1923        // Test GitHub Actions-specific && and || semantics
1924        let test_cases = &[
1925            // && returns the first falsy value, or the last value if all are truthy
1926            ("false && 'hello'", Evaluation::Boolean(false)),
1927            ("null && 'hello'", Evaluation::Null),
1928            ("'' && 'hello'", Evaluation::String("".to_string())),
1929            (
1930                "'hello' && 'world'",
1931                Evaluation::String("world".to_string()),
1932            ),
1933            ("true && 42", Evaluation::Number(42.0)),
1934            // || returns the first truthy value, or the last value if all are falsy
1935            ("true || 'hello'", Evaluation::Boolean(true)),
1936            (
1937                "'hello' || 'world'",
1938                Evaluation::String("hello".to_string()),
1939            ),
1940            ("false || 'hello'", Evaluation::String("hello".to_string())),
1941            ("null || false", Evaluation::Boolean(false)),
1942            ("'' || null", Evaluation::Null),
1943            ("!NaN", Evaluation::Boolean(true)),
1944            ("!!NaN", Evaluation::Boolean(false)),
1945        ];
1946
1947        for (expr_str, expected) in test_cases {
1948            let expr = Expr::parse(expr_str)?;
1949            let result = expr.consteval().unwrap();
1950            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1951        }
1952
1953        Ok(())
1954    }
1955
1956    #[test]
1957    fn test_expr_has_constant_reducible_subexpr() -> Result<(), Error> {
1958        for (expr, reducible) in &[
1959            // Literals are not considered reducible subexpressions.
1960            ("'foo'", false),
1961            ("1", false),
1962            ("true", false),
1963            ("null", false),
1964            // Non-reducible expressions with reducible subexpressions
1965            (
1966                "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
1967                true,
1968            ),
1969            ("foobar[format('{0}', 'event')]", true),
1970        ] {
1971            let expr = Expr::parse(expr)?;
1972            assert_eq!(!expr.constant_reducible_subexprs().is_empty(), *reducible);
1973        }
1974        Ok(())
1975    }
1976
1977    #[test]
1978    fn test_expr_contexts() -> Result<(), Error> {
1979        // A single context.
1980        let expr = Expr::parse("foo.bar.baz[1].qux")?;
1981        assert_eq!(
1982            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
1983            ["foo.bar.baz[1].qux",]
1984        );
1985
1986        // Multiple contexts.
1987        let expr = Expr::parse("foo.bar[1].baz || abc.def")?;
1988        assert_eq!(
1989            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
1990            ["foo.bar[1].baz", "abc.def",]
1991        );
1992
1993        // Two contexts, one as part of a computed index.
1994        let expr = Expr::parse("foo.bar[abc.def]")?;
1995        assert_eq!(
1996            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
1997            ["foo.bar[abc.def]", "abc.def",]
1998        );
1999
2000        Ok(())
2001    }
2002
2003    #[test]
2004    fn test_expr_dataflow_contexts() -> Result<(), Error> {
2005        // Trivial cases.
2006        let expr = Expr::parse("foo.bar")?;
2007        assert_eq!(
2008            expr.dataflow_contexts()
2009                .iter()
2010                .map(|t| t.1.raw)
2011                .collect::<Vec<_>>(),
2012            ["foo.bar"]
2013        );
2014
2015        let expr = Expr::parse("foo.bar[1]")?;
2016        assert_eq!(
2017            expr.dataflow_contexts()
2018                .iter()
2019                .map(|t| t.1.raw)
2020                .collect::<Vec<_>>(),
2021            ["foo.bar[1]"]
2022        );
2023
2024        // No dataflow due to a boolean expression.
2025        let expr = Expr::parse("foo.bar == 'bar'")?;
2026        assert!(expr.dataflow_contexts().is_empty());
2027
2028        // ||: all contexts potentially expand into the evaluation.
2029        let expr = Expr::parse("foo.bar || abc || d.e.f")?;
2030        assert_eq!(
2031            expr.dataflow_contexts()
2032                .iter()
2033                .map(|t| t.1.raw)
2034                .collect::<Vec<_>>(),
2035            ["foo.bar", "abc", "d.e.f"]
2036        );
2037
2038        // &&: only the RHS context(s) expand into the evaluation.
2039        let expr = Expr::parse("foo.bar && abc && d.e.f")?;
2040        assert_eq!(
2041            expr.dataflow_contexts()
2042                .iter()
2043                .map(|t| t.1.raw)
2044                .collect::<Vec<_>>(),
2045            ["d.e.f"]
2046        );
2047
2048        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
2049        assert_eq!(
2050            expr.dataflow_contexts()
2051                .iter()
2052                .map(|t| t.1.raw)
2053                .collect::<Vec<_>>(),
2054            ["foo.bar"]
2055        );
2056
2057        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
2058        assert_eq!(
2059            expr.dataflow_contexts()
2060                .iter()
2061                .map(|t| t.1.raw)
2062                .collect::<Vec<_>>(),
2063            ["foo.bar", "foo.baz"]
2064        );
2065
2066        let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
2067        assert_eq!(
2068            expr.dataflow_contexts()
2069                .iter()
2070                .map(|t| t.1.raw)
2071                .collect::<Vec<_>>(),
2072            ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
2073        );
2074
2075        let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
2076        assert_eq!(
2077            expr.dataflow_contexts()
2078                .iter()
2079                .map(|t| t.1.raw)
2080                .collect::<Vec<_>>(),
2081            ["foo.bar", "github", "github"]
2082        );
2083
2084        Ok(())
2085    }
2086
2087    #[test]
2088    fn test_spannedexpr_computed_indices() -> Result<(), Error> {
2089        for (expr, computed_indices) in &[
2090            ("foo.bar", vec![]),
2091            ("foo.bar[1]", vec![]),
2092            ("foo.bar[*]", vec![]),
2093            ("foo.bar[abc]", vec!["[abc]"]),
2094            (
2095                "foo.bar[format('{0}', 'foo')]",
2096                vec!["[format('{0}', 'foo')]"],
2097            ),
2098            ("foo.bar[abc].def[efg]", vec!["[abc]", "[efg]"]),
2099        ] {
2100            let expr = Expr::parse(expr)?;
2101
2102            assert_eq!(
2103                expr.computed_indices()
2104                    .iter()
2105                    .map(|e| e.origin.raw)
2106                    .collect::<Vec<_>>(),
2107                *computed_indices
2108            );
2109        }
2110
2111        Ok(())
2112    }
2113
2114    #[test]
2115    fn test_fragment_from_expr() {
2116        for (expr, expected) in &[
2117            ("foo==bar", "foo==bar"),
2118            ("foo    ==   bar", r"foo\s+==\s+bar"),
2119            ("foo == bar", r"foo\s+==\s+bar"),
2120            ("fromJSON('{}')", "fromJSON('{}')"),
2121            ("fromJSON('{ }')", r"fromJSON\('\{\s+\}'\)"),
2122            ("fromJSON ('{ }')", r"fromJSON\s+\('\{\s+\}'\)"),
2123            ("a . b . c . d", r"a\s+\.\s+b\s+\.\s+c\s+\.\s+d"),
2124            ("true \n && \n false", r"true\s+\&\&\s+false"),
2125        ] {
2126            let expr = Expr::parse(expr).unwrap();
2127            match subfeature::Fragment::from(&expr) {
2128                subfeature::Fragment::Raw(actual) => assert_eq!(actual, *expected),
2129                subfeature::Fragment::Regex(actual) => assert_eq!(actual.as_str(), *expected),
2130            };
2131        }
2132    }
2133
2134    #[test]
2135    fn test_leaf_expressions() -> Result<(), Error> {
2136        // A single literal is its own leaf.
2137        let expr = Expr::parse("'hello'")?;
2138        let leaves = expr.leaf_expressions();
2139        assert_eq!(leaves.len(), 1);
2140        assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "hello"));
2141
2142        // A single context is its own leaf.
2143        let expr = Expr::parse("foo.bar")?;
2144        let leaves = expr.leaf_expressions();
2145        assert_eq!(leaves.len(), 1);
2146        assert!(matches!(&leaves[0].inner, Expr::Context(_)));
2147
2148        // `A || B` returns both sides.
2149        let expr = Expr::parse("foo.abc || foo.def")?;
2150        let leaves = expr.leaf_expressions();
2151        assert_eq!(leaves.len(), 2);
2152        assert!(matches!(&leaves[0].inner, Expr::Context(_)));
2153        assert!(matches!(&leaves[1].inner, Expr::Context(_)));
2154
2155        // `A && B` returns only B.
2156        let expr = Expr::parse("foo.bar && 'hello'")?;
2157        let leaves = expr.leaf_expressions();
2158        assert_eq!(leaves.len(), 1);
2159        assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "hello"));
2160
2161        // Conditional pattern: `cond && 'value' || 'fallback'`
2162        let expr = Expr::parse("foo.bar == 'true' && 'redis:7' || ''")?;
2163        let leaves = expr.leaf_expressions();
2164        assert_eq!(leaves.len(), 2);
2165        assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "redis:7"));
2166        assert!(matches!(&leaves[1].inner, Expr::Literal(Literal::String(s)) if s == ""));
2167
2168        // Comparison operators are leaves themselves (they produce booleans).
2169        let expr = Expr::parse("foo.bar == 'abc'")?;
2170        let leaves = expr.leaf_expressions();
2171        assert_eq!(leaves.len(), 1);
2172        assert!(matches!(&leaves[0].inner, Expr::BinOp { .. }));
2173
2174        Ok(())
2175    }
2176
2177    #[test]
2178    fn test_upper_special() {
2179        use super::EvaluationSema;
2180
2181        let cases = &[
2182            ("", ""),
2183            ("abc", "ABC"),
2184            ("ıabc", "ıABC"),
2185            ("ııabc", "ııABC"),
2186            ("abcı", "ABCı"),
2187            ("abcıı", "ABCıı"),
2188            ("abcıdef", "ABCıDEF"),
2189            ("abcııdef", "ABCııDEF"),
2190            ("abcıdefıghi", "ABCıDEFıGHI"),
2191        ];
2192
2193        for (input, want) in cases {
2194            assert_eq!(
2195                EvaluationSema::upper_special(input),
2196                *want,
2197                "input: {input}"
2198            );
2199        }
2200    }
2201}