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