github_actions_expressions/
lib.rs

1//! GitHub Actions expression parsing and analysis.
2
3#![forbid(unsafe_code)]
4#![deny(missing_docs)]
5
6use std::borrow::Cow;
7
8use crate::context::Context;
9
10use self::parser::{ExprParser, Rule};
11use anyhow::Result;
12use itertools::Itertools;
13use pest::{Parser, iterators::Pair};
14
15pub mod context;
16
17// Isolates the ExprParser, Rule and other generated types
18// so that we can do `missing_docs` at the top-level.
19// See: https://github.com/pest-parser/pest/issues/326
20mod parser {
21    use pest_derive::Parser;
22
23    /// A parser for GitHub Actions' expression language.
24    #[derive(Parser)]
25    #[grammar = "expr.pest"]
26    pub struct ExprParser;
27}
28
29/// Represents a function in a GitHub Actions expression.
30///
31/// Function names are case-insensitive.
32#[derive(Debug)]
33pub struct Function<'src>(pub(crate) &'src str);
34
35impl PartialEq for Function<'_> {
36    fn eq(&self, other: &Self) -> bool {
37        self.0.eq_ignore_ascii_case(other.0)
38    }
39}
40impl PartialEq<str> for Function<'_> {
41    fn eq(&self, other: &str) -> bool {
42        self.0.eq_ignore_ascii_case(other)
43    }
44}
45
46/// Represents a single identifier in a GitHub Actions expression,
47/// i.e. a single context component.
48///
49/// Identifiers are case-insensitive.
50#[derive(Debug)]
51pub struct Identifier<'src>(&'src str);
52
53impl Identifier<'_> {
54    /// Returns the identifier as a string slice, as it appears in the
55    /// expression.
56    ///
57    /// Important: identifiers are case-insensitive, so this should not
58    /// be used for comparisons.
59    pub fn as_str(&self) -> &str {
60        self.0
61    }
62}
63
64impl PartialEq for Identifier<'_> {
65    fn eq(&self, other: &Self) -> bool {
66        self.0.eq_ignore_ascii_case(other.0)
67    }
68}
69
70impl PartialEq<str> for Identifier<'_> {
71    fn eq(&self, other: &str) -> bool {
72        self.0.eq_ignore_ascii_case(other)
73    }
74}
75
76/// Binary operations allowed in an expression.
77#[derive(Debug, PartialEq)]
78pub enum BinOp {
79    /// `expr && expr`
80    And,
81    /// `expr || expr`
82    Or,
83    /// `expr == expr`
84    Eq,
85    /// `expr != expr`
86    Neq,
87    /// `expr > expr`
88    Gt,
89    /// `expr >= expr`
90    Ge,
91    /// `expr < expr`
92    Lt,
93    /// `expr <= expr`
94    Le,
95}
96
97/// Unary operations allowed in an expression.
98#[derive(Debug, PartialEq)]
99pub enum UnOp {
100    /// `!expr`
101    Not,
102}
103
104/// Represents a literal value in a GitHub Actions expression.
105#[derive(Debug, PartialEq)]
106pub enum Literal<'src> {
107    /// A number literal.
108    Number(f64),
109    /// A string literal.
110    String(Cow<'src, str>),
111    /// A boolean literal.
112    Boolean(bool),
113    /// The `null` literal.
114    Null,
115}
116
117impl<'src> Literal<'src> {
118    /// Returns a string representation of the literal.
119    ///
120    /// This is not guaranteed to be an exact equivalent of the literal
121    /// as it appears in its source expression. For example, the string
122    /// representation of a floating point literal is subject to normalization,
123    /// and string literals are returned without surrounding quotes.
124    pub fn as_str(&self) -> Cow<'src, str> {
125        match self {
126            Literal::String(s) => s.clone(),
127            Literal::Number(n) => Cow::Owned(n.to_string()),
128            Literal::Boolean(b) => Cow::Owned(b.to_string()),
129            Literal::Null => Cow::Borrowed("null"),
130        }
131    }
132}
133
134/// Represents a GitHub Actions expression.
135#[derive(Debug, PartialEq)]
136pub enum Expr<'src> {
137    /// A literal value.
138    Literal(Literal<'src>),
139    /// The `*` literal within an index or context.
140    Star,
141    /// A function call.
142    Call {
143        /// The function name, e.g. `foo` in `foo()`.
144        func: Function<'src>,
145        /// The function's arguments.
146        args: Vec<Expr<'src>>,
147    },
148    /// A context identifier component, e.g. `github` in `github.actor`.
149    Identifier(Identifier<'src>),
150    /// A context index component, e.g. `[0]` in `foo[0]`.
151    Index(Box<Expr<'src>>),
152    /// A full context reference.
153    Context(Context<'src>),
154    /// A binary operation, either logical or arithmetic.
155    BinOp {
156        /// The LHS of the binop.
157        lhs: Box<Expr<'src>>,
158        /// The binary operator.
159        op: BinOp,
160        /// The RHS of the binop.
161        rhs: Box<Expr<'src>>,
162    },
163    /// A unary operation. Negation (`!`) is currently the only `UnOp`.
164    UnOp {
165        /// The unary operator.
166        op: UnOp,
167        /// The expression to apply the operator to.
168        expr: Box<Expr<'src>>,
169    },
170}
171
172impl<'src> Expr<'src> {
173    /// Convenience API for making an [`Expr::Identifier`].
174    fn ident(i: &'src str) -> Self {
175        Self::Identifier(Identifier(i))
176    }
177
178    /// Convenience API for making an [`Expr::Context`].
179    fn context(r: &'src str, components: impl Into<Vec<Expr<'src>>>) -> Self {
180        Self::Context(Context::new(r, components))
181    }
182
183    /// Returns whether the expression is a literal.
184    fn is_literal(&self) -> bool {
185        matches!(self, Expr::Literal(_))
186    }
187
188    /// Returns whether the expression is constant reducible.
189    ///
190    /// "Constant reducible" is similar to "constant foldable" but with
191    /// meta-evaluation semantics: the expression `5` would not be
192    /// constant foldable in a normal program (because it's already
193    /// an atom), but is "constant reducible" in a GitHub Actions expression
194    /// because an expression containing it (e.g. `${{ 5 }}`) can be elided
195    /// entirely and replaced with `5`.
196    ///
197    /// There are three kinds of reducible expressions:
198    ///
199    /// 1. Literals, which reduce to their literal value;
200    /// 2. Binops/unops with reducible subexpressions, which reduce
201    ///    to their evaluation;
202    /// 3. Select function calls where the semantics of the function
203    ///    mean that reducible arguments make the call itself reducible.
204    ///
205    /// NOTE: This implementation is sound but not complete.
206    pub fn constant_reducible(&self) -> bool {
207        match self {
208            // Literals are always reducible.
209            Expr::Literal(_) => true,
210            // Binops are reducible if their LHS and RHS are reducible.
211            Expr::BinOp { lhs, op: _, rhs } => lhs.constant_reducible() && rhs.constant_reducible(),
212            // Unops are reducible if their interior expression is reducible.
213            Expr::UnOp { op: _, expr } => expr.constant_reducible(),
214            Expr::Call { func, args } => {
215                // These functions are reducible if their arguments are reducible.
216                if func == "format"
217                    || func == "contains"
218                    || func == "startsWith"
219                    || func == "endsWith"
220                {
221                    args.iter().all(Expr::constant_reducible)
222                } else {
223                    // TODO: fromJSON(toJSON(...)) and vice versa.
224                    false
225                }
226            }
227            // Everything else is presumed non-reducible.
228            _ => false,
229        }
230    }
231
232    /// Like [`Self::constant_reducible`], but for all subexpressions
233    /// rather than the top-level expression.
234    ///
235    /// This has slightly different semantics than `constant_reducible`:
236    /// it doesn't include "trivially" reducible expressions like literals,
237    /// since flagging these as reducible within a larger expression
238    /// would be misleading.
239    pub fn has_constant_reducible_subexpr(&self) -> bool {
240        if !self.is_literal() && self.constant_reducible() {
241            return true;
242        }
243
244        match self {
245            Expr::Call { func: _, args } => args.iter().any(|a| a.has_constant_reducible_subexpr()),
246            Expr::Context(ctx) => {
247                // contexts themselves are never reducible, but they might
248                // contains reducible index subexpressions.
249                ctx.parts.iter().any(|c| c.has_constant_reducible_subexpr())
250            }
251            Expr::BinOp { lhs, op: _, rhs } => {
252                lhs.has_constant_reducible_subexpr() || rhs.has_constant_reducible_subexpr()
253            }
254            Expr::UnOp { op: _, expr } => expr.has_constant_reducible_subexpr(),
255
256            Expr::Index(expr) => expr.has_constant_reducible_subexpr(),
257            _ => false,
258        }
259    }
260
261    /// Returns the contexts in this expression that directly flow into the
262    /// expression's evaluation.
263    ///
264    /// For example `${{ foo.bar }}` returns `foo.bar` since the value
265    /// of `foo.bar` flows into the evaluation. On the other hand,
266    /// `${{ foo.bar == 'abc' }}` returns no expanded contexts,
267    /// since the value of `foo.bar` flows into a boolean evaluation
268    /// that gets expanded.
269    pub fn dataflow_contexts(&self) -> Vec<&Context<'src>> {
270        let mut contexts = vec![];
271
272        match self {
273            Expr::Call { func, args } => {
274                // These functions, when evaluated, produce an evaluation
275                // that includes some or all of the contexts listed in
276                // their arguments.
277                if func == "toJSON" || func == "format" || func == "join" {
278                    for arg in args {
279                        contexts.extend(arg.dataflow_contexts());
280                    }
281                }
282            }
283            // NOTE: We intentionally don't handle the `func(...).foo.bar`
284            // case differently here, since a call followed by a
285            // context access *can* flow into the evaluation.
286            // For example, `${{ fromJSON(something) }}` evaluates to
287            // `Object` but `${{ fromJSON(something).foo }}` evaluates
288            // to the contents of `something.foo`.
289            Expr::Context(ctx) => contexts.push(ctx),
290            Expr::BinOp { lhs, op, rhs } => match op {
291                // With && only the RHS can flow into the evaluation as a context
292                // (rather than a boolean).
293                BinOp::And => {
294                    contexts.extend(rhs.dataflow_contexts());
295                }
296                // With || either the LHS or RHS can flow into the evaluation as a context.
297                BinOp::Or => {
298                    contexts.extend(lhs.dataflow_contexts());
299                    contexts.extend(rhs.dataflow_contexts());
300                }
301                _ => (),
302            },
303            _ => (),
304        }
305
306        contexts
307    }
308
309    /// Parses the given string into an expression.
310    pub fn parse(expr: &str) -> Result<Expr> {
311        // Top level `expression` is a single `or_expr`.
312        let or_expr = ExprParser::parse(Rule::expression, expr)?
313            .next()
314            .unwrap()
315            .into_inner()
316            .next()
317            .unwrap();
318
319        fn parse_pair(pair: Pair<'_, Rule>) -> Result<Box<Expr>> {
320            // We're parsing a pest grammar, which isn't left-recursive.
321            // As a result, we have constructions like
322            // `or_expr = { and_expr ~ ("||" ~ and_expr)* }`, which
323            // result in wonky ASTs like one or many (>2) headed ORs.
324            // We turn these into sane looking ASTs by punching the single
325            // pairs down to their primitive type and folding the
326            // many-headed pairs appropriately.
327            // For example, `or_expr` matches the `1` one but punches through
328            // to `Number(1)`, and also matches `true || true || true` which
329            // becomes `BinOp(BinOp(true, true), true)`.
330
331            match pair.as_rule() {
332                Rule::or_expr => {
333                    let mut pairs = pair.into_inner();
334                    let lhs = parse_pair(pairs.next().unwrap())?;
335                    pairs.try_fold(lhs, |expr, next| {
336                        Ok(Expr::BinOp {
337                            lhs: expr,
338                            op: BinOp::Or,
339                            rhs: parse_pair(next)?,
340                        }
341                        .into())
342                    })
343                }
344                Rule::and_expr => {
345                    let mut pairs = pair.into_inner();
346                    let lhs = parse_pair(pairs.next().unwrap())?;
347                    pairs.try_fold(lhs, |expr, next| {
348                        Ok(Expr::BinOp {
349                            lhs: expr,
350                            op: BinOp::And,
351                            rhs: parse_pair(next)?,
352                        }
353                        .into())
354                    })
355                }
356                Rule::eq_expr => {
357                    // eq_expr matches both `==` and `!=` and captures
358                    // them in the `eq_op` capture, so we fold with
359                    // two-tuples of (eq_op, comp_expr).
360                    let mut pairs = pair.into_inner();
361                    let lhs = parse_pair(pairs.next().unwrap())?;
362
363                    let pair_chunks = pairs.chunks(2);
364                    pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
365                        let eq_op = next.next().unwrap();
366                        let comp_expr = next.next().unwrap();
367
368                        let eq_op = match eq_op.as_str() {
369                            "==" => BinOp::Eq,
370                            "!=" => BinOp::Neq,
371                            _ => unreachable!(),
372                        };
373
374                        Ok(Expr::BinOp {
375                            lhs: expr,
376                            op: eq_op,
377                            rhs: parse_pair(comp_expr)?,
378                        }
379                        .into())
380                    })
381                }
382                Rule::comp_expr => {
383                    // Same as eq_expr, but with comparison operators.
384                    let mut pairs = pair.into_inner();
385                    let lhs = parse_pair(pairs.next().unwrap())?;
386
387                    let pair_chunks = pairs.chunks(2);
388                    pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
389                        let comp_op = next.next().unwrap();
390                        let unary_expr = next.next().unwrap();
391
392                        let eq_op = match comp_op.as_str() {
393                            ">" => BinOp::Gt,
394                            ">=" => BinOp::Ge,
395                            "<" => BinOp::Lt,
396                            "<=" => BinOp::Le,
397                            _ => unreachable!(),
398                        };
399
400                        Ok(Expr::BinOp {
401                            lhs: expr,
402                            op: eq_op,
403                            rhs: parse_pair(unary_expr)?,
404                        }
405                        .into())
406                    })
407                }
408                Rule::unary_expr => {
409                    let mut pairs = pair.into_inner();
410                    let pair = pairs.next().unwrap();
411
412                    match pair.as_rule() {
413                        Rule::unary_op => Ok(Expr::UnOp {
414                            op: UnOp::Not,
415                            expr: parse_pair(pairs.next().unwrap())?,
416                        }
417                        .into()),
418                        Rule::primary_expr => parse_pair(pair),
419                        _ => unreachable!(),
420                    }
421                }
422                Rule::primary_expr => {
423                    // Punt back to the top level match to keep things simple.
424                    parse_pair(pair.into_inner().next().unwrap())
425                }
426                Rule::number => Ok(Box::new(pair.as_str().parse::<f64>().unwrap().into())),
427                Rule::string => {
428                    // string -> string_inner
429                    let string_inner = pair.into_inner().next().unwrap().as_str();
430
431                    // Optimization: if our string literal doesn't have any
432                    // escaped quotes in it, we can save ourselves a clone.
433                    if !string_inner.contains('\'') {
434                        Ok(Box::new(string_inner.into()))
435                    } else {
436                        Ok(Box::new(string_inner.replace("''", "'").into()))
437                    }
438                }
439                Rule::boolean => Ok(Box::new(pair.as_str().parse::<bool>().unwrap().into())),
440                Rule::null => Ok(Expr::Literal(Literal::Null).into()),
441                Rule::star => Ok(Expr::Star.into()),
442                Rule::function_call => {
443                    let mut pairs = pair.into_inner();
444
445                    let identifier = pairs.next().unwrap();
446                    let args = pairs
447                        .map(|pair| parse_pair(pair).map(|e| *e))
448                        .collect::<Result<_, _>>()?;
449
450                    Ok(Expr::Call {
451                        func: Function(identifier.as_str()),
452                        args,
453                    }
454                    .into())
455                }
456                Rule::identifier => Ok(Expr::ident(pair.as_str()).into()),
457                Rule::index => {
458                    Ok(Expr::Index(parse_pair(pair.into_inner().next().unwrap())?).into())
459                }
460                Rule::context => {
461                    let raw = pair.as_str();
462                    let pairs = pair.into_inner();
463
464                    let mut inner: Vec<Expr> = pairs
465                        .map(|pair| parse_pair(pair).map(|e| *e))
466                        .collect::<Result<_, _>>()?;
467
468                    // NOTE(ww): Annoying specialization: the `context` rule
469                    // wholly encloses the `function_call` rule, so we clean up
470                    // the AST slightly to turn `Context { Call }` into just `Call`.
471                    if inner.len() == 1 && matches!(inner[0], Expr::Call { .. }) {
472                        Ok(inner.remove(0).into())
473                    } else {
474                        Ok(Expr::context(raw, inner).into())
475                    }
476                }
477                r => panic!("unrecognized rule: {r:?}"),
478            }
479        }
480
481        parse_pair(or_expr).map(|e| *e)
482    }
483}
484
485impl<'src> From<&'src str> for Expr<'src> {
486    fn from(s: &'src str) -> Self {
487        Expr::Literal(Literal::String(s.into()))
488    }
489}
490
491impl From<String> for Expr<'_> {
492    fn from(s: String) -> Self {
493        Expr::Literal(Literal::String(s.into()))
494    }
495}
496
497impl From<f64> for Expr<'_> {
498    fn from(n: f64) -> Self {
499        Expr::Literal(Literal::Number(n))
500    }
501}
502
503impl From<bool> for Expr<'_> {
504    fn from(b: bool) -> Self {
505        Expr::Literal(Literal::Boolean(b))
506    }
507}
508
509#[cfg(test)]
510mod tests {
511    use std::borrow::Cow;
512
513    use anyhow::Result;
514    use pest::Parser as _;
515    use pretty_assertions::assert_eq;
516
517    use crate::Literal;
518
519    use super::{BinOp, Expr, ExprParser, Function, Rule, UnOp};
520
521    #[test]
522    fn test_literal_string_borrows() {
523        let cases = &[
524            ("'foo'", true),
525            ("'foo bar'", true),
526            ("'foo '' bar'", false),
527            ("'foo''bar'", false),
528            ("'foo''''bar'", false),
529        ];
530
531        for (expr, borrows) in cases {
532            let Expr::Literal(Literal::String(s)) = Expr::parse(expr).unwrap() else {
533                panic!("expected a literal string expression for {expr}");
534            };
535
536            assert!(matches!(
537                (s, borrows),
538                (Cow::Borrowed(_), true) | (Cow::Owned(_), false)
539            ));
540        }
541    }
542
543    #[test]
544    fn test_literal_as_str() {
545        let cases = &[
546            ("'foo'", "foo"),
547            ("'foo '' bar'", "foo ' bar"),
548            ("123", "123"),
549            ("123.000", "123"),
550            ("0.0", "0"),
551            ("0.1", "0.1"),
552            ("0.12345", "0.12345"),
553            ("true", "true"),
554            ("false", "false"),
555            ("null", "null"),
556        ];
557
558        for (expr, expected) in cases {
559            let Expr::Literal(expr) = Expr::parse(expr).unwrap() else {
560                panic!("expected a literal expression for {expr}");
561            };
562
563            assert_eq!(expr.as_str(), *expected);
564        }
565    }
566
567    #[test]
568    fn test_function_eq() {
569        let func = Function("foo");
570        assert_eq!(&func, "foo");
571        assert_eq!(&func, "FOO");
572        assert_eq!(&func, "Foo");
573
574        assert_eq!(func, Function("FOO"));
575    }
576
577    #[test]
578    fn test_parse_string_rule() {
579        let cases = &[
580            ("''", ""),
581            ("' '", " "),
582            ("''''", "''"),
583            ("'test'", "test"),
584            ("'spaces are ok'", "spaces are ok"),
585            ("'escaping '' works'", "escaping '' works"),
586        ];
587
588        for (case, expected) in cases {
589            let s = ExprParser::parse(Rule::string, case)
590                .unwrap()
591                .next()
592                .unwrap();
593
594            assert_eq!(s.into_inner().next().unwrap().as_str(), *expected);
595        }
596    }
597
598    #[test]
599    fn test_parse_context_rule() {
600        let cases = &[
601            "foo.bar",
602            "github.action_path",
603            "inputs.foo-bar",
604            "inputs.also--valid",
605            "inputs.this__too",
606            "inputs.this__too",
607            "secrets.GH_TOKEN",
608            "foo.*.bar",
609            "github.event.issue.labels.*.name",
610        ];
611
612        for case in cases {
613            assert_eq!(
614                ExprParser::parse(Rule::context, case)
615                    .unwrap()
616                    .next()
617                    .unwrap()
618                    .as_str(),
619                *case
620            );
621        }
622    }
623
624    #[test]
625    fn test_parse_call_rule() {
626        let cases = &[
627            "foo()",
628            "foo(bar)",
629            "foo(bar())",
630            "foo(1.23)",
631            "foo(1,2)",
632            "foo(1, 2)",
633            "foo(1, 2, secret.GH_TOKEN)",
634            "foo(   )",
635            "fromJSON(inputs.free-threading)",
636        ];
637
638        for case in cases {
639            assert_eq!(
640                ExprParser::parse(Rule::function_call, case)
641                    .unwrap()
642                    .next()
643                    .unwrap()
644                    .as_str(),
645                *case
646            );
647        }
648    }
649
650    #[test]
651    fn test_parse_expr_rule() -> Result<()> {
652        // Ensures that we parse multi-line expressions correctly.
653        let multiline = "github.repository_owner == 'Homebrew' &&
654        ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
655        (github.event_name == 'pull_request_target' &&
656        (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
657
658        let cases = &[
659            "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
660            "foo || bar || baz",
661            "foo || bar && baz || foo && 1 && 2 && 3 || 4",
662            "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
663            "(true || false) == true",
664            "!(!true || false)",
665            "!(!true || false) == true",
666            "(true == false) == true",
667            "(true == (false || true && (true || false))) == true",
668            "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
669            "foo()[0]",
670            "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
671            multiline,
672            "'a' == 'b' && 'c' || 'd'",
673            "github.event['a']",
674            "github.event['a' == 'b']",
675            "github.event['a' == 'b' && 'c' || 'd']",
676            "github['event']['inputs']['dry-run']",
677            "github[format('{0}', 'event')]",
678            "github['event']['inputs'][github.event.inputs.magic]",
679            "github['event']['inputs'].*",
680        ];
681
682        for case in cases {
683            assert_eq!(
684                ExprParser::parse(Rule::expression, case)?
685                    .next()
686                    .unwrap()
687                    .as_str(),
688                *case
689            );
690        }
691
692        Ok(())
693    }
694
695    #[test]
696    fn test_parse() {
697        let cases = &[
698            (
699                "!true || false || true",
700                Expr::BinOp {
701                    lhs: Expr::BinOp {
702                        lhs: Expr::UnOp {
703                            op: UnOp::Not,
704                            expr: Box::new(true.into()),
705                        }
706                        .into(),
707                        op: BinOp::Or,
708                        rhs: Box::new(false.into()),
709                    }
710                    .into(),
711                    op: BinOp::Or,
712                    rhs: Box::new(true.into()),
713                },
714            ),
715            ("'foo '' bar'", "foo ' bar".into()),
716            ("('foo '' bar')", "foo ' bar".into()),
717            ("((('foo '' bar')))", "foo ' bar".into()),
718            (
719                "foo(1, 2, 3)",
720                Expr::Call {
721                    func: Function("foo"),
722                    args: vec![1.0.into(), 2.0.into(), 3.0.into()],
723                },
724            ),
725            (
726                "foo.bar.baz",
727                Expr::context(
728                    "foo.bar.baz",
729                    [Expr::ident("foo"), Expr::ident("bar"), Expr::ident("baz")],
730                ),
731            ),
732            (
733                "foo.bar.baz[1][2]",
734                Expr::context(
735                    "foo.bar.baz[1][2]",
736                    [
737                        Expr::ident("foo"),
738                        Expr::ident("bar"),
739                        Expr::ident("baz"),
740                        Expr::Index(Box::new(1.0.into())),
741                        Expr::Index(Box::new(2.0.into())),
742                    ],
743                ),
744            ),
745            (
746                "foo.bar.baz[*]",
747                Expr::context(
748                    "foo.bar.baz[*]",
749                    [
750                        Expr::ident("foo"),
751                        Expr::ident("bar"),
752                        Expr::ident("baz"),
753                        Expr::Index(Expr::Star.into()),
754                    ],
755                ),
756            ),
757            (
758                "vegetables.*.ediblePortions",
759                Expr::context(
760                    "vegetables.*.ediblePortions",
761                    vec![
762                        Expr::ident("vegetables"),
763                        Expr::Star,
764                        Expr::ident("ediblePortions"),
765                    ],
766                ),
767            ),
768            (
769                // Sanity check for our associativity: the top level Expr here
770                // should be `BinOp::Or`.
771                "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
772                Expr::BinOp {
773                    lhs: Expr::BinOp {
774                        lhs: Expr::BinOp {
775                            lhs: Expr::context(
776                                "github.ref",
777                                [Expr::ident("github"), Expr::ident("ref")],
778                            )
779                            .into(),
780                            op: BinOp::Eq,
781                            rhs: Box::new("refs/heads/main".into()),
782                        }
783                        .into(),
784                        op: BinOp::And,
785                        rhs: Box::new("value_for_main_branch".into()),
786                    }
787                    .into(),
788                    op: BinOp::Or,
789                    rhs: Box::new("value_for_other_branches".into()),
790                },
791            ),
792            (
793                "(true || false) == true",
794                Expr::BinOp {
795                    lhs: Expr::BinOp {
796                        lhs: Box::new(true.into()),
797                        op: BinOp::Or,
798                        rhs: Box::new(false.into()),
799                    }
800                    .into(),
801                    op: BinOp::Eq,
802                    rhs: Box::new(true.into()),
803                },
804            ),
805            (
806                "!(!true || false)",
807                Expr::UnOp {
808                    op: UnOp::Not,
809                    expr: Expr::BinOp {
810                        lhs: Expr::UnOp {
811                            op: UnOp::Not,
812                            expr: Box::new(true.into()),
813                        }
814                        .into(),
815                        op: BinOp::Or,
816                        rhs: Box::new(false.into()),
817                    }
818                    .into(),
819                },
820            ),
821            (
822                "foobar[format('{0}', 'event')]",
823                Expr::context(
824                    "foobar[format('{0}', 'event')]",
825                    [
826                        Expr::ident("foobar"),
827                        Expr::Index(
828                            Expr::Call {
829                                func: Function("format"),
830                                args: vec!["{0}".into(), "event".into()],
831                            }
832                            .into(),
833                        ),
834                    ],
835                ),
836            ),
837            (
838                "github.actor_id == '49699333'",
839                Expr::BinOp {
840                    lhs: Expr::context(
841                        "github.actor_id",
842                        [Expr::ident("github"), Expr::ident("actor_id")],
843                    )
844                    .into(),
845                    op: BinOp::Eq,
846                    rhs: Box::new("49699333".into()),
847                },
848            ),
849        ];
850
851        for (case, expr) in cases {
852            assert_eq!(Expr::parse(case).unwrap(), *expr);
853        }
854    }
855
856    #[test]
857    fn test_expr_constant_reducible() -> Result<()> {
858        for (expr, reducible) in &[
859            ("'foo'", true),
860            ("1", true),
861            ("true", true),
862            ("null", true),
863            // boolean and unary expressions of all literals are
864            // always reducible.
865            ("!true", true),
866            ("!null", true),
867            ("true && false", true),
868            ("true || false", true),
869            ("null && !null && true", true),
870            // formats/contains/startsWith/endsWith are reducible
871            // if all of their arguments are reducible.
872            ("format('{0} {1}', 'foo', 'bar')", true),
873            ("format('{0} {1}', 1, 2)", true),
874            ("format('{0} {1}', 1, '2')", true),
875            ("contains('foo', 'bar')", true),
876            ("startsWith('foo', 'bar')", true),
877            ("endsWith('foo', 'bar')", true),
878            ("startsWith(some.context, 'bar')", false),
879            ("endsWith(some.context, 'bar')", false),
880            // Nesting works as long as the nested call is also reducible.
881            ("format('{0} {1}', '1', format('{0}', null))", true),
882            ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
883            ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
884            ("foo", false),
885            ("foo.bar", false),
886            ("foo.bar[1]", false),
887            ("foo.bar == 'bar'", false),
888            ("foo.bar || bar || baz", false),
889            ("foo.bar && bar && baz", false),
890        ] {
891            let expr = Expr::parse(expr)?;
892            assert_eq!(expr.constant_reducible(), *reducible);
893        }
894
895        Ok(())
896    }
897
898    #[test]
899    fn test_expr_has_constant_reducible_subexpr() -> Result<()> {
900        for (expr, reducible) in &[
901            // Literals are not considered reducible subexpressions.
902            ("'foo'", false),
903            ("1", false),
904            ("true", false),
905            ("null", false),
906            // Non-reducible expressions with reducible subexpressions
907            (
908                "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
909                true,
910            ),
911            ("foobar[format('{0}', 'event')]", true),
912        ] {
913            let expr = Expr::parse(expr)?;
914            assert_eq!(expr.has_constant_reducible_subexpr(), *reducible);
915        }
916        Ok(())
917    }
918
919    #[test]
920    fn test_expr_dataflow_contexts() -> Result<()> {
921        // Trivial cases.
922        let expr = Expr::parse("foo.bar")?;
923        assert_eq!(expr.dataflow_contexts(), ["foo.bar"]);
924
925        let expr = Expr::parse("foo.bar[1]")?;
926        assert_eq!(expr.dataflow_contexts(), ["foo.bar[1]"]);
927
928        // No dataflow due to a boolean expression.
929        let expr = Expr::parse("foo.bar == 'bar'")?;
930        assert!(expr.dataflow_contexts().is_empty());
931
932        // ||: all contexts potentially expand into the evaluation.
933        let expr = Expr::parse("foo.bar || abc || d.e.f")?;
934        assert_eq!(expr.dataflow_contexts(), ["foo.bar", "abc", "d.e.f"]);
935
936        // &&: only the RHS context(s) expand into the evaluation.
937        let expr = Expr::parse("foo.bar && abc && d.e.f")?;
938        assert_eq!(expr.dataflow_contexts(), ["d.e.f"]);
939
940        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
941        assert_eq!(expr.dataflow_contexts(), ["foo.bar"]);
942
943        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
944        assert_eq!(expr.dataflow_contexts(), ["foo.bar", "foo.baz"]);
945
946        let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
947        assert_eq!(
948            expr.dataflow_contexts(),
949            ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
950        );
951
952        let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
953        assert_eq!(expr.dataflow_contexts(), ["foo.bar", "github", "github"]);
954
955        Ok(())
956    }
957}