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