github_actions_expressions/lib.rs
1//! GitHub Actions expression parsing and analysis.
2
3#![deny(missing_docs)]
4
5use std::ops::Deref;
6
7use crate::{
8 call::{Call, Function},
9 context::Context,
10 identifier::Identifier,
11 literal::Literal,
12 op::{BinExpr, BinOp, UnOp},
13};
14
15pub mod call;
16pub mod context;
17pub mod identifier;
18mod lexer;
19pub mod literal;
20pub mod op;
21mod parser;
22
23/// Errors that can occur during expression parsing.
24#[derive(Debug, thiserror::Error)]
25pub enum Error {
26 /// The expression failed to parse according to the grammar.
27 #[error(transparent)]
28 Syntax(#[from] SyntaxError),
29 /// The expression contains an invalid function call.
30 #[error("Invalid function call")]
31 Call(#[from] call::Error),
32}
33
34/// A syntax error encountered while parsing an expression.
35#[derive(Copy, Clone, Debug, PartialEq, Eq, thiserror::Error)]
36#[error("invalid expression syntax: {message} (at offset {offset})")]
37pub struct SyntaxError {
38 /// A human-readable description of the error.
39 pub message: &'static str,
40 /// The byte offset within the expression at which the error occurred.
41 pub offset: usize,
42}
43
44/// Represents the origin of an expression, including its source span
45/// and unparsed form.
46#[derive(Copy, Clone, Debug, PartialEq)]
47pub struct Origin<'src> {
48 /// The expression's source span.
49 pub span: subfeature::Span,
50 /// The expression's unparsed form, as it appears in the source.
51 ///
52 /// This is recorded exactly as it appears in the source, *except*
53 /// that leading and trailing whitespace is stripped. This is stripped
54 /// because it's (1) non-semantic, and (2) can cause all kinds of issues
55 /// when attempting to map expressions back to YAML source features.
56 pub raw: &'src str,
57}
58
59impl<'a> Origin<'a> {
60 /// Create a new origin from the given span and raw form.
61 pub fn new(span: impl Into<subfeature::Span>, raw: &'a str) -> Self {
62 Self {
63 span: span.into(),
64 raw: raw.trim(),
65 }
66 }
67}
68
69/// An expression along with its source origin (span and unparsed form).
70///
71/// An expression's span covers exactly the bytes the expression
72/// was parsed from, with no surrounding whitespace. A parenthesized expression
73/// includes its parentheses, so every span is a balanced, self-contained slice
74/// of the source.
75///
76/// NOTE: `SpannedExpr` has a manual [`PartialEq`] implementation that only considers
77/// the underlying expression, not the span. In opther words, two `SpannedExpr` instances
78/// are equal if their ASTs are equal, even if their source spans are not.
79#[derive(Debug)]
80pub struct SpannedExpr<'src> {
81 /// The expression's source origin.
82 pub origin: Origin<'src>,
83 /// The expression itself.
84 pub inner: Expr<'src>,
85}
86
87impl<'a> SpannedExpr<'a> {
88 /// Creates a new `SpannedExpr` from an expression and its span.
89 pub(crate) fn new(origin: Origin<'a>, inner: Expr<'a>) -> Self {
90 Self { origin, inner }
91 }
92
93 /// Returns the contexts in this expression, along with their origins.
94 ///
95 /// This includes all contexts in the expression, even those that don't directly flow into
96 /// the evaluation. For example, `${{ foo.bar == 'abc' }}` returns `foo.bar` since it's a
97 /// context in the expression, even though it flows into a boolean evaluation rather than
98 /// directly into the output.
99 ///
100 /// For dataflow contexts, see [`SpannedExpr::dataflow_contexts`].
101 pub fn contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> {
102 let mut contexts = vec![];
103
104 match self.deref() {
105 Expr::Index(expr) => contexts.extend(expr.contexts()),
106 Expr::Call(Call { func: _, args }) => {
107 for arg in args {
108 contexts.extend(arg.contexts());
109 }
110 }
111 Expr::Context(ctx) => {
112 // Record the context itself.
113 contexts.push((ctx, &self.origin));
114
115 // The context's parts can also contain independent contexts,
116 // e.g. computed indices like `bar.baz` in `foo[bar.baz]`.
117 ctx.parts
118 .iter()
119 .for_each(|part| contexts.extend(part.contexts()));
120 }
121 Expr::BinExpr(BinExpr { lhs, op: _, rhs }) => {
122 contexts.extend(lhs.contexts());
123 contexts.extend(rhs.contexts());
124 }
125 Expr::UnExpr { op: _, expr } => contexts.extend(expr.contexts()),
126 _ => (),
127 }
128
129 contexts
130 }
131
132 /// Returns the contexts in this expression that directly flow into the
133 /// expression's evaluation.
134 ///
135 /// For example `${{ foo.bar }}` returns `foo.bar` since the value
136 /// of `foo.bar` flows into the evaluation. On the other hand,
137 /// `${{ foo.bar == 'abc' }}` returns no expanded contexts,
138 /// since the value of `foo.bar` flows into a boolean evaluation
139 /// that gets expanded.
140 pub fn dataflow_contexts(&self) -> Vec<(&Context<'a>, &Origin<'a>)> {
141 let mut contexts = vec![];
142
143 match self.deref() {
144 Expr::Call(Call { func, args }) => {
145 // These functions, when evaluated, produce an evaluation
146 // that includes some or all of the contexts listed in
147 // their arguments.
148 if matches!(func, Function::ToJSON | Function::Format | Function::Join) {
149 for arg in args {
150 contexts.extend(arg.dataflow_contexts());
151 }
152 }
153 }
154 // NOTE: We intentionally don't handle the `func(...).foo.bar`
155 // case differently here, since a call followed by a
156 // context access *can* flow into the evaluation.
157 // For example, `${{ fromJSON(something) }}` evaluates to
158 // `Object` but `${{ fromJSON(something).foo }}` evaluates
159 // to the contents of `something.foo`.
160 Expr::Context(ctx) => contexts.push((ctx, &self.origin)),
161 Expr::BinExpr(BinExpr { lhs, op, rhs }) => match op {
162 // With && only the RHS can flow into the evaluation as a context
163 // (rather than a boolean).
164 BinOp::And => {
165 contexts.extend(rhs.dataflow_contexts());
166 }
167 // With || either the LHS or RHS can flow into the evaluation as a context.
168 BinOp::Or => {
169 contexts.extend(lhs.dataflow_contexts());
170 contexts.extend(rhs.dataflow_contexts());
171 }
172 _ => (),
173 },
174 _ => (),
175 }
176
177 contexts
178 }
179
180 /// Returns all possible leaf expressions that could be the result
181 /// of evaluating this expression.
182 ///
183 /// Uses GitHub Actions' short-circuit semantics:
184 /// - `A && B`: only B can flow into the result (A is a condition)
185 /// - `A || B`: either A or B can be the result
186 ///
187 /// Leaf expressions are any non-`BinOp` expressions: literals, contexts,
188 /// function calls, etc.
189 ///
190 /// For example, `${{ foo.bar == 'true' && 'hello' || '' }}` returns
191 /// `['hello', '']` since those are the two possible evaluated values.
192 /// `${{ foo.abc || foo.def }}` returns `[foo.abc, foo.def]`.
193 pub fn leaf_expressions(&self) -> Vec<&SpannedExpr<'a>> {
194 let mut leaves = vec![];
195
196 match self.deref() {
197 Expr::BinExpr(BinExpr { lhs, op, rhs }) => match op {
198 BinOp::And => {
199 leaves.extend(rhs.leaf_expressions());
200 }
201 BinOp::Or => {
202 leaves.extend(lhs.leaf_expressions());
203 leaves.extend(rhs.leaf_expressions());
204 }
205 // Comparison operators produce booleans, not their operands.
206 _ => leaves.push(self),
207 },
208 _ => leaves.push(self),
209 }
210
211 leaves
212 }
213
214 /// Returns any computed indices in this expression.
215 ///
216 /// A computed index is any index operation with a non-literal
217 /// evaluation, e.g. `foo[a.b.c]`.
218 pub fn computed_indices(&self) -> Vec<&SpannedExpr<'a>> {
219 let mut index_exprs = vec![];
220
221 match self.deref() {
222 Expr::Call(Call { func: _, args }) => {
223 for arg in args {
224 index_exprs.extend(arg.computed_indices());
225 }
226 }
227 Expr::Index(spanned_expr)
228 // NOTE: We consider any non-literal, non-star index computed.
229 if !spanned_expr.is_literal() && !matches!(spanned_expr.inner, Expr::Star) => {
230 index_exprs.push(self);
231 }
232 Expr::Context(context) => {
233 for part in &context.parts {
234 index_exprs.extend(part.computed_indices());
235 }
236 }
237 Expr::BinExpr(BinExpr { lhs, op: _, rhs }) => {
238 index_exprs.extend(lhs.computed_indices());
239 index_exprs.extend(rhs.computed_indices());
240 }
241 Expr::UnExpr { op: _, expr } => {
242 index_exprs.extend(expr.computed_indices());
243 }
244 _ => {}
245 }
246
247 index_exprs
248 }
249
250 /// Like [`Expr::constant_reducible`], but for all subexpressions
251 /// rather than the top-level expression.
252 ///
253 /// This has slightly different semantics than `constant_reducible`:
254 /// it doesn't include "trivially" reducible expressions like literals,
255 /// since flagging these as reducible within a larger expression
256 /// would be misleading.
257 pub fn constant_reducible_subexprs(&self) -> Vec<&SpannedExpr<'a>> {
258 if !self.is_literal() && self.constant_reducible() {
259 return vec![self];
260 }
261
262 let mut subexprs = vec![];
263
264 match self.deref() {
265 Expr::Call(Call { func: _, args }) => {
266 for arg in args {
267 subexprs.extend(arg.constant_reducible_subexprs());
268 }
269 }
270 Expr::Context(ctx) => {
271 // contexts themselves are never reducible, but they might
272 // contains reducible index subexpressions.
273 for part in &ctx.parts {
274 subexprs.extend(part.constant_reducible_subexprs());
275 }
276 }
277 Expr::BinExpr(BinExpr { lhs, op: _, rhs }) => {
278 subexprs.extend(lhs.constant_reducible_subexprs());
279 subexprs.extend(rhs.constant_reducible_subexprs());
280 }
281 Expr::UnExpr { op: _, expr } => subexprs.extend(expr.constant_reducible_subexprs()),
282
283 Expr::Index(expr) => subexprs.extend(expr.constant_reducible_subexprs()),
284 _ => {}
285 }
286
287 subexprs
288 }
289}
290
291impl<'a> Deref for SpannedExpr<'a> {
292 type Target = Expr<'a>;
293
294 fn deref(&self) -> &Self::Target {
295 &self.inner
296 }
297}
298
299impl<'doc> From<&SpannedExpr<'doc>> for subfeature::Fragment<'doc> {
300 fn from(expr: &SpannedExpr<'doc>) -> Self {
301 Self::new(expr.origin.raw)
302 }
303}
304
305impl PartialEq for SpannedExpr<'_> {
306 fn eq(&self, other: &Self) -> bool {
307 self.inner == other.inner
308 }
309}
310
311/// Represents a GitHub Actions expression.
312#[derive(Debug, PartialEq)]
313pub enum Expr<'src> {
314 /// A literal value.
315 Literal(Literal<'src>),
316 /// The `*` literal within an index or context.
317 Star,
318 /// A function call.
319 Call(Call<'src>),
320 /// A context identifier component, e.g. `github` in `github.actor`.
321 Identifier(Identifier<'src>),
322 /// A context index component, e.g. `[0]` in `foo[0]`.
323 Index(Box<SpannedExpr<'src>>),
324 /// A full context reference.
325 Context(Context<'src>),
326 /// A binary expression, either logical or arithmetic.
327 BinExpr(BinExpr<'src>),
328 /// A unary expression. Negation (`!`) is currently the only `UnOp`.
329 UnExpr {
330 /// The unary operator.
331 op: UnOp,
332 /// The expression to apply the operator to.
333 expr: Box<SpannedExpr<'src>>,
334 },
335}
336
337impl<'src> Expr<'src> {
338 /// Convenience API for making an [`Expr::Identifier`].
339 fn ident(i: &'src str) -> Self {
340 Self::Identifier(Identifier(i))
341 }
342
343 /// Convenience API for making an [`Expr::Context`].
344 fn context(components: impl Into<Vec<SpannedExpr<'src>>>) -> Self {
345 Self::Context(Context::new(components))
346 }
347
348 /// Returns whether the expression is a literal.
349 pub fn is_literal(&self) -> bool {
350 matches!(self, Expr::Literal(_))
351 }
352
353 /// Returns whether the expression is constant reducible.
354 ///
355 /// "Constant reducible" is similar to "constant foldable" but with
356 /// meta-evaluation semantics: the expression `5` would not be
357 /// constant foldable in a normal program (because it's already
358 /// an atom), but is "constant reducible" in a GitHub Actions expression
359 /// because an expression containing it (e.g. `${{ 5 }}`) can be elided
360 /// entirely and replaced with `5`.
361 ///
362 /// There are three kinds of reducible expressions:
363 ///
364 /// 1. Literals, which reduce to their literal value;
365 /// 2. Binops/unops with reducible subexpressions, which reduce
366 /// to their evaluation;
367 /// 3. Select function calls where the semantics of the function
368 /// mean that reducible arguments make the call itself reducible.
369 ///
370 /// NOTE: This implementation is sound but not complete.
371 pub fn constant_reducible(&self) -> bool {
372 match self {
373 // Literals are always reducible.
374 Expr::Literal(_) => true,
375 // Binops are reducible if their LHS and RHS are reducible.
376 Expr::BinExpr(BinExpr { lhs, op: _, rhs }) => {
377 lhs.constant_reducible() && rhs.constant_reducible()
378 }
379 // Unops are reducible if their interior expression is reducible.
380 Expr::UnExpr { op: _, expr } => expr.constant_reducible(),
381 Expr::Call(Call { func, args }) => {
382 // These functions are reducible if their arguments are reducible.
383 // TODO(ww): `fromJSON` *is* frequently reducible, but
384 // doing so soundly with subexpressions is annoying.
385 // We overapproximate for now and consider it non-reducible.
386 if matches!(
387 func,
388 Function::Contains
389 | Function::StartsWith
390 | Function::EndsWith
391 | Function::Format
392 | Function::ToJSON
393 | Function::Join // | Function::FromJSON
394 ) {
395 args.iter().all(|e| e.constant_reducible())
396 } else {
397 false
398 }
399 }
400 // Everything else is presumed non-reducible.
401 _ => false,
402 }
403 }
404
405 /// Parses the given string into an expression.
406 pub fn parse(expr: &'src str) -> Result<SpannedExpr<'src>, Error> {
407 parser::parse(expr)
408 }
409
410 /// Returns whether this expression 'commutatively matches' the given expression.
411 ///
412 /// For most expressions, this is the same as equivalence (i.e. `==`).
413 /// For binary expressions that are also commutative, this takes commutivity into account.
414 /// For example, `a == b` is considered to match `b == a`, but `a > b` is not
415 /// considered to match `b > a`. This check is recusive, i.e. two nested binary expressions
416 /// will be fully checked for commutative equivalence.
417 pub fn commutative_matches(&self, other: &Expr<'src>) -> bool {
418 match (self, other) {
419 (Self::BinExpr(sb), Self::BinExpr(ob)) => {
420 if sb.op != ob.op {
421 return false;
422 }
423
424 // Same-position match (lhs/lhs, rhs/rhs); commutative ops
425 // additionally accept the swapped pairing below.
426 let positional = sb.lhs.inner.commutative_matches(&ob.lhs.inner)
427 && sb.rhs.inner.commutative_matches(&ob.rhs.inner);
428
429 match sb.op {
430 BinOp::And | BinOp::Or | BinOp::Eq | BinOp::Neq => {
431 positional
432 || (sb.lhs.inner.commutative_matches(&ob.rhs.inner)
433 && sb.rhs.inner.commutative_matches(&ob.lhs.inner))
434 }
435 _ => positional,
436 }
437 }
438 _ => self == other,
439 }
440 }
441}
442
443impl<'src> From<&'src str> for Expr<'src> {
444 fn from(s: &'src str) -> Self {
445 Expr::Literal(Literal::String(s.into()))
446 }
447}
448
449impl From<String> for Expr<'_> {
450 fn from(s: String) -> Self {
451 Expr::Literal(Literal::String(s.into()))
452 }
453}
454
455impl From<f64> for Expr<'_> {
456 fn from(n: f64) -> Self {
457 Expr::Literal(Literal::Number(n))
458 }
459}
460
461impl From<bool> for Expr<'_> {
462 fn from(b: bool) -> Self {
463 Expr::Literal(Literal::Boolean(b))
464 }
465}
466
467/// The result of evaluating a GitHub Actions expression.
468///
469/// This type represents the possible values that can result from evaluating
470/// GitHub Actions expressions.
471#[derive(Debug, Clone, PartialEq)]
472pub enum Evaluation {
473 /// A string value (includes both string literals and stringified other types).
474 String(String),
475 /// A numeric value.
476 Number(f64),
477 /// A boolean value.
478 Boolean(bool),
479 /// The null value.
480 Null,
481 /// An array value. Array evaluations can only be realized through `fromJSON`.
482 Array(Vec<Evaluation>),
483 /// An object value. Object evaluations can only be realized through `fromJSON`.
484 Object(std::collections::HashMap<String, Evaluation>),
485}
486
487impl TryFrom<serde_json::Value> for Evaluation {
488 type Error = ();
489
490 fn try_from(value: serde_json::Value) -> Result<Self, Self::Error> {
491 match value {
492 serde_json::Value::Null => Ok(Evaluation::Null),
493 serde_json::Value::Bool(b) => Ok(Evaluation::Boolean(b)),
494 serde_json::Value::Number(n) => {
495 if let Some(f) = n.as_f64() {
496 Ok(Evaluation::Number(f))
497 } else {
498 Err(())
499 }
500 }
501 serde_json::Value::String(s) => Ok(Evaluation::String(s)),
502 serde_json::Value::Array(arr) => {
503 let elements = arr
504 .into_iter()
505 .map(|elem| elem.try_into())
506 .collect::<Result<_, _>>()?;
507 Ok(Evaluation::Array(elements))
508 }
509 serde_json::Value::Object(obj) => {
510 let mut map = std::collections::HashMap::new();
511 for (key, value) in obj {
512 map.insert(key, value.try_into()?);
513 }
514 Ok(Evaluation::Object(map))
515 }
516 }
517 }
518}
519
520impl TryInto<serde_json::Value> for Evaluation {
521 type Error = ();
522
523 fn try_into(self) -> Result<serde_json::Value, Self::Error> {
524 match self {
525 Evaluation::Null => Ok(serde_json::Value::Null),
526 Evaluation::Boolean(b) => Ok(serde_json::Value::Bool(b)),
527 Evaluation::Number(n) => {
528 // NOTE: serde_json has different internal representations
529 // for integers and floats, so we need to handle both cases
530 // to ensure we serialize integers without a decimal point.
531 if n.fract() == 0.0 {
532 Ok(serde_json::Value::Number(serde_json::Number::from(
533 n as i64,
534 )))
535 } else if let Some(num) = serde_json::Number::from_f64(n) {
536 Ok(serde_json::Value::Number(num))
537 } else {
538 Err(())
539 }
540 }
541 Evaluation::String(s) => Ok(serde_json::Value::String(s)),
542 Evaluation::Array(arr) => {
543 let elements = arr
544 .into_iter()
545 .map(|elem| elem.try_into())
546 .collect::<Result<_, _>>()?;
547 Ok(serde_json::Value::Array(elements))
548 }
549 Evaluation::Object(obj) => {
550 let mut map = serde_json::Map::new();
551 for (key, value) in obj {
552 map.insert(key, value.try_into()?);
553 }
554 Ok(serde_json::Value::Object(map))
555 }
556 }
557 }
558}
559
560impl Evaluation {
561 /// Convert to a boolean following GitHub Actions truthiness rules.
562 ///
563 /// GitHub Actions truthiness:
564 /// - false and null are falsy
565 /// - Numbers: 0 and NaN are falsy, everything else is truthy
566 /// - Strings: empty string is falsy, everything else is truthy
567 /// - Arrays and dictionaries are always truthy (non-empty objects)
568 pub fn as_boolean(&self) -> bool {
569 match self {
570 Evaluation::Boolean(b) => *b,
571 Evaluation::Null => false,
572 Evaluation::Number(n) => *n != 0.0 && !n.is_nan(),
573 Evaluation::String(s) => !s.is_empty(),
574 // Arrays and objects are always truthy, even if empty.
575 Evaluation::Array(_) | Evaluation::Object(_) => true,
576 }
577 }
578
579 /// Convert to a number following GitHub Actions conversion rules.
580 ///
581 /// See: <https://docs.github.com/en/actions/reference/workflows-and-actions/expressions#operators>
582 pub fn as_number(&self) -> f64 {
583 match self {
584 Evaluation::String(s) => parse_number(s),
585 Evaluation::Number(n) => *n,
586 Evaluation::Boolean(b) => {
587 if *b {
588 1.0
589 } else {
590 0.0
591 }
592 }
593 Evaluation::Null => 0.0,
594 Evaluation::Array(_) | Evaluation::Object(_) => f64::NAN,
595 }
596 }
597
598 /// Returns a wrapper around this evaluation that implements
599 /// GitHub Actions evaluation semantics.
600 pub fn sema(&self) -> EvaluationSema<'_> {
601 EvaluationSema(self)
602 }
603}
604
605/// Parse a string into a number following GitHub Actions coercion rules.
606///
607/// The string is trimmed and then parsed following the rules from the
608/// GitHub Action Runner:
609/// https://github.com/actions/runner/blob/9426c35fdaf2b2e00c3ef751a15c04fa8e2a9582/src/Sdk/Expressions/Sdk/ExpressionUtility.cs#L223
610fn parse_number(s: &str) -> f64 {
611 let trimmed = s.trim();
612 if trimmed.is_empty() {
613 return 0.0;
614 }
615
616 // Decimal / scientific notation first
617 // Only accept finite results; infinity/NaN literals fall through.
618 if let Ok(value) = trimmed.parse::<f64>()
619 && value.is_finite()
620 {
621 return value;
622 }
623
624 // Hex: signed 32-bit.
625 // Values 0x80000000–0xFFFFFFFF wrap negative via two's complement.
626 if let Some(hex_digits) = trimmed.strip_prefix("0x") {
627 return u32::from_str_radix(hex_digits, 16)
628 .map(|n| (n as i32) as f64)
629 .unwrap_or(f64::NAN);
630 }
631
632 // Octal: signed 32-bit.
633 if let Some(oct_digits) = trimmed.strip_prefix("0o") {
634 return u32::from_str_radix(oct_digits, 8)
635 .map(|n| (n as i32) as f64)
636 .unwrap_or(f64::NAN);
637 }
638
639 // Explicit Infinity check — GH runner accepts full "infinity"
640 // (case-insensitive) but NOT the "inf" abbreviation.
641 let after_sign = trimmed
642 .strip_prefix(['+', '-'].as_slice())
643 .unwrap_or(trimmed);
644 if after_sign.eq_ignore_ascii_case("infinity") {
645 return if trimmed.starts_with('-') {
646 f64::NEG_INFINITY
647 } else {
648 f64::INFINITY
649 };
650 }
651
652 f64::NAN
653}
654
655/// A wrapper around `Evaluation` that implements GitHub Actions
656/// various evaluation semantics (comparison, stringification, etc.).
657pub struct EvaluationSema<'a>(&'a Evaluation);
658
659impl EvaluationSema<'_> {
660 /// Converts a string to its uppercase form using GitHub Actions'
661 /// special rules.
662 /// See `toUpperSpecial`:
663 /// <https://github.com/actions/languageservices/blob/cc316ab/expressions/src/result.ts#L209>
664 fn upper_special(value: &str) -> String {
665 // Uppercase everything except the small dotless-ı (U+0131),
666 // which GitHub Actions preserves as-is.
667 let mut result = String::with_capacity(value.len());
668 let mut parts = value.split('ı');
669 if let Some(first) = parts.next() {
670 result.extend(first.chars().flat_map(char::to_uppercase));
671 }
672 for part in parts {
673 result.push('ı');
674 result.extend(part.chars().flat_map(char::to_uppercase));
675 }
676 result
677 }
678}
679
680impl PartialEq for EvaluationSema<'_> {
681 fn eq(&self, other: &Self) -> bool {
682 match (self.0, other.0) {
683 (Evaluation::Null, Evaluation::Null) => true,
684 (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a == b,
685 (Evaluation::Number(a), Evaluation::Number(b)) => a == b,
686 // GitHub Actions string comparisons are case-insensitive.
687 (Evaluation::String(a), Evaluation::String(b)) => {
688 Self::upper_special(a) == Self::upper_special(b)
689 }
690 // Coercion rules: all others convert to number and compare.
691 (a, b) => a.as_number() == b.as_number(),
692 }
693 }
694}
695
696impl PartialOrd for EvaluationSema<'_> {
697 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
698 match (self.0, other.0) {
699 (Evaluation::Null, Evaluation::Null) => Some(std::cmp::Ordering::Equal),
700 (Evaluation::Boolean(a), Evaluation::Boolean(b)) => a.partial_cmp(b),
701 (Evaluation::Number(a), Evaluation::Number(b)) => a.partial_cmp(b),
702 (Evaluation::String(a), Evaluation::String(b)) => {
703 // GitHub Actions string comparisons are case-insensitive.
704 Self::upper_special(a).partial_cmp(&Self::upper_special(b))
705 }
706 // Coercion rules: all others convert to number and compare.
707 (a, b) => a.as_number().partial_cmp(&b.as_number()),
708 }
709 }
710}
711
712impl std::fmt::Display for EvaluationSema<'_> {
713 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
714 match self.0 {
715 Evaluation::String(s) => write!(f, "{}", s),
716 Evaluation::Number(n) => {
717 // Format numbers like GitHub Actions does
718 if n == &f64::INFINITY {
719 write!(f, "Infinity")
720 } else if n == &f64::NEG_INFINITY {
721 write!(f, "-Infinity")
722 } else {
723 // Format with 15 decimal places, parse back to f64 to
724 // clean up trailing noise, then format normally.
725 // See: https://github.com/actions/languageservices/blob/cc316ab/expressions/src/data/number.ts#L10
726 let rounded: f64 = format!("{:.15}", n)
727 .parse()
728 .expect("impossible f64 round-trip error");
729 if rounded.fract() == 0.0 {
730 write!(f, "{}", rounded as i64)
731 } else {
732 write!(f, "{}", rounded)
733 }
734 }
735 }
736 Evaluation::Boolean(b) => write!(f, "{}", b),
737 Evaluation::Null => write!(f, ""),
738 Evaluation::Array(_) => write!(f, "Array"),
739 Evaluation::Object(_) => write!(f, "Object"),
740 }
741 }
742}
743
744impl<'src> Expr<'src> {
745 /// Evaluates a constant-reducible expression to its literal value.
746 ///
747 /// Returns `Some(Evaluation)` if the expression can be constant-evaluated,
748 /// or `None` if the expression contains non-constant elements (like contexts or
749 /// non-reducible function calls).
750 ///
751 /// This implementation follows GitHub Actions' evaluation semantics as documented at:
752 /// https://docs.github.com/en/actions/reference/workflows-and-actions/expressions
753 ///
754 /// # Examples
755 ///
756 /// ```
757 /// use github_actions_expressions::{Expr, Evaluation};
758 ///
759 /// let expr = Expr::parse("'hello'").unwrap();
760 /// let result = expr.consteval().unwrap();
761 /// assert_eq!(result.sema().to_string(), "hello");
762 ///
763 /// let expr = Expr::parse("true && false").unwrap();
764 /// let result = expr.consteval().unwrap();
765 /// assert_eq!(result, Evaluation::Boolean(false));
766 /// ```
767 pub fn consteval(&self) -> Option<Evaluation> {
768 match self {
769 Expr::Literal(literal) => Some(literal.consteval()),
770
771 Expr::BinExpr(BinExpr { lhs, op, rhs }) => {
772 let lhs_val = lhs.consteval()?;
773 let rhs_val = rhs.consteval()?;
774
775 match op {
776 BinOp::And => {
777 // GitHub Actions && semantics: if LHS is falsy, return LHS, else return RHS
778 if lhs_val.as_boolean() {
779 Some(rhs_val)
780 } else {
781 Some(lhs_val)
782 }
783 }
784 BinOp::Or => {
785 // GitHub Actions || semantics: if LHS is truthy, return LHS, else return RHS
786 if lhs_val.as_boolean() {
787 Some(lhs_val)
788 } else {
789 Some(rhs_val)
790 }
791 }
792 BinOp::Eq => Some(Evaluation::Boolean(lhs_val.sema() == rhs_val.sema())),
793 BinOp::Neq => Some(Evaluation::Boolean(lhs_val.sema() != rhs_val.sema())),
794 BinOp::Lt => Some(Evaluation::Boolean(lhs_val.sema() < rhs_val.sema())),
795 BinOp::Le => Some(Evaluation::Boolean(lhs_val.sema() <= rhs_val.sema())),
796 BinOp::Gt => Some(Evaluation::Boolean(lhs_val.sema() > rhs_val.sema())),
797 BinOp::Ge => Some(Evaluation::Boolean(lhs_val.sema() >= rhs_val.sema())),
798 }
799 }
800
801 Expr::UnExpr { op, expr } => {
802 let val = expr.consteval()?;
803 match op {
804 UnOp::Not => Some(Evaluation::Boolean(!val.as_boolean())),
805 }
806 }
807
808 Expr::Call(call) => call.consteval(),
809
810 // Non-constant expressions
811 _ => None,
812 }
813 }
814}
815
816#[cfg(test)]
817mod tests {
818 use std::borrow::Cow;
819
820 use crate::{Error, Literal, context::Context};
821
822 use super::Expr;
823
824 #[test]
825 fn test_literal_string_borrows() {
826 let cases = &[
827 ("'foo'", true),
828 ("'foo bar'", true),
829 ("'foo '' bar'", false),
830 ("'foo''bar'", false),
831 ("'foo''''bar'", false),
832 ];
833
834 for (expr, borrows) in cases {
835 let Expr::Literal(Literal::String(s)) = &*Expr::parse(expr).unwrap() else {
836 panic!("expected a literal string expression for {expr}");
837 };
838
839 assert!(matches!(
840 (s, borrows),
841 (Cow::Borrowed(_), true) | (Cow::Owned(_), false)
842 ));
843 }
844 }
845
846 #[test]
847 fn test_literal_as_str() {
848 let cases = &[
849 ("'foo'", "foo"),
850 ("'foo '' bar'", "foo ' bar"),
851 ("123", "123"),
852 ("123.000", "123"),
853 ("0.0", "0"),
854 ("0.1", "0.1"),
855 ("0.12345", "0.12345"),
856 ("true", "true"),
857 ("false", "false"),
858 ("null", "null"),
859 ];
860
861 for (expr, expected) in cases {
862 let Expr::Literal(expr) = &*Expr::parse(expr).unwrap() else {
863 panic!("expected a literal expression for {expr}");
864 };
865
866 assert_eq!(expr.as_str(), *expected);
867 }
868 }
869
870 #[test]
871 fn test_parse_string_rule() {
872 // Each case maps a source literal to its unescaped value.
873 let cases = &[
874 ("''", ""),
875 ("' '", " "),
876 ("''''", "'"),
877 ("'test'", "test"),
878 ("'spaces are ok'", "spaces are ok"),
879 ("'escaping '' works'", "escaping ' works"),
880 ];
881
882 for (case, expected) in cases {
883 let Expr::Literal(Literal::String(s)) = &*Expr::parse(case).unwrap() else {
884 panic!("expected a literal string expression for {case}");
885 };
886
887 assert_eq!(s, expected);
888 }
889 }
890
891 #[test]
892 fn test_parse_context_rule() {
893 let cases = &[
894 "foo.bar",
895 "github.action_path",
896 "inputs.foo-bar",
897 "inputs.also--valid",
898 "inputs.this__too",
899 "inputs.this__too",
900 "secrets.GH_TOKEN",
901 "foo.*.bar",
902 "github.event.issue.labels.*.name",
903 ];
904
905 for case in cases {
906 assert!(
907 Context::parse(case).is_some(),
908 "{case:?} should parse as a context"
909 );
910 }
911 }
912
913 #[test]
914 fn test_parse_call_rule() {
915 // Function call syntax, exercised with real (known) functions so
916 // that `Expr::parse`'s arity/name validation is satisfied.
917 let cases = &[
918 "success()",
919 "fromJSON(bar)",
920 "toJSON(fromJSON(bar))",
921 "fromJSON(1.23)",
922 "contains(1,2)",
923 "contains(1, 2)",
924 "format('{0} {1}', 1, secret.GH_TOKEN)",
925 "success( )",
926 "fromJSON(inputs.free-threading)",
927 ];
928
929 for case in cases {
930 assert!(Expr::parse(case).is_ok(), "{case:?} should parse");
931 }
932 }
933
934 #[test]
935 fn test_parse_expr_rule() -> Result<(), Error> {
936 // Ensures that we parse multi-line expressions correctly.
937 let multiline = "github.repository_owner == 'Homebrew' &&
938 ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
939 (github.event_name == 'pull_request_target' &&
940 (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
941
942 let multiline2 = "foo.bar.baz[
943 0
944 ]";
945
946 let cases = &[
947 "true",
948 "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
949 "foo || bar || baz",
950 "foo || bar && baz || foo && 1 && 2 && 3 || 4",
951 "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
952 "(true || false) == true",
953 "!(!true || false)",
954 "!(!true || false) == true",
955 "(true == false) == true",
956 "(true == (false || true && (true || false))) == true",
957 "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
958 "fromJSON(bar)[0]",
959 "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
960 multiline,
961 "'a' == 'b' && 'c' || 'd'",
962 "github.event['a']",
963 "github.event['a' == 'b']",
964 "github.event['a' == 'b' && 'c' || 'd']",
965 "github['event']['inputs']['dry-run']",
966 "github[format('{0}', 'event')]",
967 "github['event']['inputs'][github.event.inputs.magic]",
968 "github['event']['inputs'].*",
969 "1 == 1",
970 "1 > 1",
971 "1 >= 1",
972 "matrix.node_version >= 20",
973 "true||false",
974 // Hex literals
975 "0xFF",
976 "0xff",
977 "0x0",
978 "0xFF == 255",
979 // Octal literals
980 "0o10",
981 "0o77",
982 "0o0",
983 // Scientific notation
984 "1e2",
985 "1.5E-3",
986 "1.2e+2",
987 "5e0",
988 // NaN and Infinity literals
989 "NaN",
990 "Infinity",
991 "+Infinity",
992 "-Infinity",
993 "NaN == NaN",
994 "Infinity == Infinity",
995 // Parenthesized compound expressions
996 "2 <= (3 == true)",
997 "0 > (0 < 1)",
998 "(foo || bar) == baz",
999 // Signed numbers
1000 "+42",
1001 "-42",
1002 // Leading/trailing dot
1003 ".5",
1004 "123.",
1005 // Whitespace handling
1006 multiline2,
1007 "fromJSON( github.event.inputs.hmm ) [ 0 ]",
1008 // Parens around a call
1009 "(fromJson('{\"one\": \"one val\"}')).one",
1010 "(fromJson('[\"one\", \"two\"]'))[1]",
1011 ];
1012
1013 for case in cases {
1014 Expr::parse(case).unwrap_or_else(|e| panic!("{case:?} should parse, but failed: {e}"));
1015 }
1016
1017 Ok(())
1018 }
1019
1020 #[test]
1021 fn test_parse_expr_rule_rejects() {
1022 let cases = &[
1023 // "Inf" is not a valid number form; only "Infinity" is accepted.
1024 "-Inf", "+Inf",
1025 ];
1026
1027 for case in cases {
1028 assert!(
1029 Expr::parse(case).is_err(),
1030 "{case:?} should not parse as a valid expression"
1031 );
1032 }
1033 }
1034
1035 #[test]
1036 fn test_parse_snapshot() -> Result<(), Error> {
1037 // These cases pin the parser's exact AST shape and byte-range origins.
1038
1039 insta::assert_debug_snapshot!(Expr::parse("!true || false || true")?, @r#"
1040 SpannedExpr {
1041 origin: Origin {
1042 span: Span {
1043 start: 0,
1044 end: 22,
1045 },
1046 raw: "!true || false || true",
1047 },
1048 inner: BinExpr(
1049 BinExpr {
1050 lhs: SpannedExpr {
1051 origin: Origin {
1052 span: Span {
1053 start: 0,
1054 end: 14,
1055 },
1056 raw: "!true || false",
1057 },
1058 inner: BinExpr(
1059 BinExpr {
1060 lhs: SpannedExpr {
1061 origin: Origin {
1062 span: Span {
1063 start: 0,
1064 end: 5,
1065 },
1066 raw: "!true",
1067 },
1068 inner: UnExpr {
1069 op: Not,
1070 expr: SpannedExpr {
1071 origin: Origin {
1072 span: Span {
1073 start: 1,
1074 end: 5,
1075 },
1076 raw: "true",
1077 },
1078 inner: Literal(
1079 Boolean(
1080 true,
1081 ),
1082 ),
1083 },
1084 },
1085 },
1086 op: Or,
1087 rhs: SpannedExpr {
1088 origin: Origin {
1089 span: Span {
1090 start: 9,
1091 end: 14,
1092 },
1093 raw: "false",
1094 },
1095 inner: Literal(
1096 Boolean(
1097 false,
1098 ),
1099 ),
1100 },
1101 },
1102 ),
1103 },
1104 op: Or,
1105 rhs: SpannedExpr {
1106 origin: Origin {
1107 span: Span {
1108 start: 18,
1109 end: 22,
1110 },
1111 raw: "true",
1112 },
1113 inner: Literal(
1114 Boolean(
1115 true,
1116 ),
1117 ),
1118 },
1119 },
1120 ),
1121 }
1122 "#);
1123
1124 insta::assert_debug_snapshot!(Expr::parse("'foo '' bar'")?, @r#"
1125 SpannedExpr {
1126 origin: Origin {
1127 span: Span {
1128 start: 0,
1129 end: 12,
1130 },
1131 raw: "'foo '' bar'",
1132 },
1133 inner: Literal(
1134 String(
1135 "foo ' bar",
1136 ),
1137 ),
1138 }
1139 "#);
1140
1141 insta::assert_debug_snapshot!(Expr::parse("('foo '' bar')")?, @r#"
1142 SpannedExpr {
1143 origin: Origin {
1144 span: Span {
1145 start: 0,
1146 end: 14,
1147 },
1148 raw: "('foo '' bar')",
1149 },
1150 inner: Literal(
1151 String(
1152 "foo ' bar",
1153 ),
1154 ),
1155 }
1156 "#);
1157
1158 insta::assert_debug_snapshot!(Expr::parse("((('foo '' bar')))")?, @r#"
1159 SpannedExpr {
1160 origin: Origin {
1161 span: Span {
1162 start: 0,
1163 end: 18,
1164 },
1165 raw: "((('foo '' bar')))",
1166 },
1167 inner: Literal(
1168 String(
1169 "foo ' bar",
1170 ),
1171 ),
1172 }
1173 "#);
1174
1175 insta::assert_debug_snapshot!(Expr::parse("format('{0} {1}', 2, 3)")?, @r#"
1176 SpannedExpr {
1177 origin: Origin {
1178 span: Span {
1179 start: 0,
1180 end: 23,
1181 },
1182 raw: "format('{0} {1}', 2, 3)",
1183 },
1184 inner: Call(
1185 Call {
1186 func: Format,
1187 args: [
1188 SpannedExpr {
1189 origin: Origin {
1190 span: Span {
1191 start: 7,
1192 end: 16,
1193 },
1194 raw: "'{0} {1}'",
1195 },
1196 inner: Literal(
1197 String(
1198 "{0} {1}",
1199 ),
1200 ),
1201 },
1202 SpannedExpr {
1203 origin: Origin {
1204 span: Span {
1205 start: 18,
1206 end: 19,
1207 },
1208 raw: "2",
1209 },
1210 inner: Literal(
1211 Number(
1212 2.0,
1213 ),
1214 ),
1215 },
1216 SpannedExpr {
1217 origin: Origin {
1218 span: Span {
1219 start: 21,
1220 end: 22,
1221 },
1222 raw: "3",
1223 },
1224 inner: Literal(
1225 Number(
1226 3.0,
1227 ),
1228 ),
1229 },
1230 ],
1231 },
1232 ),
1233 }
1234 "#);
1235
1236 insta::assert_debug_snapshot!(Expr::parse("foo.bar.baz")?, @r#"
1237 SpannedExpr {
1238 origin: Origin {
1239 span: Span {
1240 start: 0,
1241 end: 11,
1242 },
1243 raw: "foo.bar.baz",
1244 },
1245 inner: Context(
1246 Context {
1247 parts: [
1248 SpannedExpr {
1249 origin: Origin {
1250 span: Span {
1251 start: 0,
1252 end: 3,
1253 },
1254 raw: "foo",
1255 },
1256 inner: Identifier(
1257 Identifier(
1258 "foo",
1259 ),
1260 ),
1261 },
1262 SpannedExpr {
1263 origin: Origin {
1264 span: Span {
1265 start: 4,
1266 end: 7,
1267 },
1268 raw: "bar",
1269 },
1270 inner: Identifier(
1271 Identifier(
1272 "bar",
1273 ),
1274 ),
1275 },
1276 SpannedExpr {
1277 origin: Origin {
1278 span: Span {
1279 start: 8,
1280 end: 11,
1281 },
1282 raw: "baz",
1283 },
1284 inner: Identifier(
1285 Identifier(
1286 "baz",
1287 ),
1288 ),
1289 },
1290 ],
1291 },
1292 ),
1293 }
1294 "#);
1295
1296 insta::assert_debug_snapshot!(Expr::parse("foo.bar.baz[1][2]"), @r#"
1297 Ok(
1298 SpannedExpr {
1299 origin: Origin {
1300 span: Span {
1301 start: 0,
1302 end: 17,
1303 },
1304 raw: "foo.bar.baz[1][2]",
1305 },
1306 inner: Context(
1307 Context {
1308 parts: [
1309 SpannedExpr {
1310 origin: Origin {
1311 span: Span {
1312 start: 0,
1313 end: 3,
1314 },
1315 raw: "foo",
1316 },
1317 inner: Identifier(
1318 Identifier(
1319 "foo",
1320 ),
1321 ),
1322 },
1323 SpannedExpr {
1324 origin: Origin {
1325 span: Span {
1326 start: 4,
1327 end: 7,
1328 },
1329 raw: "bar",
1330 },
1331 inner: Identifier(
1332 Identifier(
1333 "bar",
1334 ),
1335 ),
1336 },
1337 SpannedExpr {
1338 origin: Origin {
1339 span: Span {
1340 start: 8,
1341 end: 11,
1342 },
1343 raw: "baz",
1344 },
1345 inner: Identifier(
1346 Identifier(
1347 "baz",
1348 ),
1349 ),
1350 },
1351 SpannedExpr {
1352 origin: Origin {
1353 span: Span {
1354 start: 11,
1355 end: 14,
1356 },
1357 raw: "[1]",
1358 },
1359 inner: Index(
1360 SpannedExpr {
1361 origin: Origin {
1362 span: Span {
1363 start: 12,
1364 end: 13,
1365 },
1366 raw: "1",
1367 },
1368 inner: Literal(
1369 Number(
1370 1.0,
1371 ),
1372 ),
1373 },
1374 ),
1375 },
1376 SpannedExpr {
1377 origin: Origin {
1378 span: Span {
1379 start: 14,
1380 end: 17,
1381 },
1382 raw: "[2]",
1383 },
1384 inner: Index(
1385 SpannedExpr {
1386 origin: Origin {
1387 span: Span {
1388 start: 15,
1389 end: 16,
1390 },
1391 raw: "2",
1392 },
1393 inner: Literal(
1394 Number(
1395 2.0,
1396 ),
1397 ),
1398 },
1399 ),
1400 },
1401 ],
1402 },
1403 ),
1404 },
1405 )
1406 "#);
1407
1408 insta::assert_debug_snapshot!(Expr::parse("foo.bar.baz[*]"), @r#"
1409 Ok(
1410 SpannedExpr {
1411 origin: Origin {
1412 span: Span {
1413 start: 0,
1414 end: 14,
1415 },
1416 raw: "foo.bar.baz[*]",
1417 },
1418 inner: Context(
1419 Context {
1420 parts: [
1421 SpannedExpr {
1422 origin: Origin {
1423 span: Span {
1424 start: 0,
1425 end: 3,
1426 },
1427 raw: "foo",
1428 },
1429 inner: Identifier(
1430 Identifier(
1431 "foo",
1432 ),
1433 ),
1434 },
1435 SpannedExpr {
1436 origin: Origin {
1437 span: Span {
1438 start: 4,
1439 end: 7,
1440 },
1441 raw: "bar",
1442 },
1443 inner: Identifier(
1444 Identifier(
1445 "bar",
1446 ),
1447 ),
1448 },
1449 SpannedExpr {
1450 origin: Origin {
1451 span: Span {
1452 start: 8,
1453 end: 11,
1454 },
1455 raw: "baz",
1456 },
1457 inner: Identifier(
1458 Identifier(
1459 "baz",
1460 ),
1461 ),
1462 },
1463 SpannedExpr {
1464 origin: Origin {
1465 span: Span {
1466 start: 11,
1467 end: 14,
1468 },
1469 raw: "[*]",
1470 },
1471 inner: Index(
1472 SpannedExpr {
1473 origin: Origin {
1474 span: Span {
1475 start: 12,
1476 end: 13,
1477 },
1478 raw: "*",
1479 },
1480 inner: Star,
1481 },
1482 ),
1483 },
1484 ],
1485 },
1486 ),
1487 },
1488 )
1489 "#);
1490
1491 insta::assert_debug_snapshot!(Expr::parse("vegetables.*.ediblePortions"), @r#"
1492 Ok(
1493 SpannedExpr {
1494 origin: Origin {
1495 span: Span {
1496 start: 0,
1497 end: 27,
1498 },
1499 raw: "vegetables.*.ediblePortions",
1500 },
1501 inner: Context(
1502 Context {
1503 parts: [
1504 SpannedExpr {
1505 origin: Origin {
1506 span: Span {
1507 start: 0,
1508 end: 10,
1509 },
1510 raw: "vegetables",
1511 },
1512 inner: Identifier(
1513 Identifier(
1514 "vegetables",
1515 ),
1516 ),
1517 },
1518 SpannedExpr {
1519 origin: Origin {
1520 span: Span {
1521 start: 11,
1522 end: 12,
1523 },
1524 raw: "*",
1525 },
1526 inner: Star,
1527 },
1528 SpannedExpr {
1529 origin: Origin {
1530 span: Span {
1531 start: 13,
1532 end: 27,
1533 },
1534 raw: "ediblePortions",
1535 },
1536 inner: Identifier(
1537 Identifier(
1538 "ediblePortions",
1539 ),
1540 ),
1541 },
1542 ],
1543 },
1544 ),
1545 },
1546 )
1547 "#);
1548
1549 insta::assert_debug_snapshot!(Expr::parse("github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'"), @r#"
1550 Ok(
1551 SpannedExpr {
1552 origin: Origin {
1553 span: Span {
1554 start: 0,
1555 end: 88,
1556 },
1557 raw: "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
1558 },
1559 inner: BinExpr(
1560 BinExpr {
1561 lhs: SpannedExpr {
1562 origin: Origin {
1563 span: Span {
1564 start: 0,
1565 end: 58,
1566 },
1567 raw: "github.ref == 'refs/heads/main' && 'value_for_main_branch'",
1568 },
1569 inner: BinExpr(
1570 BinExpr {
1571 lhs: SpannedExpr {
1572 origin: Origin {
1573 span: Span {
1574 start: 0,
1575 end: 31,
1576 },
1577 raw: "github.ref == 'refs/heads/main'",
1578 },
1579 inner: BinExpr(
1580 BinExpr {
1581 lhs: SpannedExpr {
1582 origin: Origin {
1583 span: Span {
1584 start: 0,
1585 end: 10,
1586 },
1587 raw: "github.ref",
1588 },
1589 inner: Context(
1590 Context {
1591 parts: [
1592 SpannedExpr {
1593 origin: Origin {
1594 span: Span {
1595 start: 0,
1596 end: 6,
1597 },
1598 raw: "github",
1599 },
1600 inner: Identifier(
1601 Identifier(
1602 "github",
1603 ),
1604 ),
1605 },
1606 SpannedExpr {
1607 origin: Origin {
1608 span: Span {
1609 start: 7,
1610 end: 10,
1611 },
1612 raw: "ref",
1613 },
1614 inner: Identifier(
1615 Identifier(
1616 "ref",
1617 ),
1618 ),
1619 },
1620 ],
1621 },
1622 ),
1623 },
1624 op: Eq,
1625 rhs: SpannedExpr {
1626 origin: Origin {
1627 span: Span {
1628 start: 14,
1629 end: 31,
1630 },
1631 raw: "'refs/heads/main'",
1632 },
1633 inner: Literal(
1634 String(
1635 "refs/heads/main",
1636 ),
1637 ),
1638 },
1639 },
1640 ),
1641 },
1642 op: And,
1643 rhs: SpannedExpr {
1644 origin: Origin {
1645 span: Span {
1646 start: 35,
1647 end: 58,
1648 },
1649 raw: "'value_for_main_branch'",
1650 },
1651 inner: Literal(
1652 String(
1653 "value_for_main_branch",
1654 ),
1655 ),
1656 },
1657 },
1658 ),
1659 },
1660 op: Or,
1661 rhs: SpannedExpr {
1662 origin: Origin {
1663 span: Span {
1664 start: 62,
1665 end: 88,
1666 },
1667 raw: "'value_for_other_branches'",
1668 },
1669 inner: Literal(
1670 String(
1671 "value_for_other_branches",
1672 ),
1673 ),
1674 },
1675 },
1676 ),
1677 },
1678 )
1679 "#);
1680
1681 insta::assert_debug_snapshot!(Expr::parse("(true || false) == true"), @r#"
1682 Ok(
1683 SpannedExpr {
1684 origin: Origin {
1685 span: Span {
1686 start: 0,
1687 end: 23,
1688 },
1689 raw: "(true || false) == true",
1690 },
1691 inner: BinExpr(
1692 BinExpr {
1693 lhs: SpannedExpr {
1694 origin: Origin {
1695 span: Span {
1696 start: 0,
1697 end: 15,
1698 },
1699 raw: "(true || false)",
1700 },
1701 inner: BinExpr(
1702 BinExpr {
1703 lhs: SpannedExpr {
1704 origin: Origin {
1705 span: Span {
1706 start: 1,
1707 end: 5,
1708 },
1709 raw: "true",
1710 },
1711 inner: Literal(
1712 Boolean(
1713 true,
1714 ),
1715 ),
1716 },
1717 op: Or,
1718 rhs: SpannedExpr {
1719 origin: Origin {
1720 span: Span {
1721 start: 9,
1722 end: 14,
1723 },
1724 raw: "false",
1725 },
1726 inner: Literal(
1727 Boolean(
1728 false,
1729 ),
1730 ),
1731 },
1732 },
1733 ),
1734 },
1735 op: Eq,
1736 rhs: SpannedExpr {
1737 origin: Origin {
1738 span: Span {
1739 start: 19,
1740 end: 23,
1741 },
1742 raw: "true",
1743 },
1744 inner: Literal(
1745 Boolean(
1746 true,
1747 ),
1748 ),
1749 },
1750 },
1751 ),
1752 },
1753 )
1754 "#);
1755
1756 insta::assert_debug_snapshot!(Expr::parse("!(!true || false)"), @r#"
1757 Ok(
1758 SpannedExpr {
1759 origin: Origin {
1760 span: Span {
1761 start: 0,
1762 end: 17,
1763 },
1764 raw: "!(!true || false)",
1765 },
1766 inner: UnExpr {
1767 op: Not,
1768 expr: SpannedExpr {
1769 origin: Origin {
1770 span: Span {
1771 start: 1,
1772 end: 17,
1773 },
1774 raw: "(!true || false)",
1775 },
1776 inner: BinExpr(
1777 BinExpr {
1778 lhs: SpannedExpr {
1779 origin: Origin {
1780 span: Span {
1781 start: 2,
1782 end: 7,
1783 },
1784 raw: "!true",
1785 },
1786 inner: UnExpr {
1787 op: Not,
1788 expr: SpannedExpr {
1789 origin: Origin {
1790 span: Span {
1791 start: 3,
1792 end: 7,
1793 },
1794 raw: "true",
1795 },
1796 inner: Literal(
1797 Boolean(
1798 true,
1799 ),
1800 ),
1801 },
1802 },
1803 },
1804 op: Or,
1805 rhs: SpannedExpr {
1806 origin: Origin {
1807 span: Span {
1808 start: 11,
1809 end: 16,
1810 },
1811 raw: "false",
1812 },
1813 inner: Literal(
1814 Boolean(
1815 false,
1816 ),
1817 ),
1818 },
1819 },
1820 ),
1821 },
1822 },
1823 },
1824 )
1825 "#);
1826
1827 insta::assert_debug_snapshot!(Expr::parse("foobar[format('{0}', 'event')]"), @r#"
1828 Ok(
1829 SpannedExpr {
1830 origin: Origin {
1831 span: Span {
1832 start: 0,
1833 end: 30,
1834 },
1835 raw: "foobar[format('{0}', 'event')]",
1836 },
1837 inner: Context(
1838 Context {
1839 parts: [
1840 SpannedExpr {
1841 origin: Origin {
1842 span: Span {
1843 start: 0,
1844 end: 6,
1845 },
1846 raw: "foobar",
1847 },
1848 inner: Identifier(
1849 Identifier(
1850 "foobar",
1851 ),
1852 ),
1853 },
1854 SpannedExpr {
1855 origin: Origin {
1856 span: Span {
1857 start: 6,
1858 end: 30,
1859 },
1860 raw: "[format('{0}', 'event')]",
1861 },
1862 inner: Index(
1863 SpannedExpr {
1864 origin: Origin {
1865 span: Span {
1866 start: 7,
1867 end: 29,
1868 },
1869 raw: "format('{0}', 'event')",
1870 },
1871 inner: Call(
1872 Call {
1873 func: Format,
1874 args: [
1875 SpannedExpr {
1876 origin: Origin {
1877 span: Span {
1878 start: 14,
1879 end: 19,
1880 },
1881 raw: "'{0}'",
1882 },
1883 inner: Literal(
1884 String(
1885 "{0}",
1886 ),
1887 ),
1888 },
1889 SpannedExpr {
1890 origin: Origin {
1891 span: Span {
1892 start: 21,
1893 end: 28,
1894 },
1895 raw: "'event'",
1896 },
1897 inner: Literal(
1898 String(
1899 "event",
1900 ),
1901 ),
1902 },
1903 ],
1904 },
1905 ),
1906 },
1907 ),
1908 },
1909 ],
1910 },
1911 ),
1912 },
1913 )
1914 "#);
1915
1916 insta::assert_debug_snapshot!(Expr::parse("github.actor_id == '49699333'"), @r#"
1917 Ok(
1918 SpannedExpr {
1919 origin: Origin {
1920 span: Span {
1921 start: 0,
1922 end: 29,
1923 },
1924 raw: "github.actor_id == '49699333'",
1925 },
1926 inner: BinExpr(
1927 BinExpr {
1928 lhs: SpannedExpr {
1929 origin: Origin {
1930 span: Span {
1931 start: 0,
1932 end: 15,
1933 },
1934 raw: "github.actor_id",
1935 },
1936 inner: Context(
1937 Context {
1938 parts: [
1939 SpannedExpr {
1940 origin: Origin {
1941 span: Span {
1942 start: 0,
1943 end: 6,
1944 },
1945 raw: "github",
1946 },
1947 inner: Identifier(
1948 Identifier(
1949 "github",
1950 ),
1951 ),
1952 },
1953 SpannedExpr {
1954 origin: Origin {
1955 span: Span {
1956 start: 7,
1957 end: 15,
1958 },
1959 raw: "actor_id",
1960 },
1961 inner: Identifier(
1962 Identifier(
1963 "actor_id",
1964 ),
1965 ),
1966 },
1967 ],
1968 },
1969 ),
1970 },
1971 op: Eq,
1972 rhs: SpannedExpr {
1973 origin: Origin {
1974 span: Span {
1975 start: 19,
1976 end: 29,
1977 },
1978 raw: "'49699333'",
1979 },
1980 inner: Literal(
1981 String(
1982 "49699333",
1983 ),
1984 ),
1985 },
1986 },
1987 ),
1988 },
1989 )
1990 "#);
1991
1992 insta::assert_debug_snapshot!(Expr::parse("(fromJSON('[]'))[1]"), @r#"
1993 Ok(
1994 SpannedExpr {
1995 origin: Origin {
1996 span: Span {
1997 start: 0,
1998 end: 19,
1999 },
2000 raw: "(fromJSON('[]'))[1]",
2001 },
2002 inner: Context(
2003 Context {
2004 parts: [
2005 SpannedExpr {
2006 origin: Origin {
2007 span: Span {
2008 start: 0,
2009 end: 16,
2010 },
2011 raw: "(fromJSON('[]'))",
2012 },
2013 inner: Call(
2014 Call {
2015 func: FromJSON,
2016 args: [
2017 SpannedExpr {
2018 origin: Origin {
2019 span: Span {
2020 start: 10,
2021 end: 14,
2022 },
2023 raw: "'[]'",
2024 },
2025 inner: Literal(
2026 String(
2027 "[]",
2028 ),
2029 ),
2030 },
2031 ],
2032 },
2033 ),
2034 },
2035 SpannedExpr {
2036 origin: Origin {
2037 span: Span {
2038 start: 16,
2039 end: 19,
2040 },
2041 raw: "[1]",
2042 },
2043 inner: Index(
2044 SpannedExpr {
2045 origin: Origin {
2046 span: Span {
2047 start: 17,
2048 end: 18,
2049 },
2050 raw: "1",
2051 },
2052 inner: Literal(
2053 Number(
2054 1.0,
2055 ),
2056 ),
2057 },
2058 ),
2059 },
2060 ],
2061 },
2062 ),
2063 },
2064 )
2065 "#);
2066
2067 Ok(())
2068 }
2069
2070 #[test]
2071 fn test_expr_constant_reducible() -> Result<(), Error> {
2072 for (expr, reducible) in &[
2073 ("'foo'", true),
2074 ("1", true),
2075 ("true", true),
2076 ("null", true),
2077 // boolean and unary expressions of all literals are
2078 // always reducible.
2079 ("!true", true),
2080 ("!null", true),
2081 ("true && false", true),
2082 ("true || false", true),
2083 ("null && !null && true", true),
2084 // formats/contains/startsWith/endsWith are reducible
2085 // if all of their arguments are reducible.
2086 ("format('{0} {1}', 'foo', 'bar')", true),
2087 ("format('{0} {1}', 1, 2)", true),
2088 ("format('{0} {1}', 1, '2')", true),
2089 ("contains('foo', 'bar')", true),
2090 ("startsWith('foo', 'bar')", true),
2091 ("endsWith('foo', 'bar')", true),
2092 ("startsWith(some.context, 'bar')", false),
2093 ("endsWith(some.context, 'bar')", false),
2094 // Nesting works as long as the nested call is also reducible.
2095 ("format('{0} {1}', '1', format('{0}', null))", true),
2096 ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
2097 ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
2098 ("foo", false),
2099 ("foo.bar", false),
2100 ("foo.bar[1]", false),
2101 ("foo.bar == 'bar'", false),
2102 ("foo.bar || bar || baz", false),
2103 ("foo.bar && bar && baz", false),
2104 ] {
2105 let expr = Expr::parse(expr)?;
2106 assert_eq!(expr.constant_reducible(), *reducible);
2107 }
2108
2109 Ok(())
2110 }
2111
2112 #[test]
2113 fn test_evaluate_constant_complex_expressions() -> Result<(), Error> {
2114 use crate::Evaluation;
2115
2116 let test_cases = &[
2117 // Nested operations
2118 ("!false", Evaluation::Boolean(true)),
2119 ("!true", Evaluation::Boolean(false)),
2120 ("!(true && false)", Evaluation::Boolean(true)),
2121 // Complex boolean logic
2122 ("true && (false || true)", Evaluation::Boolean(true)),
2123 ("false || (true && false)", Evaluation::Boolean(false)),
2124 // Mixed function calls
2125 (
2126 "contains(format('{0} {1}', 'hello', 'world'), 'world')",
2127 Evaluation::Boolean(true),
2128 ),
2129 (
2130 "startsWith(format('prefix_{0}', 'test'), 'prefix')",
2131 Evaluation::Boolean(true),
2132 ),
2133 ];
2134
2135 for (expr_str, expected) in test_cases {
2136 let expr = Expr::parse(expr_str)?;
2137 let result = expr.consteval().unwrap();
2138 assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
2139 }
2140
2141 Ok(())
2142 }
2143
2144 #[test]
2145 fn test_case_insensitive_string_comparison() -> Result<(), Error> {
2146 use crate::Evaluation;
2147
2148 let test_cases = &[
2149 // == is case-insensitive for strings
2150 ("'hello' == 'hello'", Evaluation::Boolean(true)),
2151 ("'hello' == 'HELLO'", Evaluation::Boolean(true)),
2152 ("'Hello' == 'hELLO'", Evaluation::Boolean(true)),
2153 ("'abc' == 'def'", Evaluation::Boolean(false)),
2154 // != is case-insensitive for strings
2155 ("'hello' != 'HELLO'", Evaluation::Boolean(false)),
2156 ("'abc' != 'def'", Evaluation::Boolean(true)),
2157 // Comparison operators are case-insensitive for strings
2158 ("'abc' < 'DEF'", Evaluation::Boolean(true)),
2159 ("'ABC' < 'def'", Evaluation::Boolean(true)),
2160 ("'abc' >= 'ABC'", Evaluation::Boolean(true)),
2161 ("'ABC' <= 'abc'", Evaluation::Boolean(true)),
2162 // Greek sigma: ς (final) and σ (non-final) both uppercase to Σ.
2163 // This is why we use to_uppercase() instead of to_lowercase().
2164 ("'\u{03C3}' == '\u{03C2}'", Evaluation::Boolean(true)), // σ == ς
2165 ("'\u{03A3}' == '\u{03C3}'", Evaluation::Boolean(true)), // Σ == σ
2166 ("'\u{03A3}' == '\u{03C2}'", Evaluation::Boolean(true)), // Σ == ς
2167 // Array contains with case-insensitive string matching
2168 (
2169 "contains(fromJSON('[\"Hello\", \"World\"]'), 'hello')",
2170 Evaluation::Boolean(true),
2171 ),
2172 (
2173 "contains(fromJSON('[\"hello\", \"world\"]'), 'WORLD')",
2174 Evaluation::Boolean(true),
2175 ),
2176 (
2177 "contains(fromJSON('[\"ABC\"]'), 'abc')",
2178 Evaluation::Boolean(true),
2179 ),
2180 (
2181 "contains(fromJSON('[\"abc\"]'), 'def')",
2182 Evaluation::Boolean(false),
2183 ),
2184 ];
2185
2186 for (expr_str, expected) in test_cases {
2187 let expr = Expr::parse(expr_str)?;
2188 let result = expr.consteval().unwrap();
2189 assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
2190 }
2191
2192 Ok(())
2193 }
2194
2195 #[test]
2196 fn test_evaluation_sema_display() {
2197 use crate::Evaluation;
2198
2199 let test_cases = &[
2200 (Evaluation::String("hello".to_string()), "hello"),
2201 (Evaluation::Number(42.0), "42"),
2202 (Evaluation::Number(3.14), "3.14"),
2203 (Evaluation::Boolean(true), "true"),
2204 (Evaluation::Boolean(false), "false"),
2205 (Evaluation::Null, ""),
2206 ];
2207
2208 for (result, expected) in test_cases {
2209 assert_eq!(result.sema().to_string(), *expected);
2210 }
2211 }
2212
2213 #[test]
2214 fn test_evaluation_result_to_boolean() {
2215 use crate::Evaluation;
2216
2217 let test_cases = &[
2218 (Evaluation::Boolean(true), true),
2219 (Evaluation::Boolean(false), false),
2220 (Evaluation::Null, false),
2221 (Evaluation::Number(0.0), false),
2222 (Evaluation::Number(1.0), true),
2223 (Evaluation::Number(-1.0), true),
2224 (Evaluation::Number(f64::NAN), false), // NaN is falsy in GitHub Actions
2225 (Evaluation::String("".to_string()), false),
2226 (Evaluation::String("hello".to_string()), true),
2227 (Evaluation::Array(vec![]), true), // Arrays are always truthy
2228 (Evaluation::Object(std::collections::HashMap::new()), true), // Dictionaries are always truthy
2229 ];
2230
2231 for (result, expected) in test_cases {
2232 assert_eq!(result.as_boolean(), *expected);
2233 }
2234 }
2235
2236 #[test]
2237 fn test_evaluation_result_to_number() {
2238 use crate::Evaluation;
2239
2240 // Non-string types
2241 let test_cases = &[
2242 (Evaluation::Number(42.0), 42.0),
2243 (Evaluation::Number(0.0), 0.0),
2244 (Evaluation::Boolean(true), 1.0),
2245 (Evaluation::Boolean(false), 0.0),
2246 (Evaluation::Null, 0.0),
2247 ];
2248
2249 for (eval, expected) in test_cases {
2250 assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", eval);
2251 }
2252
2253 let string_cases: &[(&str, f64)] = &[
2254 // Empty / whitespace-only
2255 ("", 0.0),
2256 (" ", 0.0),
2257 ("\t", 0.0),
2258 // Whitespace trimming
2259 (" 123 ", 123.0),
2260 (" 42 ", 42.0),
2261 (" 1 ", 1.0),
2262 ("\t5\n", 5.0),
2263 (" \t123\t ", 123.0),
2264 // Basic decimal
2265 ("42", 42.0),
2266 ("3.14", 3.14),
2267 // Hex
2268 ("0xff", 255.0),
2269 ("0xfF", 255.0),
2270 ("0xFF", 255.0),
2271 (" 0xff ", 255.0),
2272 ("0x0", 0.0),
2273 ("0x11", 17.0),
2274 // Hex: signed 32-bit two's complement wrapping
2275 ("0x7FFFFFFF", 2147483647.0),
2276 ("0x80000000", -2147483648.0),
2277 ("0xFFFFFFFF", -1.0),
2278 // Octal
2279 ("0o10", 8.0),
2280 (" 0o10 ", 8.0),
2281 ("0o0", 0.0),
2282 ("0o11", 9.0),
2283 // Octal: signed 32-bit two's complement wrapping
2284 ("0o17777777777", 2147483647.0),
2285 ("0o20000000000", -2147483648.0),
2286 // Scientific notation
2287 ("1.2e2", 120.0),
2288 ("1.2E2", 120.0),
2289 ("1.2e-2", 0.012),
2290 (" 1.2e2 ", 120.0),
2291 ("1.2e+2", 120.0),
2292 ("5e0", 5.0),
2293 ("1e3", 1000.0),
2294 ("123e-1", 12.3),
2295 (" +1.2e2 ", 120.0),
2296 (" -1.2E+2 ", -120.0),
2297 // Signs
2298 ("+42", 42.0),
2299 (" -42 ", -42.0),
2300 (" 3.14 ", 3.14),
2301 ("+0", 0.0),
2302 ("-0", 0.0),
2303 (" +123456.789 ", 123456.789),
2304 (" -123456.789 ", -123456.789),
2305 // Leading zeros -> decimal
2306 ("0123", 123.0),
2307 ("00", 0.0),
2308 ("007", 7.0),
2309 ("010", 10.0),
2310 // Trailing/leading dot
2311 ("123.", 123.0),
2312 (".5", 0.5),
2313 ];
2314
2315 for (input, expected) in string_cases {
2316 let eval = Evaluation::String(input.to_string());
2317 assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", input);
2318 }
2319
2320 // Infinity cases
2321 let infinity_cases: &[(&str, f64)] = &[
2322 ("Infinity", f64::INFINITY),
2323 (" Infinity ", f64::INFINITY),
2324 ("+Infinity", f64::INFINITY),
2325 ("-Infinity", f64::NEG_INFINITY),
2326 (" -Infinity ", f64::NEG_INFINITY),
2327 ];
2328
2329 for (input, expected) in infinity_cases {
2330 let eval = Evaluation::String(input.to_string());
2331 assert_eq!(eval.as_number(), *expected, "as_number() for {:?}", input);
2332 }
2333
2334 // NaN cases: all verified against GitHub Actions CI.
2335 let nan_cases: &[&str] = &[
2336 // Invalid strings
2337 "hello",
2338 "abc",
2339 " abc ",
2340 " NaN ",
2341 // Partial/malformed numerics
2342 "123abc",
2343 "abc123",
2344 "100a",
2345 "12.3.4",
2346 "1e2e3",
2347 "1 2",
2348 "1_000",
2349 "+",
2350 "-",
2351 ".",
2352 // Binary notation
2353 "0b1010",
2354 "0B1010",
2355 "0b0",
2356 "0b1",
2357 "0b11",
2358 " 0b11 ",
2359 // Uppercase prefixes are NOT supported
2360 "0XFF",
2361 "0O10",
2362 // Signed prefixed numbers are NOT supported
2363 "-0xff",
2364 "+0xff",
2365 "-0o10",
2366 "+0o10",
2367 "-0b11",
2368 // Empty prefixes (no digits after prefix)
2369 "0x",
2370 "0o",
2371 "0b",
2372 // Invalid digits for the base
2373 "0xZZ",
2374 "0o89",
2375 "0b23",
2376 // Hex/octal values exceeding 32-bit
2377 "0x100000000",
2378 "0o40000000000",
2379 // "inf" abbreviation rejected by GH runner
2380 "inf",
2381 "Inf",
2382 "INF",
2383 "+inf",
2384 "-inf",
2385 " inf ",
2386 ];
2387
2388 for input in nan_cases {
2389 let eval = Evaluation::String(input.to_string());
2390 assert!(
2391 eval.as_number().is_nan(),
2392 "as_number() for {:?} should be NaN",
2393 input
2394 );
2395 }
2396 }
2397
2398 #[test]
2399 fn test_github_actions_logical_semantics() -> Result<(), Error> {
2400 use crate::Evaluation;
2401
2402 // Test GitHub Actions-specific && and || semantics
2403 let test_cases = &[
2404 // && returns the first falsy value, or the last value if all are truthy
2405 ("false && 'hello'", Evaluation::Boolean(false)),
2406 ("null && 'hello'", Evaluation::Null),
2407 ("'' && 'hello'", Evaluation::String("".to_string())),
2408 (
2409 "'hello' && 'world'",
2410 Evaluation::String("world".to_string()),
2411 ),
2412 ("true && 42", Evaluation::Number(42.0)),
2413 // || returns the first truthy value, or the last value if all are falsy
2414 ("true || 'hello'", Evaluation::Boolean(true)),
2415 (
2416 "'hello' || 'world'",
2417 Evaluation::String("hello".to_string()),
2418 ),
2419 ("false || 'hello'", Evaluation::String("hello".to_string())),
2420 ("null || false", Evaluation::Boolean(false)),
2421 ("'' || null", Evaluation::Null),
2422 ("!NaN", Evaluation::Boolean(true)),
2423 ("!!NaN", Evaluation::Boolean(false)),
2424 ];
2425
2426 for (expr_str, expected) in test_cases {
2427 let expr = Expr::parse(expr_str)?;
2428 let result = expr.consteval().unwrap();
2429 assert_eq!(result, *expected, "Failed for expression: {}", expr_str);
2430 }
2431
2432 Ok(())
2433 }
2434
2435 #[test]
2436 fn test_expr_has_constant_reducible_subexpr() -> Result<(), Error> {
2437 for (expr, reducible) in &[
2438 // Literals are not considered reducible subexpressions.
2439 ("'foo'", false),
2440 ("1", false),
2441 ("true", false),
2442 ("null", false),
2443 // Non-reducible expressions with reducible subexpressions
2444 (
2445 "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
2446 true,
2447 ),
2448 ("foobar[format('{0}', 'event')]", true),
2449 ] {
2450 let expr = Expr::parse(expr)?;
2451 assert_eq!(!expr.constant_reducible_subexprs().is_empty(), *reducible);
2452 }
2453 Ok(())
2454 }
2455
2456 #[test]
2457 fn test_expr_contexts() -> Result<(), Error> {
2458 // A single context.
2459 let expr = Expr::parse("foo.bar.baz[1].qux")?;
2460 assert_eq!(
2461 expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
2462 ["foo.bar.baz[1].qux",]
2463 );
2464
2465 // Multiple contexts.
2466 let expr = Expr::parse("foo.bar[1].baz || abc.def")?;
2467 assert_eq!(
2468 expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
2469 ["foo.bar[1].baz", "abc.def",]
2470 );
2471
2472 // Two contexts, one as part of a computed index.
2473 let expr = Expr::parse("foo.bar[abc.def]")?;
2474 assert_eq!(
2475 expr.contexts().iter().map(|t| t.1.raw).collect::<Vec<_>>(),
2476 ["foo.bar[abc.def]", "abc.def",]
2477 );
2478
2479 Ok(())
2480 }
2481
2482 #[test]
2483 fn test_expr_dataflow_contexts() -> Result<(), Error> {
2484 // Trivial cases.
2485 let expr = Expr::parse("foo.bar")?;
2486 assert_eq!(
2487 expr.dataflow_contexts()
2488 .iter()
2489 .map(|t| t.1.raw)
2490 .collect::<Vec<_>>(),
2491 ["foo.bar"]
2492 );
2493
2494 let expr = Expr::parse("foo.bar[1]")?;
2495 assert_eq!(
2496 expr.dataflow_contexts()
2497 .iter()
2498 .map(|t| t.1.raw)
2499 .collect::<Vec<_>>(),
2500 ["foo.bar[1]"]
2501 );
2502
2503 // No dataflow due to a boolean expression.
2504 let expr = Expr::parse("foo.bar == 'bar'")?;
2505 assert!(expr.dataflow_contexts().is_empty());
2506
2507 // ||: all contexts potentially expand into the evaluation.
2508 let expr = Expr::parse("foo.bar || abc || d.e.f")?;
2509 assert_eq!(
2510 expr.dataflow_contexts()
2511 .iter()
2512 .map(|t| t.1.raw)
2513 .collect::<Vec<_>>(),
2514 ["foo.bar", "abc", "d.e.f"]
2515 );
2516
2517 // &&: only the RHS context(s) expand into the evaluation.
2518 let expr = Expr::parse("foo.bar && abc && d.e.f")?;
2519 assert_eq!(
2520 expr.dataflow_contexts()
2521 .iter()
2522 .map(|t| t.1.raw)
2523 .collect::<Vec<_>>(),
2524 ["d.e.f"]
2525 );
2526
2527 let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
2528 assert_eq!(
2529 expr.dataflow_contexts()
2530 .iter()
2531 .map(|t| t.1.raw)
2532 .collect::<Vec<_>>(),
2533 ["foo.bar"]
2534 );
2535
2536 let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
2537 assert_eq!(
2538 expr.dataflow_contexts()
2539 .iter()
2540 .map(|t| t.1.raw)
2541 .collect::<Vec<_>>(),
2542 ["foo.bar", "foo.baz"]
2543 );
2544
2545 let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
2546 assert_eq!(
2547 expr.dataflow_contexts()
2548 .iter()
2549 .map(|t| t.1.raw)
2550 .collect::<Vec<_>>(),
2551 ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
2552 );
2553
2554 let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
2555 assert_eq!(
2556 expr.dataflow_contexts()
2557 .iter()
2558 .map(|t| t.1.raw)
2559 .collect::<Vec<_>>(),
2560 ["foo.bar", "github", "github"]
2561 );
2562
2563 Ok(())
2564 }
2565
2566 #[test]
2567 fn test_spannedexpr_computed_indices() -> Result<(), Error> {
2568 for (expr, computed_indices) in &[
2569 ("foo.bar", vec![]),
2570 ("foo.bar[1]", vec![]),
2571 ("foo.bar[*]", vec![]),
2572 ("foo.bar[abc]", vec!["[abc]"]),
2573 (
2574 "foo.bar[format('{0}', 'foo')]",
2575 vec!["[format('{0}', 'foo')]"],
2576 ),
2577 ("foo.bar[abc].def[efg]", vec!["[abc]", "[efg]"]),
2578 ] {
2579 let expr = Expr::parse(expr)?;
2580
2581 assert_eq!(
2582 expr.computed_indices()
2583 .iter()
2584 .map(|e| e.origin.raw)
2585 .collect::<Vec<_>>(),
2586 *computed_indices
2587 );
2588 }
2589
2590 Ok(())
2591 }
2592
2593 #[test]
2594 fn test_fragment_from_expr() {
2595 for (expr, expected) in &[
2596 ("foo==bar", "foo==bar"),
2597 ("foo == bar", r"foo\s+==\s+bar"),
2598 ("foo == bar", r"foo\s+==\s+bar"),
2599 ("fromJSON('{}')", "fromJSON('{}')"),
2600 ("fromJSON('{ }')", r"fromJSON\('\{\s+\}'\)"),
2601 ("fromJSON ('{ }')", r"fromJSON\s+\('\{\s+\}'\)"),
2602 ("a . b . c . d", r"a\s+\.\s+b\s+\.\s+c\s+\.\s+d"),
2603 ("true \n && \n false", r"true\s+\&\&\s+false"),
2604 ] {
2605 let expr = Expr::parse(expr).unwrap();
2606 match subfeature::Fragment::from(&expr) {
2607 subfeature::Fragment::Raw(actual) => assert_eq!(actual, *expected),
2608 subfeature::Fragment::Regex(actual) => assert_eq!(actual.as_str(), *expected),
2609 };
2610 }
2611 }
2612
2613 #[test]
2614 fn test_leaf_expressions() -> Result<(), Error> {
2615 // A single literal is its own leaf.
2616 let expr = Expr::parse("'hello'")?;
2617 let leaves = expr.leaf_expressions();
2618 assert_eq!(leaves.len(), 1);
2619 assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "hello"));
2620
2621 // A single context is its own leaf.
2622 let expr = Expr::parse("foo.bar")?;
2623 let leaves = expr.leaf_expressions();
2624 assert_eq!(leaves.len(), 1);
2625 assert!(matches!(&leaves[0].inner, Expr::Context(_)));
2626
2627 // `A || B` returns both sides.
2628 let expr = Expr::parse("foo.abc || foo.def")?;
2629 let leaves = expr.leaf_expressions();
2630 assert_eq!(leaves.len(), 2);
2631 assert!(matches!(&leaves[0].inner, Expr::Context(_)));
2632 assert!(matches!(&leaves[1].inner, Expr::Context(_)));
2633
2634 // `A && B` returns only B.
2635 let expr = Expr::parse("foo.bar && 'hello'")?;
2636 let leaves = expr.leaf_expressions();
2637 assert_eq!(leaves.len(), 1);
2638 assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "hello"));
2639
2640 // Conditional pattern: `cond && 'value' || 'fallback'`
2641 let expr = Expr::parse("foo.bar == 'true' && 'redis:7' || ''")?;
2642 let leaves = expr.leaf_expressions();
2643 assert_eq!(leaves.len(), 2);
2644 assert!(matches!(&leaves[0].inner, Expr::Literal(Literal::String(s)) if s == "redis:7"));
2645 assert!(matches!(&leaves[1].inner, Expr::Literal(Literal::String(s)) if s == ""));
2646
2647 // Comparison operators are leaves themselves (they produce booleans).
2648 let expr = Expr::parse("foo.bar == 'abc'")?;
2649 let leaves = expr.leaf_expressions();
2650 assert_eq!(leaves.len(), 1);
2651 assert!(matches!(&leaves[0].inner, Expr::BinExpr { .. }));
2652
2653 Ok(())
2654 }
2655
2656 #[test]
2657 fn test_upper_special() {
2658 use super::EvaluationSema;
2659
2660 let cases = &[
2661 ("", ""),
2662 ("abc", "ABC"),
2663 ("ıabc", "ıABC"),
2664 ("ııabc", "ııABC"),
2665 ("abcı", "ABCı"),
2666 ("abcıı", "ABCıı"),
2667 ("abcıdef", "ABCıDEF"),
2668 ("abcııdef", "ABCııDEF"),
2669 ("abcıdefıghi", "ABCıDEFıGHI"),
2670 ];
2671
2672 for (input, want) in cases {
2673 assert_eq!(
2674 EvaluationSema::upper_special(input),
2675 *want,
2676 "input: {input}"
2677 );
2678 }
2679 }
2680
2681 #[test]
2682 fn test_expr_commutative_matches() -> Result<(), Error> {
2683 let cases = &[
2684 // Identical expressions always match.
2685 ("a == b", "a == b", true),
2686 // Commutative operators match when swapped.
2687 ("a == b", "b == a", true),
2688 ("a != b", "b != a", true),
2689 ("a && b", "b && a", true),
2690 ("a || b", "b || a", true),
2691 // Non-commutative operators don't match when swapped.
2692 ("a > b", "b > a", false),
2693 ("a >= b", "b >= a", false),
2694 ("a < b", "b < a", false),
2695 ("a <= b", "b <= a", false),
2696 // Non-commutative operators still match positionally.
2697 ("a > b", "a > b", true),
2698 // Different operators never match.
2699 ("a == b", "a != b", false),
2700 ("a > b", "a < b", false),
2701 // Recursive commutative matching through nested commutative ops.
2702 ("(a == b) && (c == d)", "(d == c) && (b == a)", true),
2703 ("(a == b) && (c == d)", "(c == d) && (a == b)", true),
2704 // Recursion descends into non-commutative ops positionally.
2705 ("(a == b) > (c == d)", "(b == a) > (d == c)", true),
2706 ("(a == b) > (c == d)", "(c == d) > (a == b)", false),
2707 // Non-binexpr fall-through uses equality.
2708 ("'foo'", "'foo'", true),
2709 ("'foo'", "'bar'", false),
2710 // Mixed binexpr vs non-binexpr never matches.
2711 ("a == b", "'foo'", false),
2712 ];
2713
2714 for (lhs, rhs, expected) in cases {
2715 let lhs_expr = Expr::parse(lhs)?;
2716 let rhs_expr = Expr::parse(rhs)?;
2717 assert_eq!(
2718 lhs_expr.inner.commutative_matches(&rhs_expr.inner),
2719 *expected,
2720 "{lhs} <=> {rhs}",
2721 );
2722 }
2723
2724 Ok(())
2725 }
2726}