github_actions_expressions/
lib.rs

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