github_actions_expressions/
lib.rs

1//! GitHub Actions expression parsing and analysis.
2
3#![forbid(unsafe_code)]
4#![deny(missing_docs)]
5
6use std::ops::Deref;
7
8use crate::{
9    call::{Call, Function},
10    context::Context,
11    identifier::Identifier,
12    literal::Literal,
13    op::{BinOp, UnOp},
14};
15
16use self::parser::{ExprParser, Rule};
17use anyhow::Result;
18use itertools::Itertools;
19use pest::{Parser, iterators::Pair};
20
21pub mod call;
22pub mod context;
23pub mod identifier;
24pub mod literal;
25pub mod op;
26
27// Isolates the ExprParser, Rule and other generated types
28// so that we can do `missing_docs` at the top-level.
29// See: https://github.com/pest-parser/pest/issues/326
30mod parser {
31    use pest_derive::Parser;
32
33    /// A parser for GitHub Actions' expression language.
34    #[derive(Parser)]
35    #[grammar = "expr.pest"]
36    pub struct ExprParser;
37}
38
39/// Represents the origin of an expression, including its source span
40/// and unparsed form.
41#[derive(Copy, Clone, Debug, PartialEq)]
42pub struct Origin<'src> {
43    /// The expression's source span.
44    pub span: subfeature::Span,
45    /// The expression's unparsed form, as it appears in the source.
46    ///
47    /// This is recorded exactly as it appears in the source, *except*
48    /// that leading and trailing whitespace is stripped. This is stripped
49    /// because it's (1) non-semantic, and (2) can cause all kinds of issues
50    /// when attempting to map expressions back to YAML source features.
51    pub raw: &'src str,
52}
53
54impl<'a> Origin<'a> {
55    /// Create a new origin from the given span and raw form.
56    pub fn new(span: impl Into<subfeature::Span>, raw: &'a str) -> Self {
57        Self {
58            span: span.into(),
59            raw: raw.trim(),
60        }
61    }
62}
63
64/// An expression along with its source origin (span and unparsed form).
65///
66/// Important: Because of how our parser works internally, an expression's
67/// span is its *rule*'s span, which can be larger than the expression itself.
68/// For example, `foo || bar || baz` is covered by a single rule, so each
69/// decomposed `Expr::BinOp` within it will have the same span despite
70/// logically having different sub-spans of the parent rule's span.
71#[derive(Debug, PartialEq)]
72pub struct SpannedExpr<'src> {
73    /// The expression's source origin.
74    pub origin: Origin<'src>,
75    /// The expression itself.
76    pub inner: Expr<'src>,
77}
78
79impl<'a> SpannedExpr<'a> {
80    /// Creates a new `SpannedExpr` from an expression and its span.
81    pub(crate) fn new(origin: Origin<'a>, inner: Expr<'a>) -> Self {
82        Self { origin, inner }
83    }
84
85    /// Returns the contexts in this expression that directly flow into the
86    /// expression's evaluation.
87    ///
88    /// For example `${{ foo.bar }}` returns `foo.bar` since the value
89    /// of `foo.bar` flows into the evaluation. On the other hand,
90    /// `${{ foo.bar == 'abc' }}` returns no expanded contexts,
91    /// since the value of `foo.bar` flows into a boolean evaluation
92    /// that gets expanded.
93    pub fn dataflow_contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> {
94        let mut contexts = vec![];
95
96        match self.deref() {
97            Expr::Call(Call { func, args }) => {
98                // These functions, when evaluated, produce an evaluation
99                // that includes some or all of the contexts listed in
100                // their arguments.
101                if func == "toJSON" || func == "format" || func == "join" {
102                    for arg in args {
103                        contexts.extend(arg.dataflow_contexts());
104                    }
105                }
106            }
107            // NOTE: We intentionally don't handle the `func(...).foo.bar`
108            // case differently here, since a call followed by a
109            // context access *can* flow into the evaluation.
110            // For example, `${{ fromJSON(something) }}` evaluates to
111            // `Object` but `${{ fromJSON(something).foo }}` evaluates
112            // to the contents of `something.foo`.
113            Expr::Context(ctx) => contexts.push((ctx, &self.origin)),
114            Expr::BinOp { lhs, op, rhs } => match op {
115                // With && only the RHS can flow into the evaluation as a context
116                // (rather than a boolean).
117                BinOp::And => {
118                    contexts.extend(rhs.dataflow_contexts());
119                }
120                // With || either the LHS or RHS can flow into the evaluation as a context.
121                BinOp::Or => {
122                    contexts.extend(lhs.dataflow_contexts());
123                    contexts.extend(rhs.dataflow_contexts());
124                }
125                _ => (),
126            },
127            _ => (),
128        }
129
130        contexts
131    }
132
133    /// Returns any computed indices in this expression.
134    ///
135    /// A computed index is any index operation with a non-literal
136    /// evaluation, e.g. `foo[a.b.c]`.
137    pub fn computed_indices(&self) -> Vec<&SpannedExpr<'a>> {
138        let mut index_exprs = vec![];
139
140        match self.deref() {
141            Expr::Call(Call { func: _, args }) => {
142                for arg in args {
143                    index_exprs.extend(arg.computed_indices());
144                }
145            }
146            Expr::Index(spanned_expr) => {
147                // NOTE: We consider any non-literal, non-star index computed.
148                if !spanned_expr.is_literal() && !matches!(spanned_expr.inner, Expr::Star) {
149                    index_exprs.push(self);
150                }
151            }
152            Expr::Context(context) => {
153                for part in &context.parts {
154                    index_exprs.extend(part.computed_indices());
155                }
156            }
157            Expr::BinOp { lhs, op: _, rhs } => {
158                index_exprs.extend(lhs.computed_indices());
159                index_exprs.extend(rhs.computed_indices());
160            }
161            Expr::UnOp { op: _, expr } => {
162                index_exprs.extend(expr.computed_indices());
163            }
164            _ => {}
165        }
166
167        index_exprs
168    }
169
170    /// Like [`Expr::constant_reducible`], but for all subexpressions
171    /// rather than the top-level expression.
172    ///
173    /// This has slightly different semantics than `constant_reducible`:
174    /// it doesn't include "trivially" reducible expressions like literals,
175    /// since flagging these as reducible within a larger expression
176    /// would be misleading.
177    pub fn constant_reducible_subexprs(&self) -> Vec<&SpannedExpr<'a>> {
178        if !self.is_literal() && self.constant_reducible() {
179            return vec![self];
180        }
181
182        let mut subexprs = vec![];
183
184        match self.deref() {
185            Expr::Call(Call { func: _, args }) => {
186                for arg in args {
187                    subexprs.extend(arg.constant_reducible_subexprs());
188                }
189            }
190            Expr::Context(ctx) => {
191                // contexts themselves are never reducible, but they might
192                // contains reducible index subexpressions.
193                for part in &ctx.parts {
194                    subexprs.extend(part.constant_reducible_subexprs());
195                }
196            }
197            Expr::BinOp { lhs, op: _, rhs } => {
198                subexprs.extend(lhs.constant_reducible_subexprs());
199                subexprs.extend(rhs.constant_reducible_subexprs());
200            }
201            Expr::UnOp { op: _, expr } => subexprs.extend(expr.constant_reducible_subexprs()),
202
203            Expr::Index(expr) => subexprs.extend(expr.constant_reducible_subexprs()),
204            _ => {}
205        }
206
207        subexprs
208    }
209}
210
211impl<'a> Deref for SpannedExpr<'a> {
212    type Target = Expr<'a>;
213
214    fn deref(&self) -> &Self::Target {
215        &self.inner
216    }
217}
218
219impl<'doc> From<&SpannedExpr<'doc>> for subfeature::Fragment<'doc> {
220    fn from(expr: &SpannedExpr<'doc>) -> Self {
221        Self::new(expr.origin.raw)
222    }
223}
224
225/// Represents a GitHub Actions expression.
226#[derive(Debug, PartialEq)]
227pub enum Expr<'src> {
228    /// A literal value.
229    Literal(Literal<'src>),
230    /// The `*` literal within an index or context.
231    Star,
232    /// A function call.
233    Call(Call<'src>),
234    /// A context identifier component, e.g. `github` in `github.actor`.
235    Identifier(Identifier<'src>),
236    /// A context index component, e.g. `[0]` in `foo[0]`.
237    Index(Box<SpannedExpr<'src>>),
238    /// A full context reference.
239    Context(Context<'src>),
240    /// A binary operation, either logical or arithmetic.
241    BinOp {
242        /// The LHS of the binop.
243        lhs: Box<SpannedExpr<'src>>,
244        /// The binary operator.
245        op: BinOp,
246        /// The RHS of the binop.
247        rhs: Box<SpannedExpr<'src>>,
248    },
249    /// A unary operation. Negation (`!`) is currently the only `UnOp`.
250    UnOp {
251        /// The unary operator.
252        op: UnOp,
253        /// The expression to apply the operator to.
254        expr: Box<SpannedExpr<'src>>,
255    },
256}
257
258impl<'src> Expr<'src> {
259    /// Convenience API for making an [`Expr::Identifier`].
260    fn ident(i: &'src str) -> Self {
261        Self::Identifier(Identifier(i))
262    }
263
264    /// Convenience API for making an [`Expr::Context`].
265    fn context(components: impl Into<Vec<SpannedExpr<'src>>>) -> Self {
266        Self::Context(Context::new(components))
267    }
268
269    /// Returns whether the expression is a literal.
270    pub fn is_literal(&self) -> bool {
271        matches!(self, Expr::Literal(_))
272    }
273
274    /// Returns whether the expression is constant reducible.
275    ///
276    /// "Constant reducible" is similar to "constant foldable" but with
277    /// meta-evaluation semantics: the expression `5` would not be
278    /// constant foldable in a normal program (because it's already
279    /// an atom), but is "constant reducible" in a GitHub Actions expression
280    /// because an expression containing it (e.g. `${{ 5 }}`) can be elided
281    /// entirely and replaced with `5`.
282    ///
283    /// There are three kinds of reducible expressions:
284    ///
285    /// 1. Literals, which reduce to their literal value;
286    /// 2. Binops/unops with reducible subexpressions, which reduce
287    ///    to their evaluation;
288    /// 3. Select function calls where the semantics of the function
289    ///    mean that reducible arguments make the call itself reducible.
290    ///
291    /// NOTE: This implementation is sound but not complete.
292    pub fn constant_reducible(&self) -> bool {
293        match self {
294            // Literals are always reducible.
295            Expr::Literal(_) => true,
296            // Binops are reducible if their LHS and RHS are reducible.
297            Expr::BinOp { lhs, op: _, rhs } => lhs.constant_reducible() && rhs.constant_reducible(),
298            // Unops are reducible if their interior expression is reducible.
299            Expr::UnOp { op: _, expr } => expr.constant_reducible(),
300            Expr::Call(Call { func, args }) => {
301                // These functions are reducible if their arguments are reducible.
302                if func == "format"
303                    || func == "contains"
304                    || func == "startsWith"
305                    || func == "endsWith"
306                    || func == "toJSON"
307                    || func == "fromJSON"
308                    || func == "join"
309                {
310                    args.iter().all(|e| e.constant_reducible())
311                } else {
312                    false
313                }
314            }
315            // Everything else is presumed non-reducible.
316            _ => false,
317        }
318    }
319
320    /// Parses the given string into an expression.
321    pub fn parse(expr: &'src str) -> Result<SpannedExpr<'src>> {
322        // Top level `expression` is a single `or_expr`.
323        let or_expr = ExprParser::parse(Rule::expression, expr)?
324            .next()
325            .unwrap()
326            .into_inner()
327            .next()
328            .unwrap();
329
330        fn parse_pair(pair: Pair<'_, Rule>) -> Result<Box<SpannedExpr<'_>>> {
331            // We're parsing a pest grammar, which isn't left-recursive.
332            // As a result, we have constructions like
333            // `or_expr = { and_expr ~ ("||" ~ and_expr)* }`, which
334            // result in wonky ASTs like one or many (>2) headed ORs.
335            // We turn these into sane looking ASTs by punching the single
336            // pairs down to their primitive type and folding the
337            // many-headed pairs appropriately.
338            // For example, `or_expr` matches the `1` one but punches through
339            // to `Number(1)`, and also matches `true || true || true` which
340            // becomes `BinOp(BinOp(true, true), true)`.
341
342            match pair.as_rule() {
343                Rule::or_expr => {
344                    let (span, raw) = (pair.as_span(), pair.as_str());
345                    let mut pairs = pair.into_inner();
346                    let lhs = parse_pair(pairs.next().unwrap())?;
347                    pairs.try_fold(lhs, |expr, next| {
348                        Ok(SpannedExpr::new(
349                            Origin::new(span.start()..span.end(), raw),
350                            Expr::BinOp {
351                                lhs: expr,
352                                op: BinOp::Or,
353                                rhs: parse_pair(next)?,
354                            },
355                        )
356                        .into())
357                    })
358                }
359                Rule::and_expr => {
360                    let (span, raw) = (pair.as_span(), pair.as_str());
361                    let mut pairs = pair.into_inner();
362                    let lhs = parse_pair(pairs.next().unwrap())?;
363                    pairs.try_fold(lhs, |expr, next| {
364                        Ok(SpannedExpr::new(
365                            Origin::new(span.start()..span.end(), raw),
366                            Expr::BinOp {
367                                lhs: expr,
368                                op: BinOp::And,
369                                rhs: parse_pair(next)?,
370                            },
371                        )
372                        .into())
373                    })
374                }
375                Rule::eq_expr => {
376                    // eq_expr matches both `==` and `!=` and captures
377                    // them in the `eq_op` capture, so we fold with
378                    // two-tuples of (eq_op, comp_expr).
379                    let (span, raw) = (pair.as_span(), pair.as_str());
380                    let mut pairs = pair.into_inner();
381                    let lhs = parse_pair(pairs.next().unwrap())?;
382
383                    let pair_chunks = pairs.chunks(2);
384                    pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
385                        let eq_op = next.next().unwrap();
386                        let comp_expr = next.next().unwrap();
387
388                        let eq_op = match eq_op.as_str() {
389                            "==" => BinOp::Eq,
390                            "!=" => BinOp::Neq,
391                            _ => unreachable!(),
392                        };
393
394                        Ok(SpannedExpr::new(
395                            Origin::new(span.start()..span.end(), raw),
396                            Expr::BinOp {
397                                lhs: expr,
398                                op: eq_op,
399                                rhs: parse_pair(comp_expr)?,
400                            },
401                        )
402                        .into())
403                    })
404                }
405                Rule::comp_expr => {
406                    // Same as eq_expr, but with comparison operators.
407                    let (span, raw) = (pair.as_span(), pair.as_str());
408                    let mut pairs = pair.into_inner();
409                    let lhs = parse_pair(pairs.next().unwrap())?;
410
411                    let pair_chunks = pairs.chunks(2);
412                    pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
413                        let comp_op = next.next().unwrap();
414                        let unary_expr = next.next().unwrap();
415
416                        let eq_op = match comp_op.as_str() {
417                            ">" => BinOp::Gt,
418                            ">=" => BinOp::Ge,
419                            "<" => BinOp::Lt,
420                            "<=" => BinOp::Le,
421                            _ => unreachable!(),
422                        };
423
424                        Ok(SpannedExpr::new(
425                            Origin::new(span.start()..span.end(), raw),
426                            Expr::BinOp {
427                                lhs: expr,
428                                op: eq_op,
429                                rhs: parse_pair(unary_expr)?,
430                            },
431                        )
432                        .into())
433                    })
434                }
435                Rule::unary_expr => {
436                    let (span, raw) = (pair.as_span(), pair.as_str());
437                    let mut pairs = pair.into_inner();
438                    let inner_pair = pairs.next().unwrap();
439
440                    match inner_pair.as_rule() {
441                        Rule::unary_op => Ok(SpannedExpr::new(
442                            Origin::new(span.start()..span.end(), raw),
443                            Expr::UnOp {
444                                op: UnOp::Not,
445                                expr: parse_pair(pairs.next().unwrap())?,
446                            },
447                        )
448                        .into()),
449                        Rule::primary_expr => parse_pair(inner_pair),
450                        _ => unreachable!(),
451                    }
452                }
453                Rule::primary_expr => {
454                    // Punt back to the top level match to keep things simple.
455                    parse_pair(pair.into_inner().next().unwrap())
456                }
457                Rule::number => Ok(SpannedExpr::new(
458                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
459                    pair.as_str().parse::<f64>().unwrap().into(),
460                )
461                .into()),
462                Rule::string => {
463                    let (span, raw) = (pair.as_span(), pair.as_str());
464                    // string -> string_inner
465                    let string_inner = pair.into_inner().next().unwrap().as_str();
466
467                    // Optimization: if our string literal doesn't have any
468                    // escaped quotes in it, we can save ourselves a clone.
469                    if !string_inner.contains('\'') {
470                        Ok(SpannedExpr::new(
471                            Origin::new(span.start()..span.end(), raw),
472                            string_inner.into(),
473                        )
474                        .into())
475                    } else {
476                        Ok(SpannedExpr::new(
477                            Origin::new(span.start()..span.end(), raw),
478                            string_inner.replace("''", "'").into(),
479                        )
480                        .into())
481                    }
482                }
483                Rule::boolean => Ok(SpannedExpr::new(
484                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
485                    pair.as_str().parse::<bool>().unwrap().into(),
486                )
487                .into()),
488                Rule::null => Ok(SpannedExpr::new(
489                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
490                    Expr::Literal(Literal::Null),
491                )
492                .into()),
493                Rule::star => Ok(SpannedExpr::new(
494                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
495                    Expr::Star,
496                )
497                .into()),
498                Rule::function_call => {
499                    let (span, raw) = (pair.as_span(), pair.as_str());
500                    let mut pairs = pair.into_inner();
501
502                    let identifier = pairs.next().unwrap();
503                    let args = pairs
504                        .map(|pair| parse_pair(pair).map(|e| *e))
505                        .collect::<Result<_, _>>()?;
506
507                    Ok(SpannedExpr::new(
508                        Origin::new(span.start()..span.end(), raw),
509                        Expr::Call(Call {
510                            func: Function(identifier.as_str()),
511                            args,
512                        }),
513                    )
514                    .into())
515                }
516                Rule::identifier => Ok(SpannedExpr::new(
517                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
518                    Expr::ident(pair.as_str()),
519                )
520                .into()),
521                Rule::index => Ok(SpannedExpr::new(
522                    Origin::new(pair.as_span().start()..pair.as_span().end(), pair.as_str()),
523                    Expr::Index(parse_pair(pair.into_inner().next().unwrap())?),
524                )
525                .into()),
526                Rule::context => {
527                    let (span, raw) = (pair.as_span(), pair.as_str());
528                    let pairs = pair.into_inner();
529
530                    let mut inner: Vec<SpannedExpr> = pairs
531                        .map(|pair| parse_pair(pair).map(|e| *e))
532                        .collect::<Result<_, _>>()?;
533
534                    // NOTE(ww): Annoying specialization: the `context` rule
535                    // wholly encloses the `function_call` rule, so we clean up
536                    // the AST slightly to turn `Context { Call }` into just `Call`.
537                    if inner.len() == 1 && matches!(inner[0].inner, Expr::Call { .. }) {
538                        Ok(inner.remove(0).into())
539                    } else {
540                        Ok(SpannedExpr::new(
541                            Origin::new(span.start()..span.end(), raw),
542                            Expr::context(inner),
543                        )
544                        .into())
545                    }
546                }
547                r => panic!("unrecognized rule: {r:?}"),
548            }
549        }
550
551        parse_pair(or_expr).map(|e| *e)
552    }
553}
554
555impl<'src> From<&'src str> for Expr<'src> {
556    fn from(s: &'src str) -> Self {
557        Expr::Literal(Literal::String(s.into()))
558    }
559}
560
561impl From<String> for Expr<'_> {
562    fn from(s: String) -> Self {
563        Expr::Literal(Literal::String(s.into()))
564    }
565}
566
567impl From<f64> for Expr<'_> {
568    fn from(n: f64) -> Self {
569        Expr::Literal(Literal::Number(n))
570    }
571}
572
573impl From<bool> for Expr<'_> {
574    fn from(b: bool) -> Self {
575        Expr::Literal(Literal::Boolean(b))
576    }
577}
578
579/// The result of evaluating a GitHub Actions expression.
580///
581/// This type represents the possible values that can result from evaluating
582/// GitHub Actions expressions.
583#[derive(Debug, Clone, PartialEq)]
584pub enum Evaluation {
585    /// A string value (includes both string literals and stringified other types).
586    String(String),
587    /// A numeric value.
588    Number(f64),
589    /// A boolean value.
590    Boolean(bool),
591    /// The null value.
592    Null,
593    /// An array value. Array evaluations can only be realized through `fromJSON`.
594    Array(Vec<Evaluation>),
595    /// An object value. Object evaluations can only be realized through `fromJSON`.
596    Object(std::collections::HashMap<String, Evaluation>),
597}
598
599impl TryFrom<serde_json::Value> for Evaluation {
600    type Error = ();
601
602    fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
603        match value {
604            serde_json::Value::Null => Ok(Evaluation::Null),
605            serde_json::Value::Bool(b) => Ok(Evaluation::Boolean(b)),
606            serde_json::Value::Number(n) => {
607                if let Some(f) = n.as_f64() {
608                    Ok(Evaluation::Number(f))
609                } else {
610                    Err(())
611                }
612            }
613            serde_json::Value::String(s) => Ok(Evaluation::String(s)),
614            serde_json::Value::Array(arr) => {
615                let elements = arr
616                    .into_iter()
617                    .map(|elem| elem.try_into())
618                    .collect::<Result<_, _>>()?;
619                Ok(Evaluation::Array(elements))
620            }
621            serde_json::Value::Object(obj) => {
622                let mut map = std::collections::HashMap::new();
623                for (key, value) in obj {
624                    map.insert(key, value.try_into()?);
625                }
626                Ok(Evaluation::Object(map))
627            }
628        }
629    }
630}
631
632impl TryInto<serde_json::Value> for Evaluation {
633    type Error = ();
634
635    fn try_into(self) -> Result<serde_json::Value, Self::Error> {
636        match self {
637            Evaluation::Null => Ok(serde_json::Value::Null),
638            Evaluation::Boolean(b) => Ok(serde_json::Value::Bool(b)),
639            Evaluation::Number(n) => {
640                // NOTE: serde_json has different internal representations
641                // for integers and floats, so we need to handle both cases
642                // to ensure we serialize integers without a decimal point.
643                if n.fract() == 0.0 {
644                    Ok(serde_json::Value::Number(serde_json::Number::from(
645                        n as i64,
646                    )))
647                } else if let Some(num) = serde_json::Number::from_f64(n) {
648                    Ok(serde_json::Value::Number(num))
649                } else {
650                    Err(())
651                }
652            }
653            Evaluation::String(s) => Ok(serde_json::Value::String(s)),
654            Evaluation::Array(arr) => {
655                let elements = arr
656                    .into_iter()
657                    .map(|elem| elem.try_into())
658                    .collect::<Result<_, _>>()?;
659                Ok(serde_json::Value::Array(elements))
660            }
661            Evaluation::Object(obj) => {
662                let mut map = serde_json::Map::new();
663                for (key, value) in obj {
664                    map.insert(key, value.try_into()?);
665                }
666                Ok(serde_json::Value::Object(map))
667            }
668        }
669    }
670}
671
672impl Evaluation {
673    /// Convert to a boolean following GitHub Actions truthiness rules.
674    ///
675    /// GitHub Actions truthiness:
676    /// - false and null are falsy
677    /// - Numbers: 0 is falsy, everything else is truthy
678    /// - Strings: empty string is falsy, everything else is truthy
679    /// - Arrays and dictionaries are always truthy (non-empty objects)
680    pub fn as_boolean(&self) -> bool {
681        match self {
682            Evaluation::Boolean(b) => *b,
683            Evaluation::Null => false,
684            Evaluation::Number(n) => *n != 0.0,
685            Evaluation::String(s) => !s.is_empty(),
686            // Arrays and objects are always truthy, even if empty.
687            Evaluation::Array(_) | Evaluation::Object(_) => true,
688        }
689    }
690
691    /// Convert to a number following GitHub Actions conversion rules.
692    ///
693    /// See: <https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators>
694    pub fn as_number(&self) -> f64 {
695        match self {
696            Evaluation::String(s) => {
697                if s.is_empty() {
698                    0.0
699                } else {
700                    s.parse::<f64>().unwrap_or(f64::NAN)
701                }
702            }
703            Evaluation::Number(n) => *n,
704            Evaluation::Boolean(b) => {
705                if *b {
706                    1.0
707                } else {
708                    0.0
709                }
710            }
711            Evaluation::Null => 0.0,
712            Evaluation::Array(_) | Evaluation::Object(_) => f64::NAN,
713        }
714    }
715
716    /// Returns a wrapper around this evaluation that implements
717    /// GitHub Actions evaluation semantics.
718    pub fn sema(&self) -> EvaluationSema<'_> {
719        EvaluationSema(self)
720    }
721}
722
723/// A wrapper around `Evaluation` that implements GitHub Actions
724/// various evaluation semantics (comparison, stringification, etc.).
725pub struct EvaluationSema<'a>(&'a Evaluation);
726
727impl PartialEq for EvaluationSema<'_> {
728    fn eq(&self, other: &Self) -> bool {
729        match (self.0, other.0) {
730            (Evaluation::Null, Evaluation::Null) => true,
731            (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a == b,
732            (Evaluation::Number(a), Evaluation::Number(b)) => a == b,
733            (Evaluation::String(a), Evaluation::String(b)) => a == b,
734
735            // Coercion rules: all others convert to number and compare.
736            (a, b) => a.as_number() == b.as_number(),
737        }
738    }
739}
740
741impl PartialOrd for EvaluationSema<'_> {
742    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
743        match (self.0, other.0) {
744            (Evaluation::Null, Evaluation::Null) => Some(std::cmp::Ordering::Equal),
745            (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a.partial_cmp(b),
746            (Evaluation::Number(a), Evaluation::Number(b)) => a.partial_cmp(b),
747            (Evaluation::String(a), Evaluation::String(b)) => a.partial_cmp(b),
748            // Coercion rules: all others convert to number and compare.
749            (a, b) => a.as_number().partial_cmp(&b.as_number()),
750        }
751    }
752}
753
754impl std::fmt::Display for EvaluationSema<'_> {
755    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
756        match self.0 {
757            Evaluation::String(s) => write!(f, "{}", s),
758            Evaluation::Number(n) => {
759                // Format numbers like GitHub Actions does
760                if n.fract() == 0.0 {
761                    write!(f, "{}", *n as i64)
762                } else {
763                    write!(f, "{}", n)
764                }
765            }
766            Evaluation::Boolean(b) => write!(f, "{}", b),
767            Evaluation::Null => write!(f, ""),
768            Evaluation::Array(_) => write!(f, "Array"),
769            Evaluation::Object(_) => write!(f, "Object"),
770        }
771    }
772}
773
774impl<'src> Expr<'src> {
775    /// Evaluates a constant-reducible expression to its literal value.
776    ///
777    /// Returns `Some(Evaluation)` if the expression can be constant-evaluated,
778    /// or `None` if the expression contains non-constant elements (like contexts or
779    /// non-reducible function calls).
780    ///
781    /// This implementation follows GitHub Actions' evaluation semantics as documented at:
782    /// https://docs.github.com/en/actions/reference/workflows-and-actions/expressions
783    ///
784    /// # Examples
785    ///
786    /// ```
787    /// use github_actions_expressions::{Expr, Evaluation};
788    ///
789    /// let expr = Expr::parse("'hello'").unwrap();
790    /// let result = expr.consteval().unwrap();
791    /// assert_eq!(result.sema().to_string(), "hello");
792    ///
793    /// let expr = Expr::parse("true && false").unwrap();
794    /// let result = expr.consteval().unwrap();
795    /// assert_eq!(result, Evaluation::Boolean(false));
796    /// ```
797    pub fn consteval(&self) -> Option<Evaluation> {
798        match self {
799            Expr::Literal(literal) => Some(literal.consteval()),
800
801            Expr::BinOp { lhs, op, rhs } => {
802                let lhs_val = lhs.consteval()?;
803                let rhs_val = rhs.consteval()?;
804
805                match op {
806                    BinOp::And => {
807                        // GitHub Actions && semantics: if LHS is falsy, return LHS, else return RHS
808                        if lhs_val.as_boolean() {
809                            Some(rhs_val)
810                        } else {
811                            Some(lhs_val)
812                        }
813                    }
814                    BinOp::Or => {
815                        // GitHub Actions || semantics: if LHS is truthy, return LHS, else return RHS
816                        if lhs_val.as_boolean() {
817                            Some(lhs_val)
818                        } else {
819                            Some(rhs_val)
820                        }
821                    }
822                    BinOp::Eq => Some(Evaluation::Boolean(lhs_val.sema() == rhs_val.sema())),
823                    BinOp::Neq => Some(Evaluation::Boolean(lhs_val.sema() != rhs_val.sema())),
824                    BinOp::Lt => Some(Evaluation::Boolean(lhs_val.sema() < rhs_val.sema())),
825                    BinOp::Le => Some(Evaluation::Boolean(lhs_val.sema() <= rhs_val.sema())),
826                    BinOp::Gt => Some(Evaluation::Boolean(lhs_val.sema() > rhs_val.sema())),
827                    BinOp::Ge => Some(Evaluation::Boolean(lhs_val.sema() >= rhs_val.sema())),
828                }
829            }
830
831            Expr::UnOp { op, expr } => {
832                let val = expr.consteval()?;
833                match op {
834                    UnOp::Not => Some(Evaluation::Boolean(!val.as_boolean())),
835                }
836            }
837
838            Expr::Call(call) => call.consteval(),
839
840            // Non-constant expressions
841            _ => None,
842        }
843    }
844}
845
846#[cfg(test)]
847mod tests {
848    use std::borrow::Cow;
849
850    use anyhow::Result;
851    use pest::Parser as _;
852    use pretty_assertions::assert_eq;
853
854    use crate::{Call, Literal, Origin, SpannedExpr};
855
856    use super::{BinOp, Expr, ExprParser, Function, Rule, UnOp};
857
858    #[test]
859    fn test_literal_string_borrows() {
860        let cases = &[
861            ("'foo'", true),
862            ("'foo bar'", true),
863            ("'foo '' bar'", false),
864            ("'foo''bar'", false),
865            ("'foo''''bar'", false),
866        ];
867
868        for (expr, borrows) in cases {
869            let Expr::Literal(Literal::String(s)) = &*Expr::parse(expr).unwrap() else {
870                panic!("expected a literal string expression for {expr}");
871            };
872
873            assert!(matches!(
874                (s, borrows),
875                (Cow::Borrowed(_), true) | (Cow::Owned(_), false)
876            ));
877        }
878    }
879
880    #[test]
881    fn test_literal_as_str() {
882        let cases = &[
883            ("'foo'", "foo"),
884            ("'foo '' bar'", "foo ' bar"),
885            ("123", "123"),
886            ("123.000", "123"),
887            ("0.0", "0"),
888            ("0.1", "0.1"),
889            ("0.12345", "0.12345"),
890            ("true", "true"),
891            ("false", "false"),
892            ("null", "null"),
893        ];
894
895        for (expr, expected) in cases {
896            let Expr::Literal(expr) = &*Expr::parse(expr).unwrap() else {
897                panic!("expected a literal expression for {expr}");
898            };
899
900            assert_eq!(expr.as_str(), *expected);
901        }
902    }
903
904    #[test]
905    fn test_function_eq() {
906        let func = Function("foo");
907        assert_eq!(&func, "foo");
908        assert_eq!(&func, "FOO");
909        assert_eq!(&func, "Foo");
910
911        assert_eq!(func, Function("FOO"));
912    }
913
914    #[test]
915    fn test_parse_string_rule() {
916        let cases = &[
917            ("''", ""),
918            ("' '", " "),
919            ("''''", "''"),
920            ("'test'", "test"),
921            ("'spaces are ok'", "spaces are ok"),
922            ("'escaping '' works'", "escaping '' works"),
923        ];
924
925        for (case, expected) in cases {
926            let s = ExprParser::parse(Rule::string, case)
927                .unwrap()
928                .next()
929                .unwrap();
930
931            assert_eq!(s.into_inner().next().unwrap().as_str(), *expected);
932        }
933    }
934
935    #[test]
936    fn test_parse_context_rule() {
937        let cases = &[
938            "foo.bar",
939            "github.action_path",
940            "inputs.foo-bar",
941            "inputs.also--valid",
942            "inputs.this__too",
943            "inputs.this__too",
944            "secrets.GH_TOKEN",
945            "foo.*.bar",
946            "github.event.issue.labels.*.name",
947        ];
948
949        for case in cases {
950            assert_eq!(
951                ExprParser::parse(Rule::context, case)
952                    .unwrap()
953                    .next()
954                    .unwrap()
955                    .as_str(),
956                *case
957            );
958        }
959    }
960
961    #[test]
962    fn test_parse_call_rule() {
963        let cases = &[
964            "foo()",
965            "foo(bar)",
966            "foo(bar())",
967            "foo(1.23)",
968            "foo(1,2)",
969            "foo(1, 2)",
970            "foo(1, 2, secret.GH_TOKEN)",
971            "foo(   )",
972            "fromJSON(inputs.free-threading)",
973        ];
974
975        for case in cases {
976            assert_eq!(
977                ExprParser::parse(Rule::function_call, case)
978                    .unwrap()
979                    .next()
980                    .unwrap()
981                    .as_str(),
982                *case
983            );
984        }
985    }
986
987    #[test]
988    fn test_parse_expr_rule() -> Result<()> {
989        // Ensures that we parse multi-line expressions correctly.
990        let multiline = "github.repository_owner == 'Homebrew' &&
991        ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
992        (github.event_name == 'pull_request_target' &&
993        (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
994
995        let multiline2 = "foo.bar.baz[
996        0
997        ]";
998
999        let cases = &[
1000            "true",
1001            "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
1002            "foo || bar || baz",
1003            "foo || bar && baz || foo && 1 && 2 && 3 || 4",
1004            "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
1005            "(true || false) == true",
1006            "!(!true || false)",
1007            "!(!true || false) == true",
1008            "(true == false) == true",
1009            "(true == (false || true && (true || false))) == true",
1010            "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
1011            "foo()[0]",
1012            "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
1013            multiline,
1014            "'a' == 'b' && 'c' || 'd'",
1015            "github.event['a']",
1016            "github.event['a' == 'b']",
1017            "github.event['a' == 'b' && 'c' || 'd']",
1018            "github['event']['inputs']['dry-run']",
1019            "github[format('{0}', 'event')]",
1020            "github['event']['inputs'][github.event.inputs.magic]",
1021            "github['event']['inputs'].*",
1022            "1 == 1",
1023            "1 > 1",
1024            "1 >= 1",
1025            "matrix.node_version >= 20",
1026            "true||false",
1027            multiline2,
1028            "fromJSON( github.event.inputs.hmm ) [ 0 ]",
1029        ];
1030
1031        for case in cases {
1032            assert_eq!(
1033                ExprParser::parse(Rule::expression, case)?
1034                    .next()
1035                    .unwrap()
1036                    .as_str(),
1037                *case
1038            );
1039        }
1040
1041        Ok(())
1042    }
1043
1044    #[test]
1045    fn test_parse() {
1046        let cases = &[
1047            (
1048                "!true || false || true",
1049                SpannedExpr::new(
1050                    Origin::new(0..22, "!true || false || true"),
1051                    Expr::BinOp {
1052                        lhs: SpannedExpr::new(
1053                            Origin::new(0..22, "!true || false || true"),
1054                            Expr::BinOp {
1055                                lhs: SpannedExpr::new(
1056                                    Origin::new(0..5, "!true"),
1057                                    Expr::UnOp {
1058                                        op: UnOp::Not,
1059                                        expr: SpannedExpr::new(
1060                                            Origin::new(1..5, "true"),
1061                                            true.into(),
1062                                        )
1063                                        .into(),
1064                                    },
1065                                )
1066                                .into(),
1067                                op: BinOp::Or,
1068                                rhs: SpannedExpr::new(Origin::new(9..14, "false"), false.into())
1069                                    .into(),
1070                            },
1071                        )
1072                        .into(),
1073                        op: BinOp::Or,
1074                        rhs: SpannedExpr::new(Origin::new(18..22, "true"), true.into()).into(),
1075                    },
1076                ),
1077            ),
1078            (
1079                "'foo '' bar'",
1080                SpannedExpr::new(
1081                    Origin::new(0..12, "'foo '' bar'"),
1082                    Expr::Literal(Literal::String("foo ' bar".into())),
1083                ),
1084            ),
1085            (
1086                "('foo '' bar')",
1087                SpannedExpr::new(
1088                    Origin::new(1..13, "'foo '' bar'"),
1089                    Expr::Literal(Literal::String("foo ' bar".into())),
1090                ),
1091            ),
1092            (
1093                "((('foo '' bar')))",
1094                SpannedExpr::new(
1095                    Origin::new(3..15, "'foo '' bar'"),
1096                    Expr::Literal(Literal::String("foo ' bar".into())),
1097                ),
1098            ),
1099            (
1100                "foo(1, 2, 3)",
1101                SpannedExpr::new(
1102                    Origin::new(0..12, "foo(1, 2, 3)"),
1103                    Expr::Call(Call {
1104                        func: Function("foo"),
1105                        args: vec![
1106                            SpannedExpr::new(Origin::new(4..5, "1"), 1.0.into()),
1107                            SpannedExpr::new(Origin::new(7..8, "2"), 2.0.into()),
1108                            SpannedExpr::new(Origin::new(10..11, "3"), 3.0.into()),
1109                        ],
1110                    }),
1111                ),
1112            ),
1113            (
1114                "foo.bar.baz",
1115                SpannedExpr::new(
1116                    Origin::new(0..11, "foo.bar.baz"),
1117                    Expr::context(vec![
1118                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1119                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1120                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1121                    ]),
1122                ),
1123            ),
1124            (
1125                "foo.bar.baz[1][2]",
1126                SpannedExpr::new(
1127                    Origin::new(0..17, "foo.bar.baz[1][2]"),
1128                    Expr::context(vec![
1129                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1130                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1131                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1132                        SpannedExpr::new(
1133                            Origin::new(11..14, "[1]"),
1134                            Expr::Index(Box::new(SpannedExpr::new(
1135                                Origin::new(12..13, "1"),
1136                                1.0.into(),
1137                            ))),
1138                        ),
1139                        SpannedExpr::new(
1140                            Origin::new(14..17, "[2]"),
1141                            Expr::Index(Box::new(SpannedExpr::new(
1142                                Origin::new(15..16, "2"),
1143                                2.0.into(),
1144                            ))),
1145                        ),
1146                    ]),
1147                ),
1148            ),
1149            (
1150                "foo.bar.baz[*]",
1151                SpannedExpr::new(
1152                    Origin::new(0..14, "foo.bar.baz[*]"),
1153                    Expr::context([
1154                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1155                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1156                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1157                        SpannedExpr::new(
1158                            Origin::new(11..14, "[*]"),
1159                            Expr::Index(Box::new(SpannedExpr::new(
1160                                Origin::new(12..13, "*"),
1161                                Expr::Star,
1162                            ))),
1163                        ),
1164                    ]),
1165                ),
1166            ),
1167            (
1168                "vegetables.*.ediblePortions",
1169                SpannedExpr::new(
1170                    Origin::new(0..27, "vegetables.*.ediblePortions"),
1171                    Expr::context(vec![
1172                        SpannedExpr::new(
1173                            Origin::new(0..10, "vegetables"),
1174                            Expr::ident("vegetables"),
1175                        ),
1176                        SpannedExpr::new(Origin::new(11..12, "*"), Expr::Star),
1177                        SpannedExpr::new(
1178                            Origin::new(13..27, "ediblePortions"),
1179                            Expr::ident("ediblePortions"),
1180                        ),
1181                    ]),
1182                ),
1183            ),
1184            (
1185                // Sanity check for our associativity: the top level Expr here
1186                // should be `BinOp::Or`.
1187                "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1188                SpannedExpr::new(
1189                    Origin::new(
1190                        0..88,
1191                        "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1192                    ),
1193                    Expr::BinOp {
1194                        lhs: Box::new(SpannedExpr::new(
1195                            Origin::new(
1196                                0..59,
1197                                "github.ref == 'refs/heads/main' && 'value_for_main_branch'",
1198                            ),
1199                            Expr::BinOp {
1200                                lhs: Box::new(SpannedExpr::new(
1201                                    Origin::new(0..32, "github.ref == 'refs/heads/main'"),
1202                                    Expr::BinOp {
1203                                        lhs: Box::new(SpannedExpr::new(
1204                                            Origin::new(0..10, "github.ref"),
1205                                            Expr::context(vec![
1206                                                SpannedExpr::new(
1207                                                    Origin::new(0..6, "github"),
1208                                                    Expr::ident("github"),
1209                                                ),
1210                                                SpannedExpr::new(
1211                                                    Origin::new(7..10, "ref"),
1212                                                    Expr::ident("ref"),
1213                                                ),
1214                                            ]),
1215                                        )),
1216                                        op: BinOp::Eq,
1217                                        rhs: Box::new(SpannedExpr::new(
1218                                            Origin::new(14..31, "'refs/heads/main'"),
1219                                            Expr::Literal(Literal::String(
1220                                                "refs/heads/main".into(),
1221                                            )),
1222                                        )),
1223                                    },
1224                                )),
1225                                op: BinOp::And,
1226                                rhs: Box::new(SpannedExpr::new(
1227                                    Origin::new(35..58, "'value_for_main_branch'"),
1228                                    Expr::Literal(Literal::String("value_for_main_branch".into())),
1229                                )),
1230                            },
1231                        )),
1232                        op: BinOp::Or,
1233                        rhs: Box::new(SpannedExpr::new(
1234                            Origin::new(62..88, "'value_for_other_branches'"),
1235                            Expr::Literal(Literal::String("value_for_other_branches".into())),
1236                        )),
1237                    },
1238                ),
1239            ),
1240            (
1241                "(true || false) == true",
1242                SpannedExpr::new(
1243                    Origin::new(0..23, "(true || false) == true"),
1244                    Expr::BinOp {
1245                        lhs: Box::new(SpannedExpr::new(
1246                            Origin::new(1..14, "true || false"),
1247                            Expr::BinOp {
1248                                lhs: Box::new(SpannedExpr::new(
1249                                    Origin::new(1..5, "true"),
1250                                    true.into(),
1251                                )),
1252                                op: BinOp::Or,
1253                                rhs: Box::new(SpannedExpr::new(
1254                                    Origin::new(9..14, "false"),
1255                                    false.into(),
1256                                )),
1257                            },
1258                        )),
1259                        op: BinOp::Eq,
1260                        rhs: Box::new(SpannedExpr::new(Origin::new(19..23, "true"), true.into())),
1261                    },
1262                ),
1263            ),
1264            (
1265                "!(!true || false)",
1266                SpannedExpr::new(
1267                    Origin::new(0..17, "!(!true || false)"),
1268                    Expr::UnOp {
1269                        op: UnOp::Not,
1270                        expr: Box::new(SpannedExpr::new(
1271                            Origin::new(2..16, "!true || false"),
1272                            Expr::BinOp {
1273                                lhs: Box::new(SpannedExpr::new(
1274                                    Origin::new(2..7, "!true"),
1275                                    Expr::UnOp {
1276                                        op: UnOp::Not,
1277                                        expr: Box::new(SpannedExpr::new(
1278                                            Origin::new(3..7, "true"),
1279                                            true.into(),
1280                                        )),
1281                                    },
1282                                )),
1283                                op: BinOp::Or,
1284                                rhs: Box::new(SpannedExpr::new(
1285                                    Origin::new(11..16, "false"),
1286                                    false.into(),
1287                                )),
1288                            },
1289                        )),
1290                    },
1291                ),
1292            ),
1293            (
1294                "foobar[format('{0}', 'event')]",
1295                SpannedExpr::new(
1296                    Origin::new(0..30, "foobar[format('{0}', 'event')]"),
1297                    Expr::context([
1298                        SpannedExpr::new(Origin::new(0..6, "foobar"), Expr::ident("foobar")),
1299                        SpannedExpr::new(
1300                            Origin::new(6..30, "[format('{0}', 'event')]"),
1301                            Expr::Index(Box::new(SpannedExpr::new(
1302                                Origin::new(7..29, "format('{0}', 'event')"),
1303                                Expr::Call(Call {
1304                                    func: Function("format"),
1305                                    args: vec![
1306                                        SpannedExpr::new(
1307                                            Origin::new(14..19, "'{0}'"),
1308                                            Expr::from("{0}"),
1309                                        ),
1310                                        SpannedExpr::new(
1311                                            Origin::new(21..28, "'event'"),
1312                                            Expr::from("event"),
1313                                        ),
1314                                    ],
1315                                }),
1316                            ))),
1317                        ),
1318                    ]),
1319                ),
1320            ),
1321            (
1322                "github.actor_id == '49699333'",
1323                SpannedExpr::new(
1324                    Origin::new(0..29, "github.actor_id == '49699333'"),
1325                    Expr::BinOp {
1326                        lhs: SpannedExpr::new(
1327                            Origin::new(0..15, "github.actor_id"),
1328                            Expr::context(vec![
1329                                SpannedExpr::new(
1330                                    Origin::new(0..6, "github"),
1331                                    Expr::ident("github"),
1332                                ),
1333                                SpannedExpr::new(
1334                                    Origin::new(7..15, "actor_id"),
1335                                    Expr::ident("actor_id"),
1336                                ),
1337                            ]),
1338                        )
1339                        .into(),
1340                        op: BinOp::Eq,
1341                        rhs: Box::new(SpannedExpr::new(
1342                            Origin::new(19..29, "'49699333'"),
1343                            Expr::from("49699333"),
1344                        )),
1345                    },
1346                ),
1347            ),
1348        ];
1349
1350        for (case, expr) in cases {
1351            assert_eq!(*expr, Expr::parse(case).unwrap());
1352        }
1353    }
1354
1355    #[test]
1356    fn test_expr_constant_reducible() -> Result<()> {
1357        for (expr, reducible) in &[
1358            ("'foo'", true),
1359            ("1", true),
1360            ("true", true),
1361            ("null", true),
1362            // boolean and unary expressions of all literals are
1363            // always reducible.
1364            ("!true", true),
1365            ("!null", true),
1366            ("true && false", true),
1367            ("true || false", true),
1368            ("null && !null && true", true),
1369            // formats/contains/startsWith/endsWith are reducible
1370            // if all of their arguments are reducible.
1371            ("format('{0} {1}', 'foo', 'bar')", true),
1372            ("format('{0} {1}', 1, 2)", true),
1373            ("format('{0} {1}', 1, '2')", true),
1374            ("contains('foo', 'bar')", true),
1375            ("startsWith('foo', 'bar')", true),
1376            ("endsWith('foo', 'bar')", true),
1377            ("startsWith(some.context, 'bar')", false),
1378            ("endsWith(some.context, 'bar')", false),
1379            // Nesting works as long as the nested call is also reducible.
1380            ("format('{0} {1}', '1', format('{0}', null))", true),
1381            ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
1382            ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
1383            ("foo", false),
1384            ("foo.bar", false),
1385            ("foo.bar[1]", false),
1386            ("foo.bar == 'bar'", false),
1387            ("foo.bar || bar || baz", false),
1388            ("foo.bar && bar && baz", false),
1389        ] {
1390            let expr = Expr::parse(expr)?;
1391            assert_eq!(expr.constant_reducible(), *reducible);
1392        }
1393
1394        Ok(())
1395    }
1396
1397    #[test]
1398    fn test_evaluate_constant_complex_expressions() -> Result<()> {
1399        use crate::Evaluation;
1400
1401        let test_cases = &[
1402            // Nested operations
1403            ("!false", Evaluation::Boolean(true)),
1404            ("!true", Evaluation::Boolean(false)),
1405            ("!(true && false)", Evaluation::Boolean(true)),
1406            // Complex boolean logic
1407            ("true && (false || true)", Evaluation::Boolean(true)),
1408            ("false || (true && false)", Evaluation::Boolean(false)),
1409            // Mixed function calls
1410            (
1411                "contains(format('{0} {1}', 'hello', 'world'), 'world')",
1412                Evaluation::Boolean(true),
1413            ),
1414            (
1415                "startsWith(format('prefix_{0}', 'test'), 'prefix')",
1416                Evaluation::Boolean(true),
1417            ),
1418        ];
1419
1420        for (expr_str, expected) in test_cases {
1421            let expr = Expr::parse(expr_str)?;
1422            let result = expr.consteval().unwrap();
1423            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1424        }
1425
1426        Ok(())
1427    }
1428
1429    #[test]
1430    fn test_evaluation_sema_display() {
1431        use crate::Evaluation;
1432
1433        let test_cases = &[
1434            (Evaluation::String("hello".to_string()), "hello"),
1435            (Evaluation::Number(42.0), "42"),
1436            (Evaluation::Number(3.14), "3.14"),
1437            (Evaluation::Boolean(true), "true"),
1438            (Evaluation::Boolean(false), "false"),
1439            (Evaluation::Null, ""),
1440        ];
1441
1442        for (result, expected) in test_cases {
1443            assert_eq!(result.sema().to_string(), *expected);
1444        }
1445    }
1446
1447    #[test]
1448    fn test_evaluation_result_to_boolean() {
1449        use crate::Evaluation;
1450
1451        let test_cases = &[
1452            (Evaluation::Boolean(true), true),
1453            (Evaluation::Boolean(false), false),
1454            (Evaluation::Null, false),
1455            (Evaluation::Number(0.0), false),
1456            (Evaluation::Number(1.0), true),
1457            (Evaluation::Number(-1.0), true),
1458            (Evaluation::String("".to_string()), false),
1459            (Evaluation::String("hello".to_string()), true),
1460            (Evaluation::Array(vec![]), true), // Arrays are always truthy
1461            (Evaluation::Object(std::collections::HashMap::new()), true), // Dictionaries are always truthy
1462        ];
1463
1464        for (result, expected) in test_cases {
1465            assert_eq!(result.as_boolean(), *expected);
1466        }
1467    }
1468
1469    #[test]
1470    fn test_github_actions_logical_semantics() -> Result<()> {
1471        use crate::Evaluation;
1472
1473        // Test GitHub Actions-specific && and || semantics
1474        let test_cases = &[
1475            // && returns the first falsy value, or the last value if all are truthy
1476            ("false && 'hello'", Evaluation::Boolean(false)),
1477            ("null && 'hello'", Evaluation::Null),
1478            ("'' && 'hello'", Evaluation::String("".to_string())),
1479            (
1480                "'hello' && 'world'",
1481                Evaluation::String("world".to_string()),
1482            ),
1483            ("true && 42", Evaluation::Number(42.0)),
1484            // || returns the first truthy value, or the last value if all are falsy
1485            ("true || 'hello'", Evaluation::Boolean(true)),
1486            (
1487                "'hello' || 'world'",
1488                Evaluation::String("hello".to_string()),
1489            ),
1490            ("false || 'hello'", Evaluation::String("hello".to_string())),
1491            ("null || false", Evaluation::Boolean(false)),
1492            ("'' || null", Evaluation::Null),
1493        ];
1494
1495        for (expr_str, expected) in test_cases {
1496            let expr = Expr::parse(expr_str)?;
1497            let result = expr.consteval().unwrap();
1498            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1499        }
1500
1501        Ok(())
1502    }
1503
1504    #[test]
1505    fn test_expr_has_constant_reducible_subexpr() -> Result<()> {
1506        for (expr, reducible) in &[
1507            // Literals are not considered reducible subexpressions.
1508            ("'foo'", false),
1509            ("1", false),
1510            ("true", false),
1511            ("null", false),
1512            // Non-reducible expressions with reducible subexpressions
1513            (
1514                "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
1515                true,
1516            ),
1517            ("foobar[format('{0}', 'event')]", true),
1518        ] {
1519            let expr = Expr::parse(expr)?;
1520            assert_eq!(!expr.constant_reducible_subexprs().is_empty(), *reducible);
1521        }
1522        Ok(())
1523    }
1524
1525    #[test]
1526    fn test_expr_dataflow_contexts() -> Result<()> {
1527        // Trivial cases.
1528        let expr = Expr::parse("foo.bar")?;
1529        assert_eq!(
1530            expr.dataflow_contexts()
1531                .iter()
1532                .map(|t| t.1.raw)
1533                .collect::<Vec<_>>(),
1534            ["foo.bar"]
1535        );
1536
1537        let expr = Expr::parse("foo.bar[1]")?;
1538        assert_eq!(
1539            expr.dataflow_contexts()
1540                .iter()
1541                .map(|t| t.1.raw)
1542                .collect::<Vec<_>>(),
1543            ["foo.bar[1]"]
1544        );
1545
1546        // No dataflow due to a boolean expression.
1547        let expr = Expr::parse("foo.bar == 'bar'")?;
1548        assert!(expr.dataflow_contexts().is_empty());
1549
1550        // ||: all contexts potentially expand into the evaluation.
1551        let expr = Expr::parse("foo.bar || abc || d.e.f")?;
1552        assert_eq!(
1553            expr.dataflow_contexts()
1554                .iter()
1555                .map(|t| t.1.raw)
1556                .collect::<Vec<_>>(),
1557            ["foo.bar", "abc", "d.e.f"]
1558        );
1559
1560        // &&: only the RHS context(s) expand into the evaluation.
1561        let expr = Expr::parse("foo.bar && abc && d.e.f")?;
1562        assert_eq!(
1563            expr.dataflow_contexts()
1564                .iter()
1565                .map(|t| t.1.raw)
1566                .collect::<Vec<_>>(),
1567            ["d.e.f"]
1568        );
1569
1570        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
1571        assert_eq!(
1572            expr.dataflow_contexts()
1573                .iter()
1574                .map(|t| t.1.raw)
1575                .collect::<Vec<_>>(),
1576            ["foo.bar"]
1577        );
1578
1579        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
1580        assert_eq!(
1581            expr.dataflow_contexts()
1582                .iter()
1583                .map(|t| t.1.raw)
1584                .collect::<Vec<_>>(),
1585            ["foo.bar", "foo.baz"]
1586        );
1587
1588        let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
1589        assert_eq!(
1590            expr.dataflow_contexts()
1591                .iter()
1592                .map(|t| t.1.raw)
1593                .collect::<Vec<_>>(),
1594            ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
1595        );
1596
1597        let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
1598        assert_eq!(
1599            expr.dataflow_contexts()
1600                .iter()
1601                .map(|t| t.1.raw)
1602                .collect::<Vec<_>>(),
1603            ["foo.bar", "github", "github"]
1604        );
1605
1606        Ok(())
1607    }
1608
1609    #[test]
1610    fn test_spannedexpr_computed_indices() -> Result<()> {
1611        for (expr, computed_indices) in &[
1612            ("foo.bar", vec![]),
1613            ("foo.bar[1]", vec![]),
1614            ("foo.bar[*]", vec![]),
1615            ("foo.bar[abc]", vec!["[abc]"]),
1616            (
1617                "foo.bar[format('{0}', 'foo')]",
1618                vec!["[format('{0}', 'foo')]"],
1619            ),
1620            ("foo.bar[abc].def[efg]", vec!["[abc]", "[efg]"]),
1621        ] {
1622            let expr = Expr::parse(expr)?;
1623
1624            assert_eq!(
1625                expr.computed_indices()
1626                    .iter()
1627                    .map(|e| e.origin.raw)
1628                    .collect::<Vec<_>>(),
1629                *computed_indices
1630            );
1631        }
1632
1633        Ok(())
1634    }
1635
1636    #[test]
1637    fn test_fragment_from_expr() {
1638        for (expr, expected) in &[
1639            ("foo==bar", "foo==bar"),
1640            ("foo    ==   bar", "foo    ==   bar"),
1641            ("foo == bar", r"foo == bar"),
1642            ("foo(bar)", "foo(bar)"),
1643            ("foo(bar, baz)", "foo(bar, baz)"),
1644            ("foo (bar, baz)", "foo (bar, baz)"),
1645            ("a . b . c . d", "a . b . c . d"),
1646            ("true \n && \n false", r"true\s+\&\&\s+false"),
1647        ] {
1648            let expr = Expr::parse(expr).unwrap();
1649            match subfeature::Fragment::from(&expr) {
1650                subfeature::Fragment::Raw(actual) => assert_eq!(actual, *expected),
1651                subfeature::Fragment::Regex(actual) => assert_eq!(actual.as_str(), *expected),
1652            };
1653        }
1654    }
1655}