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, ops::Deref};
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// TODO: Move this type to some kind of common crate? It's useful to have
135// a single span type everywhere, instead of the current mash of span helpers
136// and ranges we have throughout zizmor.
137/// Represents a `[start, end)` byte span for a source expression.
138#[derive(Copy, Clone, Debug, PartialEq)]
139pub struct Span {
140    /// The start of the span, inclusive.
141    pub start: usize,
142    /// The end of the span, exclusive.
143    pub end: usize,
144}
145
146impl Span {
147    /// Adjust this span by the given bias.
148    pub fn adjust(self, bias: usize) -> Self {
149        Self {
150            start: self.start + bias,
151            end: self.end + bias,
152        }
153    }
154}
155
156impl From<pest::Span<'_>> for Span {
157    fn from(span: pest::Span<'_>) -> Self {
158        Self {
159            start: span.start(),
160            end: span.end(),
161        }
162    }
163}
164
165impl From<std::ops::Range<usize>> for Span {
166    fn from(range: std::ops::Range<usize>) -> Self {
167        Self {
168            start: range.start,
169            end: range.end,
170        }
171    }
172}
173
174impl From<Span> for std::ops::Range<usize> {
175    fn from(span: Span) -> Self {
176        span.start..span.end
177    }
178}
179
180/// Represents the origin of an expression, including its source span
181/// and unparsed form.
182#[derive(Copy, Clone, Debug, PartialEq)]
183pub struct Origin<'src> {
184    /// The expression's source span.
185    pub span: Span,
186    /// The expression's unparsed form, as it appears in the source.
187    ///
188    /// This is recorded exactly as it appears in the source, *except*
189    /// that leading and trailing whitespace is stripped. This is stripped
190    /// because it's (1) non-semantic, and (2) can cause all kinds of issues
191    /// when attempting to map expressions back to YAML source features.
192    pub raw: &'src str,
193}
194
195impl<'a> Origin<'a> {
196    /// Create a new origin from the given span and raw form.
197    pub fn new(span: impl Into<Span>, raw: &'a str) -> Self {
198        Self {
199            span: span.into(),
200            raw: raw.trim(),
201        }
202    }
203}
204
205/// An expression along with its source origin (span and unparsed form).
206///
207/// Important: Because of how our parser works internally, an expression's
208/// span is its *rule*'s span, which can be larger than the expression itself.
209/// For example, `foo || bar || baz` is covered by a single rule, so each
210/// decomposed `Expr::BinOp` within it will have the same span despite
211/// logically having different sub-spans of the parent rule's span.
212#[derive(Debug, PartialEq)]
213pub struct SpannedExpr<'src> {
214    /// The expression's source origin.
215    pub origin: Origin<'src>,
216    /// The expression itself.
217    pub inner: Expr<'src>,
218}
219
220impl<'a> SpannedExpr<'a> {
221    /// Creates a new `SpannedExpr` from an expression and its span.
222    pub(crate) fn new(origin: Origin<'a>, inner: Expr<'a>) -> Self {
223        Self { origin, inner }
224    }
225
226    /// Returns the contexts in this expression that directly flow into the
227    /// expression's evaluation.
228    ///
229    /// For example `${{ foo.bar }}` returns `foo.bar` since the value
230    /// of `foo.bar` flows into the evaluation. On the other hand,
231    /// `${{ foo.bar == 'abc' }}` returns no expanded contexts,
232    /// since the value of `foo.bar` flows into a boolean evaluation
233    /// that gets expanded.
234    pub fn dataflow_contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> {
235        let mut contexts = vec![];
236
237        match self.deref() {
238            Expr::Call { func, args } => {
239                // These functions, when evaluated, produce an evaluation
240                // that includes some or all of the contexts listed in
241                // their arguments.
242                if func == "toJSON" || func == "format" || func == "join" {
243                    for arg in args {
244                        contexts.extend(arg.dataflow_contexts());
245                    }
246                }
247            }
248            // NOTE: We intentionally don't handle the `func(...).foo.bar`
249            // case differently here, since a call followed by a
250            // context access *can* flow into the evaluation.
251            // For example, `${{ fromJSON(something) }}` evaluates to
252            // `Object` but `${{ fromJSON(something).foo }}` evaluates
253            // to the contents of `something.foo`.
254            Expr::Context(ctx) => contexts.push((ctx, &self.origin)),
255            Expr::BinOp { lhs, op, rhs } => match op {
256                // With && only the RHS can flow into the evaluation as a context
257                // (rather than a boolean).
258                BinOp::And => {
259                    contexts.extend(rhs.dataflow_contexts());
260                }
261                // With || either the LHS or RHS can flow into the evaluation as a context.
262                BinOp::Or => {
263                    contexts.extend(lhs.dataflow_contexts());
264                    contexts.extend(rhs.dataflow_contexts());
265                }
266                _ => (),
267            },
268            _ => (),
269        }
270
271        contexts
272    }
273
274    /// Returns any computed indices in this expression.
275    ///
276    /// A computed index is any index operation with a non-literal
277    /// evaluation, e.g. `foo[a.b.c]`.
278    pub fn computed_indices(&self) -> Vec<&SpannedExpr<'a>> {
279        let mut index_exprs = vec![];
280
281        match self.deref() {
282            Expr::Call { func: _, args } => {
283                for arg in args {
284                    index_exprs.extend(arg.computed_indices());
285                }
286            }
287            Expr::Index(spanned_expr) => {
288                // NOTE: We consider any non-literal, non-star index computed.
289                if !spanned_expr.is_literal() && !matches!(spanned_expr.inner, Expr::Star) {
290                    index_exprs.push(self);
291                }
292            }
293            Expr::Context(context) => {
294                for part in &context.parts {
295                    index_exprs.extend(part.computed_indices());
296                }
297            }
298            Expr::BinOp { lhs, op: _, rhs } => {
299                index_exprs.extend(lhs.computed_indices());
300                index_exprs.extend(rhs.computed_indices());
301            }
302            Expr::UnOp { op: _, expr } => {
303                index_exprs.extend(expr.computed_indices());
304            }
305            _ => {}
306        }
307
308        index_exprs
309    }
310
311    /// Like [`Expr::constant_reducible`], but for all subexpressions
312    /// rather than the top-level expression.
313    ///
314    /// This has slightly different semantics than `constant_reducible`:
315    /// it doesn't include "trivially" reducible expressions like literals,
316    /// since flagging these as reducible within a larger expression
317    /// would be misleading.
318    pub fn constant_reducible_subexprs(&self) -> Vec<&SpannedExpr<'a>> {
319        if !self.is_literal() && self.constant_reducible() {
320            return vec![self];
321        }
322
323        let mut subexprs = vec![];
324
325        match self.deref() {
326            Expr::Call { func: _, args } => {
327                for arg in args {
328                    subexprs.extend(arg.constant_reducible_subexprs());
329                }
330            }
331            Expr::Context(ctx) => {
332                // contexts themselves are never reducible, but they might
333                // contains reducible index subexpressions.
334                for part in &ctx.parts {
335                    subexprs.extend(part.constant_reducible_subexprs());
336                }
337            }
338            Expr::BinOp { lhs, op: _, rhs } => {
339                subexprs.extend(lhs.constant_reducible_subexprs());
340                subexprs.extend(rhs.constant_reducible_subexprs());
341            }
342            Expr::UnOp { op: _, expr } => subexprs.extend(expr.constant_reducible_subexprs()),
343
344            Expr::Index(expr) => subexprs.extend(expr.constant_reducible_subexprs()),
345            _ => {}
346        }
347
348        subexprs
349    }
350}
351
352impl<'a> Deref for SpannedExpr<'a> {
353    type Target = Expr<'a>;
354
355    fn deref(&self) -> &Self::Target {
356        &self.inner
357    }
358}
359
360/// Represents a GitHub Actions expression.
361#[derive(Debug, PartialEq)]
362pub enum Expr<'src> {
363    /// A literal value.
364    Literal(Literal<'src>),
365    /// The `*` literal within an index or context.
366    Star,
367    /// A function call.
368    Call {
369        /// The function name, e.g. `foo` in `foo()`.
370        func: Function<'src>,
371        /// The function's arguments.
372        args: Vec<SpannedExpr<'src>>,
373    },
374    /// A context identifier component, e.g. `github` in `github.actor`.
375    Identifier(Identifier<'src>),
376    /// A context index component, e.g. `[0]` in `foo[0]`.
377    Index(Box<SpannedExpr<'src>>),
378    /// A full context reference.
379    Context(Context<'src>),
380    /// A binary operation, either logical or arithmetic.
381    BinOp {
382        /// The LHS of the binop.
383        lhs: Box<SpannedExpr<'src>>,
384        /// The binary operator.
385        op: BinOp,
386        /// The RHS of the binop.
387        rhs: Box<SpannedExpr<'src>>,
388    },
389    /// A unary operation. Negation (`!`) is currently the only `UnOp`.
390    UnOp {
391        /// The unary operator.
392        op: UnOp,
393        /// The expression to apply the operator to.
394        expr: Box<SpannedExpr<'src>>,
395    },
396}
397
398impl<'src> Expr<'src> {
399    /// Convenience API for making an [`Expr::Identifier`].
400    fn ident(i: &'src str) -> Self {
401        Self::Identifier(Identifier(i))
402    }
403
404    /// Convenience API for making an [`Expr::Context`].
405    fn context(components: impl Into<Vec<SpannedExpr<'src>>>) -> Self {
406        Self::Context(Context::new(components))
407    }
408
409    /// Returns whether the expression is a literal.
410    pub fn is_literal(&self) -> bool {
411        matches!(self, Expr::Literal(_))
412    }
413
414    /// Returns whether the expression is constant reducible.
415    ///
416    /// "Constant reducible" is similar to "constant foldable" but with
417    /// meta-evaluation semantics: the expression `5` would not be
418    /// constant foldable in a normal program (because it's already
419    /// an atom), but is "constant reducible" in a GitHub Actions expression
420    /// because an expression containing it (e.g. `${{ 5 }}`) can be elided
421    /// entirely and replaced with `5`.
422    ///
423    /// There are three kinds of reducible expressions:
424    ///
425    /// 1. Literals, which reduce to their literal value;
426    /// 2. Binops/unops with reducible subexpressions, which reduce
427    ///    to their evaluation;
428    /// 3. Select function calls where the semantics of the function
429    ///    mean that reducible arguments make the call itself reducible.
430    ///
431    /// NOTE: This implementation is sound but not complete.
432    pub fn constant_reducible(&self) -> bool {
433        match self {
434            // Literals are always reducible.
435            Expr::Literal(_) => true,
436            // Binops are reducible if their LHS and RHS are reducible.
437            Expr::BinOp { lhs, op: _, rhs } => lhs.constant_reducible() && rhs.constant_reducible(),
438            // Unops are reducible if their interior expression is reducible.
439            Expr::UnOp { op: _, expr } => expr.constant_reducible(),
440            Expr::Call { func, args } => {
441                // These functions are reducible if their arguments are reducible.
442                if func == "format"
443                    || func == "contains"
444                    || func == "startsWith"
445                    || func == "endsWith"
446                {
447                    args.iter().all(|e| e.constant_reducible())
448                } else {
449                    // TODO: fromJSON(toJSON(...)) and vice versa.
450                    false
451                }
452            }
453            // Everything else is presumed non-reducible.
454            _ => false,
455        }
456    }
457
458    /// Parses the given string into an expression.
459    pub fn parse(expr: &'src str) -> Result<SpannedExpr<'src>> {
460        // Top level `expression` is a single `or_expr`.
461        let or_expr = ExprParser::parse(Rule::expression, expr)?
462            .next()
463            .unwrap()
464            .into_inner()
465            .next()
466            .unwrap();
467
468        fn parse_pair(pair: Pair<'_, Rule>) -> Result<Box<SpannedExpr>> {
469            // We're parsing a pest grammar, which isn't left-recursive.
470            // As a result, we have constructions like
471            // `or_expr = { and_expr ~ ("||" ~ and_expr)* }`, which
472            // result in wonky ASTs like one or many (>2) headed ORs.
473            // We turn these into sane looking ASTs by punching the single
474            // pairs down to their primitive type and folding the
475            // many-headed pairs appropriately.
476            // For example, `or_expr` matches the `1` one but punches through
477            // to `Number(1)`, and also matches `true || true || true` which
478            // becomes `BinOp(BinOp(true, true), true)`.
479
480            match pair.as_rule() {
481                Rule::or_expr => {
482                    let (span, raw) = (pair.as_span(), pair.as_str());
483                    let mut pairs = pair.into_inner();
484                    let lhs = parse_pair(pairs.next().unwrap())?;
485                    pairs.try_fold(lhs, |expr, next| {
486                        Ok(SpannedExpr::new(
487                            Origin::new(span, raw),
488                            Expr::BinOp {
489                                lhs: expr,
490                                op: BinOp::Or,
491                                rhs: parse_pair(next)?,
492                            },
493                        )
494                        .into())
495                    })
496                }
497                Rule::and_expr => {
498                    let (span, raw) = (pair.as_span(), pair.as_str());
499                    let mut pairs = pair.into_inner();
500                    let lhs = parse_pair(pairs.next().unwrap())?;
501                    pairs.try_fold(lhs, |expr, next| {
502                        Ok(SpannedExpr::new(
503                            Origin::new(span, raw),
504                            Expr::BinOp {
505                                lhs: expr,
506                                op: BinOp::And,
507                                rhs: parse_pair(next)?,
508                            },
509                        )
510                        .into())
511                    })
512                }
513                Rule::eq_expr => {
514                    // eq_expr matches both `==` and `!=` and captures
515                    // them in the `eq_op` capture, so we fold with
516                    // two-tuples of (eq_op, comp_expr).
517                    let (span, raw) = (pair.as_span(), pair.as_str());
518                    let mut pairs = pair.into_inner();
519                    let lhs = parse_pair(pairs.next().unwrap())?;
520
521                    let pair_chunks = pairs.chunks(2);
522                    pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
523                        let eq_op = next.next().unwrap();
524                        let comp_expr = next.next().unwrap();
525
526                        let eq_op = match eq_op.as_str() {
527                            "==" => BinOp::Eq,
528                            "!=" => BinOp::Neq,
529                            _ => unreachable!(),
530                        };
531
532                        Ok(SpannedExpr::new(
533                            Origin::new(span, raw),
534                            Expr::BinOp {
535                                lhs: expr,
536                                op: eq_op,
537                                rhs: parse_pair(comp_expr)?,
538                            },
539                        )
540                        .into())
541                    })
542                }
543                Rule::comp_expr => {
544                    // Same as eq_expr, but with comparison operators.
545                    let (span, raw) = (pair.as_span(), pair.as_str());
546                    let mut pairs = pair.into_inner();
547                    let lhs = parse_pair(pairs.next().unwrap())?;
548
549                    let pair_chunks = pairs.chunks(2);
550                    pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
551                        let comp_op = next.next().unwrap();
552                        let unary_expr = next.next().unwrap();
553
554                        let eq_op = match comp_op.as_str() {
555                            ">" => BinOp::Gt,
556                            ">=" => BinOp::Ge,
557                            "<" => BinOp::Lt,
558                            "<=" => BinOp::Le,
559                            _ => unreachable!(),
560                        };
561
562                        Ok(SpannedExpr::new(
563                            Origin::new(span, raw),
564                            Expr::BinOp {
565                                lhs: expr,
566                                op: eq_op,
567                                rhs: parse_pair(unary_expr)?,
568                            },
569                        )
570                        .into())
571                    })
572                }
573                Rule::unary_expr => {
574                    let (span, raw) = (pair.as_span(), pair.as_str());
575                    let mut pairs = pair.into_inner();
576                    let inner_pair = pairs.next().unwrap();
577
578                    match inner_pair.as_rule() {
579                        Rule::unary_op => Ok(SpannedExpr::new(
580                            Origin::new(span, raw),
581                            Expr::UnOp {
582                                op: UnOp::Not,
583                                expr: parse_pair(pairs.next().unwrap())?,
584                            },
585                        )
586                        .into()),
587                        Rule::primary_expr => parse_pair(inner_pair),
588                        _ => unreachable!(),
589                    }
590                }
591                Rule::primary_expr => {
592                    // Punt back to the top level match to keep things simple.
593                    parse_pair(pair.into_inner().next().unwrap())
594                }
595                Rule::number => Ok(SpannedExpr::new(
596                    Origin::new(pair.as_span(), pair.as_str()),
597                    pair.as_str().parse::<f64>().unwrap().into(),
598                )
599                .into()),
600                Rule::string => {
601                    let (span, raw) = (pair.as_span(), pair.as_str());
602                    // string -> string_inner
603                    let string_inner = pair.into_inner().next().unwrap().as_str();
604
605                    // Optimization: if our string literal doesn't have any
606                    // escaped quotes in it, we can save ourselves a clone.
607                    if !string_inner.contains('\'') {
608                        Ok(SpannedExpr::new(Origin::new(span, raw), string_inner.into()).into())
609                    } else {
610                        Ok(SpannedExpr::new(
611                            Origin::new(span, raw),
612                            string_inner.replace("''", "'").into(),
613                        )
614                        .into())
615                    }
616                }
617                Rule::boolean => Ok(SpannedExpr::new(
618                    Origin::new(pair.as_span(), pair.as_str()),
619                    pair.as_str().parse::<bool>().unwrap().into(),
620                )
621                .into()),
622                Rule::null => Ok(SpannedExpr::new(
623                    Origin::new(pair.as_span(), pair.as_str()),
624                    Expr::Literal(Literal::Null),
625                )
626                .into()),
627                Rule::star => Ok(SpannedExpr::new(
628                    Origin::new(pair.as_span(), pair.as_str()),
629                    Expr::Star,
630                )
631                .into()),
632                Rule::function_call => {
633                    let (span, raw) = (pair.as_span(), pair.as_str());
634                    let mut pairs = pair.into_inner();
635
636                    let identifier = pairs.next().unwrap();
637                    let args = pairs
638                        .map(|pair| parse_pair(pair).map(|e| *e))
639                        .collect::<Result<_, _>>()?;
640
641                    Ok(SpannedExpr::new(
642                        Origin::new(span, raw),
643                        Expr::Call {
644                            func: Function(identifier.as_str()),
645                            args,
646                        },
647                    )
648                    .into())
649                }
650                Rule::identifier => Ok(SpannedExpr::new(
651                    Origin::new(pair.as_span(), pair.as_str()),
652                    Expr::ident(pair.as_str()),
653                )
654                .into()),
655                Rule::index => Ok(SpannedExpr::new(
656                    Origin::new(pair.as_span(), pair.as_str()),
657                    Expr::Index(parse_pair(pair.into_inner().next().unwrap())?),
658                )
659                .into()),
660                Rule::context => {
661                    let (span, raw) = (pair.as_span(), pair.as_str());
662                    let pairs = pair.into_inner();
663
664                    let mut inner: Vec<SpannedExpr> = pairs
665                        .map(|pair| parse_pair(pair).map(|e| *e))
666                        .collect::<Result<_, _>>()?;
667
668                    // NOTE(ww): Annoying specialization: the `context` rule
669                    // wholly encloses the `function_call` rule, so we clean up
670                    // the AST slightly to turn `Context { Call }` into just `Call`.
671                    if inner.len() == 1 && matches!(inner[0].inner, Expr::Call { .. }) {
672                        Ok(inner.remove(0).into())
673                    } else {
674                        Ok(SpannedExpr::new(Origin::new(span, raw), Expr::context(inner)).into())
675                    }
676                }
677                r => panic!("unrecognized rule: {r:?}"),
678            }
679        }
680
681        parse_pair(or_expr).map(|e| *e)
682    }
683}
684
685impl<'src> From<&'src str> for Expr<'src> {
686    fn from(s: &'src str) -> Self {
687        Expr::Literal(Literal::String(s.into()))
688    }
689}
690
691impl From<String> for Expr<'_> {
692    fn from(s: String) -> Self {
693        Expr::Literal(Literal::String(s.into()))
694    }
695}
696
697impl From<f64> for Expr<'_> {
698    fn from(n: f64) -> Self {
699        Expr::Literal(Literal::Number(n))
700    }
701}
702
703impl From<bool> for Expr<'_> {
704    fn from(b: bool) -> Self {
705        Expr::Literal(Literal::Boolean(b))
706    }
707}
708
709#[cfg(test)]
710mod tests {
711    use std::borrow::Cow;
712
713    use anyhow::Result;
714    use pest::Parser as _;
715    use pretty_assertions::assert_eq;
716
717    use crate::{Literal, Origin, SpannedExpr};
718
719    use super::{BinOp, Expr, ExprParser, Function, Rule, UnOp};
720
721    #[test]
722    fn test_literal_string_borrows() {
723        let cases = &[
724            ("'foo'", true),
725            ("'foo bar'", true),
726            ("'foo '' bar'", false),
727            ("'foo''bar'", false),
728            ("'foo''''bar'", false),
729        ];
730
731        for (expr, borrows) in cases {
732            let Expr::Literal(Literal::String(s)) = &*Expr::parse(expr).unwrap() else {
733                panic!("expected a literal string expression for {expr}");
734            };
735
736            assert!(matches!(
737                (s, borrows),
738                (Cow::Borrowed(_), true) | (Cow::Owned(_), false)
739            ));
740        }
741    }
742
743    #[test]
744    fn test_literal_as_str() {
745        let cases = &[
746            ("'foo'", "foo"),
747            ("'foo '' bar'", "foo ' bar"),
748            ("123", "123"),
749            ("123.000", "123"),
750            ("0.0", "0"),
751            ("0.1", "0.1"),
752            ("0.12345", "0.12345"),
753            ("true", "true"),
754            ("false", "false"),
755            ("null", "null"),
756        ];
757
758        for (expr, expected) in cases {
759            let Expr::Literal(expr) = &*Expr::parse(expr).unwrap() else {
760                panic!("expected a literal expression for {expr}");
761            };
762
763            assert_eq!(expr.as_str(), *expected);
764        }
765    }
766
767    #[test]
768    fn test_function_eq() {
769        let func = Function("foo");
770        assert_eq!(&func, "foo");
771        assert_eq!(&func, "FOO");
772        assert_eq!(&func, "Foo");
773
774        assert_eq!(func, Function("FOO"));
775    }
776
777    #[test]
778    fn test_parse_string_rule() {
779        let cases = &[
780            ("''", ""),
781            ("' '", " "),
782            ("''''", "''"),
783            ("'test'", "test"),
784            ("'spaces are ok'", "spaces are ok"),
785            ("'escaping '' works'", "escaping '' works"),
786        ];
787
788        for (case, expected) in cases {
789            let s = ExprParser::parse(Rule::string, case)
790                .unwrap()
791                .next()
792                .unwrap();
793
794            assert_eq!(s.into_inner().next().unwrap().as_str(), *expected);
795        }
796    }
797
798    #[test]
799    fn test_parse_context_rule() {
800        let cases = &[
801            "foo.bar",
802            "github.action_path",
803            "inputs.foo-bar",
804            "inputs.also--valid",
805            "inputs.this__too",
806            "inputs.this__too",
807            "secrets.GH_TOKEN",
808            "foo.*.bar",
809            "github.event.issue.labels.*.name",
810        ];
811
812        for case in cases {
813            assert_eq!(
814                ExprParser::parse(Rule::context, case)
815                    .unwrap()
816                    .next()
817                    .unwrap()
818                    .as_str(),
819                *case
820            );
821        }
822    }
823
824    #[test]
825    fn test_parse_call_rule() {
826        let cases = &[
827            "foo()",
828            "foo(bar)",
829            "foo(bar())",
830            "foo(1.23)",
831            "foo(1,2)",
832            "foo(1, 2)",
833            "foo(1, 2, secret.GH_TOKEN)",
834            "foo(   )",
835            "fromJSON(inputs.free-threading)",
836        ];
837
838        for case in cases {
839            assert_eq!(
840                ExprParser::parse(Rule::function_call, case)
841                    .unwrap()
842                    .next()
843                    .unwrap()
844                    .as_str(),
845                *case
846            );
847        }
848    }
849
850    #[test]
851    fn test_parse_expr_rule() -> Result<()> {
852        // Ensures that we parse multi-line expressions correctly.
853        let multiline = "github.repository_owner == 'Homebrew' &&
854        ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
855        (github.event_name == 'pull_request_target' &&
856        (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
857
858        let multiline2 = "foo.bar.baz[
859        0
860        ]";
861
862        let cases = &[
863            "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
864            "foo || bar || baz",
865            "foo || bar && baz || foo && 1 && 2 && 3 || 4",
866            "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
867            "(true || false) == true",
868            "!(!true || false)",
869            "!(!true || false) == true",
870            "(true == false) == true",
871            "(true == (false || true && (true || false))) == true",
872            "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
873            "foo()[0]",
874            "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
875            multiline,
876            "'a' == 'b' && 'c' || 'd'",
877            "github.event['a']",
878            "github.event['a' == 'b']",
879            "github.event['a' == 'b' && 'c' || 'd']",
880            "github['event']['inputs']['dry-run']",
881            "github[format('{0}', 'event')]",
882            "github['event']['inputs'][github.event.inputs.magic]",
883            "github['event']['inputs'].*",
884            "1 == 1",
885            "1 > 1",
886            "1 >= 1",
887            "matrix.node_version >= 20",
888            "true||false",
889            multiline2,
890            "fromJSON( github.event.inputs.hmm ) [ 0 ]",
891        ];
892
893        for case in cases {
894            assert_eq!(
895                ExprParser::parse(Rule::expression, case)?
896                    .next()
897                    .unwrap()
898                    .as_str(),
899                *case
900            );
901        }
902
903        Ok(())
904    }
905
906    #[test]
907    fn test_parse() {
908        let cases = &[
909            (
910                "!true || false || true",
911                SpannedExpr::new(
912                    Origin::new(0..22, "!true || false || true"),
913                    Expr::BinOp {
914                        lhs: SpannedExpr::new(
915                            Origin::new(0..22, "!true || false || true"),
916                            Expr::BinOp {
917                                lhs: SpannedExpr::new(
918                                    Origin::new(0..5, "!true"),
919                                    Expr::UnOp {
920                                        op: UnOp::Not,
921                                        expr: SpannedExpr::new(
922                                            Origin::new(1..5, "true"),
923                                            true.into(),
924                                        )
925                                        .into(),
926                                    },
927                                )
928                                .into(),
929                                op: BinOp::Or,
930                                rhs: SpannedExpr::new(Origin::new(9..14, "false"), false.into())
931                                    .into(),
932                            },
933                        )
934                        .into(),
935                        op: BinOp::Or,
936                        rhs: SpannedExpr::new(Origin::new(18..22, "true"), true.into()).into(),
937                    },
938                ),
939            ),
940            (
941                "'foo '' bar'",
942                SpannedExpr::new(
943                    Origin::new(0..12, "'foo '' bar'"),
944                    Expr::Literal(Literal::String("foo ' bar".into())),
945                ),
946            ),
947            (
948                "('foo '' bar')",
949                SpannedExpr::new(
950                    Origin::new(1..13, "'foo '' bar'"),
951                    Expr::Literal(Literal::String("foo ' bar".into())),
952                ),
953            ),
954            (
955                "((('foo '' bar')))",
956                SpannedExpr::new(
957                    Origin::new(3..15, "'foo '' bar'"),
958                    Expr::Literal(Literal::String("foo ' bar".into())),
959                ),
960            ),
961            (
962                "foo(1, 2, 3)",
963                SpannedExpr::new(
964                    Origin::new(0..12, "foo(1, 2, 3)"),
965                    Expr::Call {
966                        func: Function("foo"),
967                        args: vec![
968                            SpannedExpr::new(Origin::new(4..5, "1"), 1.0.into()),
969                            SpannedExpr::new(Origin::new(7..8, "2"), 2.0.into()),
970                            SpannedExpr::new(Origin::new(10..11, "3"), 3.0.into()),
971                        ],
972                    },
973                ),
974            ),
975            (
976                "foo.bar.baz",
977                SpannedExpr::new(
978                    Origin::new(0..11, "foo.bar.baz"),
979                    Expr::context(vec![
980                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
981                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
982                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
983                    ]),
984                ),
985            ),
986            (
987                "foo.bar.baz[1][2]",
988                SpannedExpr::new(
989                    Origin::new(0..17, "foo.bar.baz[1][2]"),
990                    Expr::context(vec![
991                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
992                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
993                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
994                        SpannedExpr::new(
995                            Origin::new(11..14, "[1]"),
996                            Expr::Index(Box::new(SpannedExpr::new(
997                                Origin::new(12..13, "1"),
998                                1.0.into(),
999                            ))),
1000                        ),
1001                        SpannedExpr::new(
1002                            Origin::new(14..17, "[2]"),
1003                            Expr::Index(Box::new(SpannedExpr::new(
1004                                Origin::new(15..16, "2"),
1005                                2.0.into(),
1006                            ))),
1007                        ),
1008                    ]),
1009                ),
1010            ),
1011            (
1012                "foo.bar.baz[*]",
1013                SpannedExpr::new(
1014                    Origin::new(0..14, "foo.bar.baz[*]"),
1015                    Expr::context([
1016                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1017                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1018                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1019                        SpannedExpr::new(
1020                            Origin::new(11..14, "[*]"),
1021                            Expr::Index(Box::new(SpannedExpr::new(
1022                                Origin::new(12..13, "*"),
1023                                Expr::Star,
1024                            ))),
1025                        ),
1026                    ]),
1027                ),
1028            ),
1029            (
1030                "vegetables.*.ediblePortions",
1031                SpannedExpr::new(
1032                    Origin::new(0..27, "vegetables.*.ediblePortions"),
1033                    Expr::context(vec![
1034                        SpannedExpr::new(
1035                            Origin::new(0..10, "vegetables"),
1036                            Expr::ident("vegetables"),
1037                        ),
1038                        SpannedExpr::new(Origin::new(11..12, "*"), Expr::Star),
1039                        SpannedExpr::new(
1040                            Origin::new(13..27, "ediblePortions"),
1041                            Expr::ident("ediblePortions"),
1042                        ),
1043                    ]),
1044                ),
1045            ),
1046            (
1047                // Sanity check for our associativity: the top level Expr here
1048                // should be `BinOp::Or`.
1049                "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1050                SpannedExpr::new(
1051                    Origin::new(
1052                        0..88,
1053                        "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1054                    ),
1055                    Expr::BinOp {
1056                        lhs: Box::new(SpannedExpr::new(
1057                            Origin::new(
1058                                0..59,
1059                                "github.ref == 'refs/heads/main' && 'value_for_main_branch'",
1060                            ),
1061                            Expr::BinOp {
1062                                lhs: Box::new(SpannedExpr::new(
1063                                    Origin::new(0..32, "github.ref == 'refs/heads/main'"),
1064                                    Expr::BinOp {
1065                                        lhs: Box::new(SpannedExpr::new(
1066                                            Origin::new(0..10, "github.ref"),
1067                                            Expr::context(vec![
1068                                                SpannedExpr::new(
1069                                                    Origin::new(0..6, "github"),
1070                                                    Expr::ident("github"),
1071                                                ),
1072                                                SpannedExpr::new(
1073                                                    Origin::new(7..10, "ref"),
1074                                                    Expr::ident("ref"),
1075                                                ),
1076                                            ]),
1077                                        )),
1078                                        op: BinOp::Eq,
1079                                        rhs: Box::new(SpannedExpr::new(
1080                                            Origin::new(14..31, "'refs/heads/main'"),
1081                                            Expr::Literal(Literal::String(
1082                                                "refs/heads/main".into(),
1083                                            )),
1084                                        )),
1085                                    },
1086                                )),
1087                                op: BinOp::And,
1088                                rhs: Box::new(SpannedExpr::new(
1089                                    Origin::new(35..58, "'value_for_main_branch'"),
1090                                    Expr::Literal(Literal::String("value_for_main_branch".into())),
1091                                )),
1092                            },
1093                        )),
1094                        op: BinOp::Or,
1095                        rhs: Box::new(SpannedExpr::new(
1096                            Origin::new(62..88, "'value_for_other_branches'"),
1097                            Expr::Literal(Literal::String("value_for_other_branches".into())),
1098                        )),
1099                    },
1100                ),
1101            ),
1102            (
1103                "(true || false) == true",
1104                SpannedExpr::new(
1105                    Origin::new(0..23, "(true || false) == true"),
1106                    Expr::BinOp {
1107                        lhs: Box::new(SpannedExpr::new(
1108                            Origin::new(1..14, "true || false"),
1109                            Expr::BinOp {
1110                                lhs: Box::new(SpannedExpr::new(
1111                                    Origin::new(1..5, "true"),
1112                                    true.into(),
1113                                )),
1114                                op: BinOp::Or,
1115                                rhs: Box::new(SpannedExpr::new(
1116                                    Origin::new(9..14, "false"),
1117                                    false.into(),
1118                                )),
1119                            },
1120                        )),
1121                        op: BinOp::Eq,
1122                        rhs: Box::new(SpannedExpr::new(Origin::new(19..23, "true"), true.into())),
1123                    },
1124                ),
1125            ),
1126            (
1127                "!(!true || false)",
1128                SpannedExpr::new(
1129                    Origin::new(0..17, "!(!true || false)"),
1130                    Expr::UnOp {
1131                        op: UnOp::Not,
1132                        expr: Box::new(SpannedExpr::new(
1133                            Origin::new(2..16, "!true || false"),
1134                            Expr::BinOp {
1135                                lhs: Box::new(SpannedExpr::new(
1136                                    Origin::new(2..7, "!true"),
1137                                    Expr::UnOp {
1138                                        op: UnOp::Not,
1139                                        expr: Box::new(SpannedExpr::new(
1140                                            Origin::new(3..7, "true"),
1141                                            true.into(),
1142                                        )),
1143                                    },
1144                                )),
1145                                op: BinOp::Or,
1146                                rhs: Box::new(SpannedExpr::new(
1147                                    Origin::new(11..16, "false"),
1148                                    false.into(),
1149                                )),
1150                            },
1151                        )),
1152                    },
1153                ),
1154            ),
1155            (
1156                "foobar[format('{0}', 'event')]",
1157                SpannedExpr::new(
1158                    Origin::new(0..30, "foobar[format('{0}', 'event')]"),
1159                    Expr::context([
1160                        SpannedExpr::new(Origin::new(0..6, "foobar"), Expr::ident("foobar")),
1161                        SpannedExpr::new(
1162                            Origin::new(6..30, "[format('{0}', 'event')]"),
1163                            Expr::Index(Box::new(SpannedExpr::new(
1164                                Origin::new(7..29, "format('{0}', 'event')"),
1165                                Expr::Call {
1166                                    func: Function("format"),
1167                                    args: vec![
1168                                        SpannedExpr::new(
1169                                            Origin::new(14..19, "'{0}'"),
1170                                            Expr::from("{0}"),
1171                                        ),
1172                                        SpannedExpr::new(
1173                                            Origin::new(21..28, "'event'"),
1174                                            Expr::from("event"),
1175                                        ),
1176                                    ],
1177                                },
1178                            ))),
1179                        ),
1180                    ]),
1181                ),
1182            ),
1183            (
1184                "github.actor_id == '49699333'",
1185                SpannedExpr::new(
1186                    Origin::new(0..29, "github.actor_id == '49699333'"),
1187                    Expr::BinOp {
1188                        lhs: SpannedExpr::new(
1189                            Origin::new(0..15, "github.actor_id"),
1190                            Expr::context(vec![
1191                                SpannedExpr::new(
1192                                    Origin::new(0..6, "github"),
1193                                    Expr::ident("github"),
1194                                ),
1195                                SpannedExpr::new(
1196                                    Origin::new(7..15, "actor_id"),
1197                                    Expr::ident("actor_id"),
1198                                ),
1199                            ]),
1200                        )
1201                        .into(),
1202                        op: BinOp::Eq,
1203                        rhs: Box::new(SpannedExpr::new(
1204                            Origin::new(19..29, "'49699333'"),
1205                            Expr::from("49699333"),
1206                        )),
1207                    },
1208                ),
1209            ),
1210        ];
1211
1212        for (case, expr) in cases {
1213            assert_eq!(*expr, Expr::parse(case).unwrap());
1214        }
1215    }
1216
1217    #[test]
1218    fn test_expr_constant_reducible() -> Result<()> {
1219        for (expr, reducible) in &[
1220            ("'foo'", true),
1221            ("1", true),
1222            ("true", true),
1223            ("null", true),
1224            // boolean and unary expressions of all literals are
1225            // always reducible.
1226            ("!true", true),
1227            ("!null", true),
1228            ("true && false", true),
1229            ("true || false", true),
1230            ("null && !null && true", true),
1231            // formats/contains/startsWith/endsWith are reducible
1232            // if all of their arguments are reducible.
1233            ("format('{0} {1}', 'foo', 'bar')", true),
1234            ("format('{0} {1}', 1, 2)", true),
1235            ("format('{0} {1}', 1, '2')", true),
1236            ("contains('foo', 'bar')", true),
1237            ("startsWith('foo', 'bar')", true),
1238            ("endsWith('foo', 'bar')", true),
1239            ("startsWith(some.context, 'bar')", false),
1240            ("endsWith(some.context, 'bar')", false),
1241            // Nesting works as long as the nested call is also reducible.
1242            ("format('{0} {1}', '1', format('{0}', null))", true),
1243            ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
1244            ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
1245            ("foo", false),
1246            ("foo.bar", false),
1247            ("foo.bar[1]", false),
1248            ("foo.bar == 'bar'", false),
1249            ("foo.bar || bar || baz", false),
1250            ("foo.bar && bar && baz", false),
1251        ] {
1252            let expr = Expr::parse(expr)?;
1253            assert_eq!(expr.constant_reducible(), *reducible);
1254        }
1255
1256        Ok(())
1257    }
1258
1259    #[test]
1260    fn test_expr_has_constant_reducible_subexpr() -> Result<()> {
1261        for (expr, reducible) in &[
1262            // Literals are not considered reducible subexpressions.
1263            ("'foo'", false),
1264            ("1", false),
1265            ("true", false),
1266            ("null", false),
1267            // Non-reducible expressions with reducible subexpressions
1268            (
1269                "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
1270                true,
1271            ),
1272            ("foobar[format('{0}', 'event')]", true),
1273        ] {
1274            let expr = Expr::parse(expr)?;
1275            assert_eq!(!expr.constant_reducible_subexprs().is_empty(), *reducible);
1276        }
1277        Ok(())
1278    }
1279
1280    #[test]
1281    fn test_expr_dataflow_contexts() -> Result<()> {
1282        // Trivial cases.
1283        let expr = Expr::parse("foo.bar")?;
1284        assert_eq!(
1285            expr.dataflow_contexts()
1286                .iter()
1287                .map(|t| t.1.raw)
1288                .collect::<Vec<_>>(),
1289            ["foo.bar"]
1290        );
1291
1292        let expr = Expr::parse("foo.bar[1]")?;
1293        assert_eq!(
1294            expr.dataflow_contexts()
1295                .iter()
1296                .map(|t| t.1.raw)
1297                .collect::<Vec<_>>(),
1298            ["foo.bar[1]"]
1299        );
1300
1301        // No dataflow due to a boolean expression.
1302        let expr = Expr::parse("foo.bar == 'bar'")?;
1303        assert!(expr.dataflow_contexts().is_empty());
1304
1305        // ||: all contexts potentially expand into the evaluation.
1306        let expr = Expr::parse("foo.bar || abc || d.e.f")?;
1307        assert_eq!(
1308            expr.dataflow_contexts()
1309                .iter()
1310                .map(|t| t.1.raw)
1311                .collect::<Vec<_>>(),
1312            ["foo.bar", "abc", "d.e.f"]
1313        );
1314
1315        // &&: only the RHS context(s) expand into the evaluation.
1316        let expr = Expr::parse("foo.bar && abc && d.e.f")?;
1317        assert_eq!(
1318            expr.dataflow_contexts()
1319                .iter()
1320                .map(|t| t.1.raw)
1321                .collect::<Vec<_>>(),
1322            ["d.e.f"]
1323        );
1324
1325        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
1326        assert_eq!(
1327            expr.dataflow_contexts()
1328                .iter()
1329                .map(|t| t.1.raw)
1330                .collect::<Vec<_>>(),
1331            ["foo.bar"]
1332        );
1333
1334        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
1335        assert_eq!(
1336            expr.dataflow_contexts()
1337                .iter()
1338                .map(|t| t.1.raw)
1339                .collect::<Vec<_>>(),
1340            ["foo.bar", "foo.baz"]
1341        );
1342
1343        let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
1344        assert_eq!(
1345            expr.dataflow_contexts()
1346                .iter()
1347                .map(|t| t.1.raw)
1348                .collect::<Vec<_>>(),
1349            ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
1350        );
1351
1352        let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
1353        assert_eq!(
1354            expr.dataflow_contexts()
1355                .iter()
1356                .map(|t| t.1.raw)
1357                .collect::<Vec<_>>(),
1358            ["foo.bar", "github", "github"]
1359        );
1360
1361        Ok(())
1362    }
1363
1364    #[test]
1365    fn test_spannedexpr_computed_indices() -> Result<()> {
1366        for (expr, computed_indices) in &[
1367            ("foo.bar", vec![]),
1368            ("foo.bar[1]", vec![]),
1369            ("foo.bar[*]", vec![]),
1370            ("foo.bar[abc]", vec!["[abc]"]),
1371            (
1372                "foo.bar[format('{0}', 'foo')]",
1373                vec!["[format('{0}', 'foo')]"],
1374            ),
1375            ("foo.bar[abc].def[efg]", vec!["[abc]", "[efg]"]),
1376        ] {
1377            let expr = Expr::parse(expr)?;
1378
1379            assert_eq!(
1380                expr.computed_indices()
1381                    .iter()
1382                    .map(|e| e.origin.raw)
1383                    .collect::<Vec<_>>(),
1384                *computed_indices
1385            );
1386        }
1387
1388        Ok(())
1389    }
1390}