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                    parse_number(pair.as_str()).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) => parse_number(s),
740            Evaluation::Number(n) => *n,
741            Evaluation::Boolean(b) => {
742                if *b {
743                    1.0
744                } else {
745                    0.0
746                }
747            }
748            Evaluation::Null => 0.0,
749            Evaluation::Array(_) | Evaluation::Object(_) => f64::NAN,
750        }
751    }
752
753    /// Returns a wrapper around this evaluation that implements
754    /// GitHub Actions evaluation semantics.
755    pub fn sema(&self) -> EvaluationSema<'_> {
756        EvaluationSema(self)
757    }
758}
759
760/// Parse a string into a number following GitHub Actions coercion rules.
761///
762/// The string is trimmed and then parsed following the rules from the
763/// GitHub Action Runner:
764/// https://github.com/actions/runner/blob/9426c35fdaf2b2e00c3ef751a15c04fa8e2a9582/src/Sdk/Expressions/Sdk/ExpressionUtility.cs#L223
765fn parse_number(s: &str) -> f64 {
766    let trimmed = s.trim();
767    if trimmed.is_empty() {
768        return 0.0;
769    }
770
771    // Decimal / scientific notation first
772    // Only accept finite results; infinity/NaN literals fall through.
773    if let Ok(value) = trimmed.parse::<f64>()
774        && value.is_finite()
775    {
776        return value;
777    }
778
779    // Hex: signed 32-bit.
780    // Values 0x80000000–0xFFFFFFFF wrap negative via two's complement.
781    if let Some(hex_digits) = trimmed.strip_prefix("0x") {
782        return u32::from_str_radix(hex_digits, 16)
783            .map(|n| (n as i32) as f64)
784            .unwrap_or(f64::NAN);
785    }
786
787    // Octal: signed 32-bit.
788    if let Some(oct_digits) = trimmed.strip_prefix("0o") {
789        return u32::from_str_radix(oct_digits, 8)
790            .map(|n| (n as i32) as f64)
791            .unwrap_or(f64::NAN);
792    }
793
794    // Explicit Infinity check — GH runner accepts full "infinity"
795    // (case-insensitive) but NOT the "inf" abbreviation.
796    let after_sign = trimmed
797        .strip_prefix(['+', '-'].as_slice())
798        .unwrap_or(trimmed);
799    if after_sign.eq_ignore_ascii_case("infinity") {
800        return if trimmed.starts_with('-') {
801            f64::NEG_INFINITY
802        } else {
803            f64::INFINITY
804        };
805    }
806
807    f64::NAN
808}
809
810/// A wrapper around `Evaluation` that implements GitHub Actions
811/// various evaluation semantics (comparison, stringification, etc.).
812pub struct EvaluationSema<'a>(&'a Evaluation);
813
814impl PartialEq for EvaluationSema<'_> {
815    fn eq(&self, other: &Self) -> bool {
816        match (self.0, other.0) {
817            (Evaluation::Null, Evaluation::Null) => true,
818            (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a == b,
819            (Evaluation::Number(a), Evaluation::Number(b)) => a == b,
820            // GitHub Actions string comparisons are case-insensitive.
821            (Evaluation::String(a), Evaluation::String(b)) => a.to_uppercase() == b.to_uppercase(),
822
823            // Coercion rules: all others convert to number and compare.
824            (a, b) => a.as_number() == b.as_number(),
825        }
826    }
827}
828
829impl PartialOrd for EvaluationSema<'_> {
830    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
831        match (self.0, other.0) {
832            (Evaluation::Null, Evaluation::Null) => Some(std::cmp::Ordering::Equal),
833            (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a.partial_cmp(b),
834            (Evaluation::Number(a), Evaluation::Number(b)) => a.partial_cmp(b),
835            (Evaluation::String(a), Evaluation::String(b)) => {
836                a.to_uppercase().partial_cmp(&b.to_uppercase())
837            }
838            // Coercion rules: all others convert to number and compare.
839            (a, b) => a.as_number().partial_cmp(&b.as_number()),
840        }
841    }
842}
843
844impl std::fmt::Display for EvaluationSema<'_> {
845    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
846        match self.0 {
847            Evaluation::String(s) => write!(f, "{}", s),
848            Evaluation::Number(n) => {
849                // Format numbers like GitHub Actions does
850                if n.fract() == 0.0 {
851                    write!(f, "{}", *n as i64)
852                } else {
853                    write!(f, "{}", n)
854                }
855            }
856            Evaluation::Boolean(b) => write!(f, "{}", b),
857            Evaluation::Null => write!(f, ""),
858            Evaluation::Array(_) => write!(f, "Array"),
859            Evaluation::Object(_) => write!(f, "Object"),
860        }
861    }
862}
863
864impl<'src> Expr<'src> {
865    /// Evaluates a constant-reducible expression to its literal value.
866    ///
867    /// Returns `Some(Evaluation)` if the expression can be constant-evaluated,
868    /// or `None` if the expression contains non-constant elements (like contexts or
869    /// non-reducible function calls).
870    ///
871    /// This implementation follows GitHub Actions' evaluation semantics as documented at:
872    /// https://docs.github.com/en/actions/reference/workflows-and-actions/expressions
873    ///
874    /// # Examples
875    ///
876    /// ```
877    /// use github_actions_expressions::{Expr, Evaluation};
878    ///
879    /// let expr = Expr::parse("'hello'").unwrap();
880    /// let result = expr.consteval().unwrap();
881    /// assert_eq!(result.sema().to_string(), "hello");
882    ///
883    /// let expr = Expr::parse("true && false").unwrap();
884    /// let result = expr.consteval().unwrap();
885    /// assert_eq!(result, Evaluation::Boolean(false));
886    /// ```
887    pub fn consteval(&self) -> Option<Evaluation> {
888        match self {
889            Expr::Literal(literal) => Some(literal.consteval()),
890
891            Expr::BinOp { lhs, op, rhs } => {
892                let lhs_val = lhs.consteval()?;
893                let rhs_val = rhs.consteval()?;
894
895                match op {
896                    BinOp::And => {
897                        // GitHub Actions && semantics: if LHS is falsy, return LHS, else return RHS
898                        if lhs_val.as_boolean() {
899                            Some(rhs_val)
900                        } else {
901                            Some(lhs_val)
902                        }
903                    }
904                    BinOp::Or => {
905                        // GitHub Actions || semantics: if LHS is truthy, return LHS, else return RHS
906                        if lhs_val.as_boolean() {
907                            Some(lhs_val)
908                        } else {
909                            Some(rhs_val)
910                        }
911                    }
912                    BinOp::Eq => Some(Evaluation::Boolean(lhs_val.sema() == rhs_val.sema())),
913                    BinOp::Neq => Some(Evaluation::Boolean(lhs_val.sema() != rhs_val.sema())),
914                    BinOp::Lt => Some(Evaluation::Boolean(lhs_val.sema() < rhs_val.sema())),
915                    BinOp::Le => Some(Evaluation::Boolean(lhs_val.sema() <= rhs_val.sema())),
916                    BinOp::Gt => Some(Evaluation::Boolean(lhs_val.sema() > rhs_val.sema())),
917                    BinOp::Ge => Some(Evaluation::Boolean(lhs_val.sema() >= rhs_val.sema())),
918                }
919            }
920
921            Expr::UnOp { op, expr } => {
922                let val = expr.consteval()?;
923                match op {
924                    UnOp::Not => Some(Evaluation::Boolean(!val.as_boolean())),
925                }
926            }
927
928            Expr::Call(call) => call.consteval(),
929
930            // Non-constant expressions
931            _ => None,
932        }
933    }
934}
935
936#[cfg(test)]
937mod tests {
938    use std::borrow::Cow;
939
940    use anyhow::Result;
941    use pest::Parser as _;
942    use pretty_assertions::assert_eq;
943
944    use crate::{Call, Literal, Origin, SpannedExpr};
945
946    use super::{BinOp, Expr, ExprParser, Function, Rule, UnOp};
947
948    #[test]
949    fn test_literal_string_borrows() {
950        let cases = &[
951            ("'foo'", true),
952            ("'foo bar'", true),
953            ("'foo '' bar'", false),
954            ("'foo''bar'", false),
955            ("'foo''''bar'", false),
956        ];
957
958        for (expr, borrows) in cases {
959            let Expr::Literal(Literal::String(s)) = &*Expr::parse(expr).unwrap() else {
960                panic!("expected a literal string expression for {expr}");
961            };
962
963            assert!(matches!(
964                (s, borrows),
965                (Cow::Borrowed(_), true) | (Cow::Owned(_), false)
966            ));
967        }
968    }
969
970    #[test]
971    fn test_literal_as_str() {
972        let cases = &[
973            ("'foo'", "foo"),
974            ("'foo '' bar'", "foo ' bar"),
975            ("123", "123"),
976            ("123.000", "123"),
977            ("0.0", "0"),
978            ("0.1", "0.1"),
979            ("0.12345", "0.12345"),
980            ("true", "true"),
981            ("false", "false"),
982            ("null", "null"),
983        ];
984
985        for (expr, expected) in cases {
986            let Expr::Literal(expr) = &*Expr::parse(expr).unwrap() else {
987                panic!("expected a literal expression for {expr}");
988            };
989
990            assert_eq!(expr.as_str(), *expected);
991        }
992    }
993
994    #[test]
995    fn test_function_eq() {
996        let func = Function("foo");
997        assert_eq!(&func, "foo");
998        assert_eq!(&func, "FOO");
999        assert_eq!(&func, "Foo");
1000
1001        assert_eq!(func, Function("FOO"));
1002    }
1003
1004    #[test]
1005    fn test_parse_string_rule() {
1006        let cases = &[
1007            ("''", ""),
1008            ("' '", " "),
1009            ("''''", "''"),
1010            ("'test'", "test"),
1011            ("'spaces are ok'", "spaces are ok"),
1012            ("'escaping '' works'", "escaping '' works"),
1013        ];
1014
1015        for (case, expected) in cases {
1016            let s = ExprParser::parse(Rule::string, case)
1017                .unwrap()
1018                .next()
1019                .unwrap();
1020
1021            assert_eq!(s.into_inner().next().unwrap().as_str(), *expected);
1022        }
1023    }
1024
1025    #[test]
1026    fn test_parse_context_rule() {
1027        let cases = &[
1028            "foo.bar",
1029            "github.action_path",
1030            "inputs.foo-bar",
1031            "inputs.also--valid",
1032            "inputs.this__too",
1033            "inputs.this__too",
1034            "secrets.GH_TOKEN",
1035            "foo.*.bar",
1036            "github.event.issue.labels.*.name",
1037        ];
1038
1039        for case in cases {
1040            assert_eq!(
1041                ExprParser::parse(Rule::context, case)
1042                    .unwrap()
1043                    .next()
1044                    .unwrap()
1045                    .as_str(),
1046                *case
1047            );
1048        }
1049    }
1050
1051    #[test]
1052    fn test_parse_call_rule() {
1053        let cases = &[
1054            "foo()",
1055            "foo(bar)",
1056            "foo(bar())",
1057            "foo(1.23)",
1058            "foo(1,2)",
1059            "foo(1, 2)",
1060            "foo(1, 2, secret.GH_TOKEN)",
1061            "foo(   )",
1062            "fromJSON(inputs.free-threading)",
1063        ];
1064
1065        for case in cases {
1066            assert_eq!(
1067                ExprParser::parse(Rule::function_call, case)
1068                    .unwrap()
1069                    .next()
1070                    .unwrap()
1071                    .as_str(),
1072                *case
1073            );
1074        }
1075    }
1076
1077    #[test]
1078    fn test_parse_expr_rule() -> Result<()> {
1079        // Ensures that we parse multi-line expressions correctly.
1080        let multiline = "github.repository_owner == 'Homebrew' &&
1081        ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
1082        (github.event_name == 'pull_request_target' &&
1083        (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
1084
1085        let multiline2 = "foo.bar.baz[
1086        0
1087        ]";
1088
1089        let cases = &[
1090            "true",
1091            "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
1092            "foo || bar || baz",
1093            "foo || bar && baz || foo && 1 && 2 && 3 || 4",
1094            "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
1095            "(true || false) == true",
1096            "!(!true || false)",
1097            "!(!true || false) == true",
1098            "(true == false) == true",
1099            "(true == (false || true && (true || false))) == true",
1100            "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
1101            "foo()[0]",
1102            "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
1103            multiline,
1104            "'a' == 'b' && 'c' || 'd'",
1105            "github.event['a']",
1106            "github.event['a' == 'b']",
1107            "github.event['a' == 'b' && 'c' || 'd']",
1108            "github['event']['inputs']['dry-run']",
1109            "github[format('{0}', 'event')]",
1110            "github['event']['inputs'][github.event.inputs.magic]",
1111            "github['event']['inputs'].*",
1112            "1 == 1",
1113            "1 > 1",
1114            "1 >= 1",
1115            "matrix.node_version >= 20",
1116            "true||false",
1117            // Hex literals
1118            "0xFF",
1119            "0xff",
1120            "0x0",
1121            "0xFF == 255",
1122            // Octal literals
1123            "0o10",
1124            "0o77",
1125            "0o0",
1126            // Scientific notation
1127            "1e2",
1128            "1.5E-3",
1129            "1.2e+2",
1130            "5e0",
1131            // Signed numbers
1132            "+42",
1133            "-42",
1134            // Leading/trailing dot
1135            ".5",
1136            "123.",
1137            multiline2,
1138            "fromJSON( github.event.inputs.hmm ) [ 0 ]",
1139        ];
1140
1141        for case in cases {
1142            assert_eq!(
1143                ExprParser::parse(Rule::expression, case)?
1144                    .next()
1145                    .unwrap()
1146                    .as_str(),
1147                *case
1148            );
1149        }
1150
1151        Ok(())
1152    }
1153
1154    #[test]
1155    fn test_parse() {
1156        let cases = &[
1157            (
1158                "!true || false || true",
1159                SpannedExpr::new(
1160                    Origin::new(0..22, "!true || false || true"),
1161                    Expr::BinOp {
1162                        lhs: SpannedExpr::new(
1163                            Origin::new(0..22, "!true || false || true"),
1164                            Expr::BinOp {
1165                                lhs: SpannedExpr::new(
1166                                    Origin::new(0..5, "!true"),
1167                                    Expr::UnOp {
1168                                        op: UnOp::Not,
1169                                        expr: SpannedExpr::new(
1170                                            Origin::new(1..5, "true"),
1171                                            true.into(),
1172                                        )
1173                                        .into(),
1174                                    },
1175                                )
1176                                .into(),
1177                                op: BinOp::Or,
1178                                rhs: SpannedExpr::new(Origin::new(9..14, "false"), false.into())
1179                                    .into(),
1180                            },
1181                        )
1182                        .into(),
1183                        op: BinOp::Or,
1184                        rhs: SpannedExpr::new(Origin::new(18..22, "true"), true.into()).into(),
1185                    },
1186                ),
1187            ),
1188            (
1189                "'foo '' bar'",
1190                SpannedExpr::new(
1191                    Origin::new(0..12, "'foo '' bar'"),
1192                    Expr::Literal(Literal::String("foo ' bar".into())),
1193                ),
1194            ),
1195            (
1196                "('foo '' bar')",
1197                SpannedExpr::new(
1198                    Origin::new(1..13, "'foo '' bar'"),
1199                    Expr::Literal(Literal::String("foo ' bar".into())),
1200                ),
1201            ),
1202            (
1203                "((('foo '' bar')))",
1204                SpannedExpr::new(
1205                    Origin::new(3..15, "'foo '' bar'"),
1206                    Expr::Literal(Literal::String("foo ' bar".into())),
1207                ),
1208            ),
1209            (
1210                "foo(1, 2, 3)",
1211                SpannedExpr::new(
1212                    Origin::new(0..12, "foo(1, 2, 3)"),
1213                    Expr::Call(Call {
1214                        func: Function("foo"),
1215                        args: vec![
1216                            SpannedExpr::new(Origin::new(4..5, "1"), 1.0.into()),
1217                            SpannedExpr::new(Origin::new(7..8, "2"), 2.0.into()),
1218                            SpannedExpr::new(Origin::new(10..11, "3"), 3.0.into()),
1219                        ],
1220                    }),
1221                ),
1222            ),
1223            (
1224                "foo.bar.baz",
1225                SpannedExpr::new(
1226                    Origin::new(0..11, "foo.bar.baz"),
1227                    Expr::context(vec![
1228                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1229                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1230                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1231                    ]),
1232                ),
1233            ),
1234            (
1235                "foo.bar.baz[1][2]",
1236                SpannedExpr::new(
1237                    Origin::new(0..17, "foo.bar.baz[1][2]"),
1238                    Expr::context(vec![
1239                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1240                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1241                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1242                        SpannedExpr::new(
1243                            Origin::new(11..14, "[1]"),
1244                            Expr::Index(Box::new(SpannedExpr::new(
1245                                Origin::new(12..13, "1"),
1246                                1.0.into(),
1247                            ))),
1248                        ),
1249                        SpannedExpr::new(
1250                            Origin::new(14..17, "[2]"),
1251                            Expr::Index(Box::new(SpannedExpr::new(
1252                                Origin::new(15..16, "2"),
1253                                2.0.into(),
1254                            ))),
1255                        ),
1256                    ]),
1257                ),
1258            ),
1259            (
1260                "foo.bar.baz[*]",
1261                SpannedExpr::new(
1262                    Origin::new(0..14, "foo.bar.baz[*]"),
1263                    Expr::context([
1264                        SpannedExpr::new(Origin::new(0..3, "foo"), Expr::ident("foo")),
1265                        SpannedExpr::new(Origin::new(4..7, "bar"), Expr::ident("bar")),
1266                        SpannedExpr::new(Origin::new(8..11, "baz"), Expr::ident("baz")),
1267                        SpannedExpr::new(
1268                            Origin::new(11..14, "[*]"),
1269                            Expr::Index(Box::new(SpannedExpr::new(
1270                                Origin::new(12..13, "*"),
1271                                Expr::Star,
1272                            ))),
1273                        ),
1274                    ]),
1275                ),
1276            ),
1277            (
1278                "vegetables.*.ediblePortions",
1279                SpannedExpr::new(
1280                    Origin::new(0..27, "vegetables.*.ediblePortions"),
1281                    Expr::context(vec![
1282                        SpannedExpr::new(
1283                            Origin::new(0..10, "vegetables"),
1284                            Expr::ident("vegetables"),
1285                        ),
1286                        SpannedExpr::new(Origin::new(11..12, "*"), Expr::Star),
1287                        SpannedExpr::new(
1288                            Origin::new(13..27, "ediblePortions"),
1289                            Expr::ident("ediblePortions"),
1290                        ),
1291                    ]),
1292                ),
1293            ),
1294            (
1295                // Sanity check for our associativity: the top level Expr here
1296                // should be `BinOp::Or`.
1297                "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1298                SpannedExpr::new(
1299                    Origin::new(
1300                        0..88,
1301                        "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1302                    ),
1303                    Expr::BinOp {
1304                        lhs: Box::new(SpannedExpr::new(
1305                            Origin::new(
1306                                0..59,
1307                                "github.ref == 'refs/heads/main' && 'value_for_main_branch'",
1308                            ),
1309                            Expr::BinOp {
1310                                lhs: Box::new(SpannedExpr::new(
1311                                    Origin::new(0..32, "github.ref == 'refs/heads/main'"),
1312                                    Expr::BinOp {
1313                                        lhs: Box::new(SpannedExpr::new(
1314                                            Origin::new(0..10, "github.ref"),
1315                                            Expr::context(vec![
1316                                                SpannedExpr::new(
1317                                                    Origin::new(0..6, "github"),
1318                                                    Expr::ident("github"),
1319                                                ),
1320                                                SpannedExpr::new(
1321                                                    Origin::new(7..10, "ref"),
1322                                                    Expr::ident("ref"),
1323                                                ),
1324                                            ]),
1325                                        )),
1326                                        op: BinOp::Eq,
1327                                        rhs: Box::new(SpannedExpr::new(
1328                                            Origin::new(14..31, "'refs/heads/main'"),
1329                                            Expr::Literal(Literal::String(
1330                                                "refs/heads/main".into(),
1331                                            )),
1332                                        )),
1333                                    },
1334                                )),
1335                                op: BinOp::And,
1336                                rhs: Box::new(SpannedExpr::new(
1337                                    Origin::new(35..58, "'value_for_main_branch'"),
1338                                    Expr::Literal(Literal::String("value_for_main_branch".into())),
1339                                )),
1340                            },
1341                        )),
1342                        op: BinOp::Or,
1343                        rhs: Box::new(SpannedExpr::new(
1344                            Origin::new(62..88, "'value_for_other_branches'"),
1345                            Expr::Literal(Literal::String("value_for_other_branches".into())),
1346                        )),
1347                    },
1348                ),
1349            ),
1350            (
1351                "(true || false) == true",
1352                SpannedExpr::new(
1353                    Origin::new(0..23, "(true || false) == true"),
1354                    Expr::BinOp {
1355                        lhs: Box::new(SpannedExpr::new(
1356                            Origin::new(1..14, "true || false"),
1357                            Expr::BinOp {
1358                                lhs: Box::new(SpannedExpr::new(
1359                                    Origin::new(1..5, "true"),
1360                                    true.into(),
1361                                )),
1362                                op: BinOp::Or,
1363                                rhs: Box::new(SpannedExpr::new(
1364                                    Origin::new(9..14, "false"),
1365                                    false.into(),
1366                                )),
1367                            },
1368                        )),
1369                        op: BinOp::Eq,
1370                        rhs: Box::new(SpannedExpr::new(Origin::new(19..23, "true"), true.into())),
1371                    },
1372                ),
1373            ),
1374            (
1375                "!(!true || false)",
1376                SpannedExpr::new(
1377                    Origin::new(0..17, "!(!true || false)"),
1378                    Expr::UnOp {
1379                        op: UnOp::Not,
1380                        expr: Box::new(SpannedExpr::new(
1381                            Origin::new(2..16, "!true || false"),
1382                            Expr::BinOp {
1383                                lhs: Box::new(SpannedExpr::new(
1384                                    Origin::new(2..7, "!true"),
1385                                    Expr::UnOp {
1386                                        op: UnOp::Not,
1387                                        expr: Box::new(SpannedExpr::new(
1388                                            Origin::new(3..7, "true"),
1389                                            true.into(),
1390                                        )),
1391                                    },
1392                                )),
1393                                op: BinOp::Or,
1394                                rhs: Box::new(SpannedExpr::new(
1395                                    Origin::new(11..16, "false"),
1396                                    false.into(),
1397                                )),
1398                            },
1399                        )),
1400                    },
1401                ),
1402            ),
1403            (
1404                "foobar[format('{0}', 'event')]",
1405                SpannedExpr::new(
1406                    Origin::new(0..30, "foobar[format('{0}', 'event')]"),
1407                    Expr::context([
1408                        SpannedExpr::new(Origin::new(0..6, "foobar"), Expr::ident("foobar")),
1409                        SpannedExpr::new(
1410                            Origin::new(6..30, "[format('{0}', 'event')]"),
1411                            Expr::Index(Box::new(SpannedExpr::new(
1412                                Origin::new(7..29, "format('{0}', 'event')"),
1413                                Expr::Call(Call {
1414                                    func: Function("format"),
1415                                    args: vec![
1416                                        SpannedExpr::new(
1417                                            Origin::new(14..19, "'{0}'"),
1418                                            Expr::from("{0}"),
1419                                        ),
1420                                        SpannedExpr::new(
1421                                            Origin::new(21..28, "'event'"),
1422                                            Expr::from("event"),
1423                                        ),
1424                                    ],
1425                                }),
1426                            ))),
1427                        ),
1428                    ]),
1429                ),
1430            ),
1431            (
1432                "github.actor_id == '49699333'",
1433                SpannedExpr::new(
1434                    Origin::new(0..29, "github.actor_id == '49699333'"),
1435                    Expr::BinOp {
1436                        lhs: SpannedExpr::new(
1437                            Origin::new(0..15, "github.actor_id"),
1438                            Expr::context(vec![
1439                                SpannedExpr::new(
1440                                    Origin::new(0..6, "github"),
1441                                    Expr::ident("github"),
1442                                ),
1443                                SpannedExpr::new(
1444                                    Origin::new(7..15, "actor_id"),
1445                                    Expr::ident("actor_id"),
1446                                ),
1447                            ]),
1448                        )
1449                        .into(),
1450                        op: BinOp::Eq,
1451                        rhs: Box::new(SpannedExpr::new(
1452                            Origin::new(19..29, "'49699333'"),
1453                            Expr::from("49699333"),
1454                        )),
1455                    },
1456                ),
1457            ),
1458        ];
1459
1460        for (case, expr) in cases {
1461            assert_eq!(*expr, Expr::parse(case).unwrap());
1462        }
1463    }
1464
1465    #[test]
1466    fn test_expr_constant_reducible() -> Result<()> {
1467        for (expr, reducible) in &[
1468            ("'foo'", true),
1469            ("1", true),
1470            ("true", true),
1471            ("null", true),
1472            // boolean and unary expressions of all literals are
1473            // always reducible.
1474            ("!true", true),
1475            ("!null", true),
1476            ("true && false", true),
1477            ("true || false", true),
1478            ("null && !null && true", true),
1479            // formats/contains/startsWith/endsWith are reducible
1480            // if all of their arguments are reducible.
1481            ("format('{0} {1}', 'foo', 'bar')", true),
1482            ("format('{0} {1}', 1, 2)", true),
1483            ("format('{0} {1}', 1, '2')", true),
1484            ("contains('foo', 'bar')", true),
1485            ("startsWith('foo', 'bar')", true),
1486            ("endsWith('foo', 'bar')", true),
1487            ("startsWith(some.context, 'bar')", false),
1488            ("endsWith(some.context, 'bar')", false),
1489            // Nesting works as long as the nested call is also reducible.
1490            ("format('{0} {1}', '1', format('{0}', null))", true),
1491            ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
1492            ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
1493            ("foo", false),
1494            ("foo.bar", false),
1495            ("foo.bar[1]", false),
1496            ("foo.bar == 'bar'", false),
1497            ("foo.bar || bar || baz", false),
1498            ("foo.bar && bar && baz", false),
1499        ] {
1500            let expr = Expr::parse(expr)?;
1501            assert_eq!(expr.constant_reducible(), *reducible);
1502        }
1503
1504        Ok(())
1505    }
1506
1507    #[test]
1508    fn test_evaluate_constant_complex_expressions() -> Result<()> {
1509        use crate::Evaluation;
1510
1511        let test_cases = &[
1512            // Nested operations
1513            ("!false", Evaluation::Boolean(true)),
1514            ("!true", Evaluation::Boolean(false)),
1515            ("!(true && false)", Evaluation::Boolean(true)),
1516            // Complex boolean logic
1517            ("true && (false || true)", Evaluation::Boolean(true)),
1518            ("false || (true && false)", Evaluation::Boolean(false)),
1519            // Mixed function calls
1520            (
1521                "contains(format('{0} {1}', 'hello', 'world'), 'world')",
1522                Evaluation::Boolean(true),
1523            ),
1524            (
1525                "startsWith(format('prefix_{0}', 'test'), 'prefix')",
1526                Evaluation::Boolean(true),
1527            ),
1528        ];
1529
1530        for (expr_str, expected) in test_cases {
1531            let expr = Expr::parse(expr_str)?;
1532            let result = expr.consteval().unwrap();
1533            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1534        }
1535
1536        Ok(())
1537    }
1538
1539    #[test]
1540    fn test_case_insensitive_string_comparison() -> Result<()> {
1541        use crate::Evaluation;
1542
1543        let test_cases = &[
1544            // == is case-insensitive for strings
1545            ("'hello' == 'hello'", Evaluation::Boolean(true)),
1546            ("'hello' == 'HELLO'", Evaluation::Boolean(true)),
1547            ("'Hello' == 'hELLO'", Evaluation::Boolean(true)),
1548            ("'abc' == 'def'", Evaluation::Boolean(false)),
1549            // != is case-insensitive for strings
1550            ("'hello' != 'HELLO'", Evaluation::Boolean(false)),
1551            ("'abc' != 'def'", Evaluation::Boolean(true)),
1552            // Comparison operators are case-insensitive for strings
1553            ("'abc' < 'DEF'", Evaluation::Boolean(true)),
1554            ("'ABC' < 'def'", Evaluation::Boolean(true)),
1555            ("'abc' >= 'ABC'", Evaluation::Boolean(true)),
1556            ("'ABC' <= 'abc'", Evaluation::Boolean(true)),
1557            // Greek sigma: ς (final) and σ (non-final) both uppercase to Σ.
1558            // This is why we use to_uppercase() instead of to_lowercase().
1559            ("'\u{03C3}' == '\u{03C2}'", Evaluation::Boolean(true)), // σ == ς
1560            ("'\u{03A3}' == '\u{03C3}'", Evaluation::Boolean(true)), // Σ == σ
1561            ("'\u{03A3}' == '\u{03C2}'", Evaluation::Boolean(true)), // Σ == ς
1562            // Array contains with case-insensitive string matching
1563            (
1564                "contains(fromJSON('[\"Hello\", \"World\"]'), 'hello')",
1565                Evaluation::Boolean(true),
1566            ),
1567            (
1568                "contains(fromJSON('[\"hello\", \"world\"]'), 'WORLD')",
1569                Evaluation::Boolean(true),
1570            ),
1571            (
1572                "contains(fromJSON('[\"ABC\"]'), 'abc')",
1573                Evaluation::Boolean(true),
1574            ),
1575            (
1576                "contains(fromJSON('[\"abc\"]'), 'def')",
1577                Evaluation::Boolean(false),
1578            ),
1579        ];
1580
1581        for (expr_str, expected) in test_cases {
1582            let expr = Expr::parse(expr_str)?;
1583            let result = expr.consteval().unwrap();
1584            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1585        }
1586
1587        Ok(())
1588    }
1589
1590    #[test]
1591    fn test_evaluation_sema_display() {
1592        use crate::Evaluation;
1593
1594        let test_cases = &[
1595            (Evaluation::String("hello".to_string()), "hello"),
1596            (Evaluation::Number(42.0), "42"),
1597            (Evaluation::Number(3.14), "3.14"),
1598            (Evaluation::Boolean(true), "true"),
1599            (Evaluation::Boolean(false), "false"),
1600            (Evaluation::Null, ""),
1601        ];
1602
1603        for (result, expected) in test_cases {
1604            assert_eq!(result.sema().to_string(), *expected);
1605        }
1606    }
1607
1608    #[test]
1609    fn test_evaluation_result_to_boolean() {
1610        use crate::Evaluation;
1611
1612        let test_cases = &[
1613            (Evaluation::Boolean(true), true),
1614            (Evaluation::Boolean(false), false),
1615            (Evaluation::Null, false),
1616            (Evaluation::Number(0.0), false),
1617            (Evaluation::Number(1.0), true),
1618            (Evaluation::Number(-1.0), true),
1619            (Evaluation::String("".to_string()), false),
1620            (Evaluation::String("hello".to_string()), true),
1621            (Evaluation::Array(vec![]), true), // Arrays are always truthy
1622            (Evaluation::Object(std::collections::HashMap::new()), true), // Dictionaries are always truthy
1623        ];
1624
1625        for (result, expected) in test_cases {
1626            assert_eq!(result.as_boolean(), *expected);
1627        }
1628    }
1629
1630    #[test]
1631    fn test_evaluation_result_to_number() {
1632        use crate::Evaluation;
1633
1634        // Non-string types
1635        let test_cases = &[
1636            (Evaluation::Number(42.0), 42.0),
1637            (Evaluation::Number(0.0), 0.0),
1638            (Evaluation::Boolean(true), 1.0),
1639            (Evaluation::Boolean(false), 0.0),
1640            (Evaluation::Null, 0.0),
1641        ];
1642
1643        for (eval, expected) in test_cases {
1644            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", eval);
1645        }
1646
1647        let string_cases: &[(&str, f64)] = &[
1648            // Empty / whitespace-only
1649            ("", 0.0),
1650            ("   ", 0.0),
1651            ("\t", 0.0),
1652            // Whitespace trimming
1653            ("   123   ", 123.0),
1654            (" 42 ", 42.0),
1655            ("   1   ", 1.0),
1656            ("\t5\n", 5.0),
1657            ("  \t123\t  ", 123.0),
1658            // Basic decimal
1659            ("42", 42.0),
1660            ("3.14", 3.14),
1661            // Hex
1662            ("0xff", 255.0),
1663            ("0xfF", 255.0),
1664            ("0xFF", 255.0),
1665            (" 0xff ", 255.0),
1666            ("0x0", 0.0),
1667            ("0x11", 17.0),
1668            // Hex: signed 32-bit two's complement wrapping
1669            ("0x7FFFFFFF", 2147483647.0),
1670            ("0x80000000", -2147483648.0),
1671            ("0xFFFFFFFF", -1.0),
1672            // Octal
1673            ("0o10", 8.0),
1674            (" 0o10 ", 8.0),
1675            ("0o0", 0.0),
1676            ("0o11", 9.0),
1677            // Octal: signed 32-bit two's complement wrapping
1678            ("0o17777777777", 2147483647.0),
1679            ("0o20000000000", -2147483648.0),
1680            // Scientific notation
1681            ("1.2e2", 120.0),
1682            ("1.2E2", 120.0),
1683            ("1.2e-2", 0.012),
1684            (" 1.2e2 ", 120.0),
1685            ("1.2e+2", 120.0),
1686            ("5e0", 5.0),
1687            ("1e3", 1000.0),
1688            ("123e-1", 12.3),
1689            (" +1.2e2 ", 120.0),
1690            (" -1.2E+2 ", -120.0),
1691            // Signs
1692            ("+42", 42.0),
1693            ("  -42  ", -42.0),
1694            ("  3.14  ", 3.14),
1695            ("+0", 0.0),
1696            ("-0", 0.0),
1697            (" +123456.789 ", 123456.789),
1698            (" -123456.789 ", -123456.789),
1699            // Leading zeros -> decimal
1700            ("0123", 123.0),
1701            ("00", 0.0),
1702            ("007", 7.0),
1703            ("010", 10.0),
1704            // Trailing/leading dot
1705            ("123.", 123.0),
1706            (".5", 0.5),
1707        ];
1708
1709        for (input, expected) in string_cases {
1710            let eval = Evaluation::String(input.to_string());
1711            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", input);
1712        }
1713
1714        // Infinity cases
1715        let infinity_cases: &[(&str, f64)] = &[
1716            ("Infinity", f64::INFINITY),
1717            (" Infinity ", f64::INFINITY),
1718            ("+Infinity", f64::INFINITY),
1719            ("-Infinity", f64::NEG_INFINITY),
1720            (" -Infinity ", f64::NEG_INFINITY),
1721        ];
1722
1723        for (input, expected) in infinity_cases {
1724            let eval = Evaluation::String(input.to_string());
1725            assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", input);
1726        }
1727
1728        // NaN cases: all verified against GitHub Actions CI.
1729        let nan_cases: &[&str] = &[
1730            // Invalid strings
1731            "hello",
1732            "abc",
1733            " abc ",
1734            " NaN ",
1735            // Partial/malformed numerics
1736            "123abc",
1737            "abc123",
1738            "100a",
1739            "12.3.4",
1740            "1e2e3",
1741            "1 2",
1742            "1_000",
1743            "+",
1744            "-",
1745            ".",
1746            // Binary notation
1747            "0b1010",
1748            "0B1010",
1749            "0b0",
1750            "0b1",
1751            "0b11",
1752            " 0b11 ",
1753            // Uppercase prefixes are NOT supported
1754            "0XFF",
1755            "0O10",
1756            // Signed prefixed numbers are NOT supported
1757            "-0xff",
1758            "+0xff",
1759            "-0o10",
1760            "+0o10",
1761            "-0b11",
1762            // Empty prefixes (no digits after prefix)
1763            "0x",
1764            "0o",
1765            "0b",
1766            // Invalid digits for the base
1767            "0xZZ",
1768            "0o89",
1769            "0b23",
1770            // Hex/octal values exceeding 32-bit
1771            "0x100000000",
1772            "0o40000000000",
1773            // "inf" abbreviation rejected by GH runner
1774            "inf",
1775            "Inf",
1776            "INF",
1777            "+inf",
1778            "-inf",
1779            " inf ",
1780        ];
1781
1782        for input in nan_cases {
1783            let eval = Evaluation::String(input.to_string());
1784            assert!(
1785                eval.as_number().is_nan(),
1786                "as_number() for {:?} should be NaN",
1787                input
1788            );
1789        }
1790    }
1791
1792    #[test]
1793    fn test_github_actions_logical_semantics() -> Result<()> {
1794        use crate::Evaluation;
1795
1796        // Test GitHub Actions-specific && and || semantics
1797        let test_cases = &[
1798            // && returns the first falsy value, or the last value if all are truthy
1799            ("false && 'hello'", Evaluation::Boolean(false)),
1800            ("null && 'hello'", Evaluation::Null),
1801            ("'' && 'hello'", Evaluation::String("".to_string())),
1802            (
1803                "'hello' && 'world'",
1804                Evaluation::String("world".to_string()),
1805            ),
1806            ("true && 42", Evaluation::Number(42.0)),
1807            // || returns the first truthy value, or the last value if all are falsy
1808            ("true || 'hello'", Evaluation::Boolean(true)),
1809            (
1810                "'hello' || 'world'",
1811                Evaluation::String("hello".to_string()),
1812            ),
1813            ("false || 'hello'", Evaluation::String("hello".to_string())),
1814            ("null || false", Evaluation::Boolean(false)),
1815            ("'' || null", Evaluation::Null),
1816        ];
1817
1818        for (expr_str, expected) in test_cases {
1819            let expr = Expr::parse(expr_str)?;
1820            let result = expr.consteval().unwrap();
1821            assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
1822        }
1823
1824        Ok(())
1825    }
1826
1827    #[test]
1828    fn test_expr_has_constant_reducible_subexpr() -> Result<()> {
1829        for (expr, reducible) in &[
1830            // Literals are not considered reducible subexpressions.
1831            ("'foo'", false),
1832            ("1", false),
1833            ("true", false),
1834            ("null", false),
1835            // Non-reducible expressions with reducible subexpressions
1836            (
1837                "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
1838                true,
1839            ),
1840            ("foobar[format('{0}', 'event')]", true),
1841        ] {
1842            let expr = Expr::parse(expr)?;
1843            assert_eq!(!expr.constant_reducible_subexprs().is_empty(), *reducible);
1844        }
1845        Ok(())
1846    }
1847
1848    #[test]
1849    fn test_expr_contexts() -> Result<()> {
1850        // A single context.
1851        let expr = Expr::parse("foo.bar.baz[1].qux")?;
1852        assert_eq!(
1853            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
1854            ["foo.bar.baz[1].qux",]
1855        );
1856
1857        // Multiple contexts.
1858        let expr = Expr::parse("foo.bar[1].baz || abc.def")?;
1859        assert_eq!(
1860            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
1861            ["foo.bar[1].baz", "abc.def",]
1862        );
1863
1864        // Two contexts, one as part of a computed index.
1865        let expr = Expr::parse("foo.bar[abc.def]")?;
1866        assert_eq!(
1867            expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
1868            ["foo.bar[abc.def]", "abc.def",]
1869        );
1870
1871        Ok(())
1872    }
1873
1874    #[test]
1875    fn test_expr_dataflow_contexts() -> Result<()> {
1876        // Trivial cases.
1877        let expr = Expr::parse("foo.bar")?;
1878        assert_eq!(
1879            expr.dataflow_contexts()
1880                .iter()
1881                .map(|t| t.1.raw)
1882                .collect::<Vec<_>>(),
1883            ["foo.bar"]
1884        );
1885
1886        let expr = Expr::parse("foo.bar[1]")?;
1887        assert_eq!(
1888            expr.dataflow_contexts()
1889                .iter()
1890                .map(|t| t.1.raw)
1891                .collect::<Vec<_>>(),
1892            ["foo.bar[1]"]
1893        );
1894
1895        // No dataflow due to a boolean expression.
1896        let expr = Expr::parse("foo.bar == 'bar'")?;
1897        assert!(expr.dataflow_contexts().is_empty());
1898
1899        // ||: all contexts potentially expand into the evaluation.
1900        let expr = Expr::parse("foo.bar || abc || d.e.f")?;
1901        assert_eq!(
1902            expr.dataflow_contexts()
1903                .iter()
1904                .map(|t| t.1.raw)
1905                .collect::<Vec<_>>(),
1906            ["foo.bar", "abc", "d.e.f"]
1907        );
1908
1909        // &&: only the RHS context(s) expand into the evaluation.
1910        let expr = Expr::parse("foo.bar && abc && d.e.f")?;
1911        assert_eq!(
1912            expr.dataflow_contexts()
1913                .iter()
1914                .map(|t| t.1.raw)
1915                .collect::<Vec<_>>(),
1916            ["d.e.f"]
1917        );
1918
1919        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
1920        assert_eq!(
1921            expr.dataflow_contexts()
1922                .iter()
1923                .map(|t| t.1.raw)
1924                .collect::<Vec<_>>(),
1925            ["foo.bar"]
1926        );
1927
1928        let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
1929        assert_eq!(
1930            expr.dataflow_contexts()
1931                .iter()
1932                .map(|t| t.1.raw)
1933                .collect::<Vec<_>>(),
1934            ["foo.bar", "foo.baz"]
1935        );
1936
1937        let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
1938        assert_eq!(
1939            expr.dataflow_contexts()
1940                .iter()
1941                .map(|t| t.1.raw)
1942                .collect::<Vec<_>>(),
1943            ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
1944        );
1945
1946        let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
1947        assert_eq!(
1948            expr.dataflow_contexts()
1949                .iter()
1950                .map(|t| t.1.raw)
1951                .collect::<Vec<_>>(),
1952            ["foo.bar", "github", "github"]
1953        );
1954
1955        Ok(())
1956    }
1957
1958    #[test]
1959    fn test_spannedexpr_computed_indices() -> Result<()> {
1960        for (expr, computed_indices) in &[
1961            ("foo.bar", vec![]),
1962            ("foo.bar[1]", vec![]),
1963            ("foo.bar[*]", vec![]),
1964            ("foo.bar[abc]", vec!["[abc]"]),
1965            (
1966                "foo.bar[format('{0}', 'foo')]",
1967                vec!["[format('{0}', 'foo')]"],
1968            ),
1969            ("foo.bar[abc].def[efg]", vec!["[abc]", "[efg]"]),
1970        ] {
1971            let expr = Expr::parse(expr)?;
1972
1973            assert_eq!(
1974                expr.computed_indices()
1975                    .iter()
1976                    .map(|e| e.origin.raw)
1977                    .collect::<Vec<_>>(),
1978                *computed_indices
1979            );
1980        }
1981
1982        Ok(())
1983    }
1984
1985    #[test]
1986    fn test_fragment_from_expr() {
1987        for (expr, expected) in &[
1988            ("foo==bar", "foo==bar"),
1989            ("foo    ==   bar", r"foo\s+==\s+bar"),
1990            ("foo == bar", r"foo\s+==\s+bar"),
1991            ("foo(bar)", "foo(bar)"),
1992            ("foo(bar, baz)", r"foo\(bar,\s+baz\)"),
1993            ("foo (bar, baz)", r"foo\s+\(bar,\s+baz\)"),
1994            ("a . b . c . d", r"a\s+\.\s+b\s+\.\s+c\s+\.\s+d"),
1995            ("true \n && \n false", r"true\s+\&\&\s+false"),
1996        ] {
1997            let expr = Expr::parse(expr).unwrap();
1998            match subfeature::Fragment::from(&expr) {
1999                subfeature::Fragment::Raw(actual) => assert_eq!(actual, *expected),
2000                subfeature::Fragment::Regex(actual) => assert_eq!(actual.as_str(), *expected),
2001            };
2002        }
2003    }
2004}