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