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