Skip to main content

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