pr_01m47d15m3e54sn21z27rpy5n9/crates/actions/src/expr.rs

1,756 lines60,526 bytesCodeBlame
1//! The GitHub Actions expression language: the `${{ }}` language.
2//!
3//! This follows GitHub's "Evaluate expressions in workflows and actions"
4//! precisely, so a real GitHub workflow evaluates here the way it
5//! does on GitHub: the same literals, the same operator precedence, the same
6//! loose equality (with its coercions to number), the same case-insensitive
7//! string handling, the same object filters (`labels.*.name`) and the same
8//! functions. Parse errors are found before anything is evaluated, so an
9//! unknown context or function is an error even in a branch that would never
10//! run, as on GitHub.
11
12use serde_json::{Map, Value};
13use std::borrow::Cow;
14use std::cmp::Ordering;
15
16/// How the job is going, for success(), failure(), cancelled(), always().
17#[derive(Clone, Copy, Debug, PartialEq, Eq)]
18pub enum Status {
19 Success,
20 Failure,
21 Cancelled,
22}
23
24pub struct Scope<'a> {
25 /// Top-level contexts by lower-case name: github, env, vars, secrets, inputs, matrix, strategy, needs, steps, job, jobs, runner.
26 pub contexts: &'a Map<String, Value>,
27 pub status: Status,
28 /// hashFiles(...) when the caller can compute it (the sandbox); None means hashFiles evaluates to "".
29 #[allow(clippy::type_complexity)]
30 pub hash_files: Option<&'a dyn Fn(&[String]) -> String>,
31}
32
33/// The contexts a workflow may name, whether or not the caller supplied them.
34const NAMED_VALUES: &[&str] = &[
35 "github", "env", "vars", "secrets", "inputs", "matrix", "strategy", "needs", "steps", "job",
36 "jobs", "runner",
37];
38
39/// Evaluates one expression (the text between `${{` and `}}`, or a bare `if:`).
40pub fn evaluate(expression: &str, scope: &Scope) -> Result<Value, String> {
41 let ast = parse(expression, scope.contexts)?;
42 Ok(eval(&ast, scope)?.into_value())
43}
44
45/// An `if:` value: with or without `${{ }}` around it; when the expression calls none of success/failure/cancelled/always, it is implicitly `success() && (expr)`. An empty condition is `success()`.
46pub fn condition(text: &str, scope: &Scope) -> Result<bool, String> {
47 let success = scope.status == Status::Success;
48 let trimmed = text.trim();
49 let source = match single_expression(trimmed) {
50 Some(inner) => inner,
51 // Text around or between expressions makes the whole thing a string,
52 // as on GitHub: it is true whenever it interpolates to anything.
53 None if has_expression(trimmed) => {
54 let text = interpolate(trimmed, scope)?;
55 return Ok(success && !text.is_empty());
56 }
57 None => trimmed,
58 };
59 if source.trim().is_empty() {
60 return Ok(success);
61 }
62 let ast = parse(source, scope.contexts)?;
63 if uses_status(&ast) {
64 Ok(eval(&ast, scope)?.truthy())
65 } else {
66 Ok(success && eval(&ast, scope)?.truthy())
67 }
68}
69
70/// Replaces each `${{ expr }}` in text with the value converted to a string. Text without `${{` is returned unchanged.
71pub fn interpolate(text: &str, scope: &Scope) -> Result<String, String> {
72 if !has_expression(text) {
73 return Ok(text.to_string());
74 }
75 let mut out = String::with_capacity(text.len());
76 let mut from = 0;
77 while let Some(rel) = text[from..].find("${{") {
78 let open = from + rel;
79 out.push_str(&text[from..open]);
80 let body = open + 3;
81 let close = find_close(text, body).ok_or_else(|| {
82 format!(
83 "The expression is not closed. An unescaped ${{{{ sequence was found, but the closing }}}} sequence was not found: {text}"
84 )
85 })?;
86 let value = evaluate(&text[body..close], scope)?;
87 out.push_str(&to_text(&value));
88 from = close + 2;
89 }
90 out.push_str(&text[from..]);
91 Ok(out)
92}
93
94/// Interpolates every string inside a JSON value (keys too). If a string is exactly one `${{ expr }}` and nothing else, the result keeps the expression's type (as GitHub does for e.g. `strategy.matrix: ${{ fromJSON(...) }}`, `continue-on-error: ${{ ... }}`).
95pub fn interpolate_value(value: &Value, scope: &Scope) -> Result<Value, String> {
96 Ok(match value {
97 Value::String(text) => match single_expression(text) {
98 Some(inner) => evaluate(inner, scope)?,
99 None => Value::String(interpolate(text, scope)?),
100 },
101 Value::Array(items) => Value::Array(
102 items
103 .iter()
104 .map(|item| interpolate_value(item, scope))
105 .collect::<Result<_, _>>()?,
106 ),
107 Value::Object(map) => {
108 let mut out = Map::new();
109 for (key, item) in map {
110 out.insert(interpolate(key, scope)?, interpolate_value(item, scope)?);
111 }
112 Value::Object(out)
113 }
114 other => other.clone(),
115 })
116}
117
118/// false, 0, -0, NaN, "" and null are falsy; everything else is truthy.
119pub fn truthy(value: &Value) -> bool {
120 match value {
121 Value::Null => false,
122 Value::Bool(b) => *b,
123 Value::Number(n) => n.as_f64().is_some_and(|f| f != 0.0 && !f.is_nan()),
124 Value::String(s) => !s.is_empty(),
125 Value::Array(_) | Value::Object(_) => true,
126 }
127}
128
129/// A value as GitHub writes it into a string: null → "", bools "true"/"false", numbers as GitHub formats them (integers without ".0"), strings as-is, and `Array` or `Object` for collections, as GitHub's runner does (`toJSON` gives their contents).
130pub fn to_text(value: &Value) -> String {
131 match value {
132 Value::Null => String::new(),
133 Value::Bool(b) => b.to_string(),
134 Value::Number(n) => format_number(n.as_f64().unwrap_or(f64::NAN)),
135 Value::String(s) => s.clone(),
136 Value::Array(_) => "Array".to_owned(),
137 Value::Object(_) => "Object".to_owned(),
138 }
139}
140
141/// Whether text contains `${{`.
142pub fn has_expression(text: &str) -> bool {
143 text.contains("${{")
144}
145
146// ---------------------------------------------------------------------------
147// Template scanning
148
149/// Finds the `}}` closing an expression whose body starts at byte `from`,
150/// skipping over string literals (which may themselves contain `}}`).
151fn find_close(text: &str, from: usize) -> Option<usize> {
152 let bytes = text.as_bytes();
153 let mut in_string = false;
154 let mut i = from;
155 while i < bytes.len() {
156 match bytes[i] {
157 b'\'' => in_string = !in_string,
158 b'}' if !in_string && bytes.get(i + 1) == Some(&b'}') => return Some(i),
159 _ => {}
160 }
161 i += 1;
162 }
163 None
164}
165
166/// The body of text when text is exactly one `${{ expr }}` and nothing else.
167fn single_expression(text: &str) -> Option<&str> {
168 let text = text.trim();
169 if !text.starts_with("${{") {
170 return None;
171 }
172 let close = find_close(text, 3)?;
173 (close + 2 == text.len()).then(|| &text[3..close])
174}
175
176// ---------------------------------------------------------------------------
177// Lexing
178
179#[derive(Clone, Debug, PartialEq)]
180enum Tok {
181 Null,
182 True,
183 False,
184 Number(f64),
185 Str(String),
186 Ident(String),
187 Dot,
188 Star,
189 LBracket,
190 RBracket,
191 LParen,
192 RParen,
193 Comma,
194 Not,
195 Lt,
196 Le,
197 Gt,
198 Ge,
199 Eq,
200 Ne,
201 And,
202 Or,
203}
204
205#[derive(Clone, Debug)]
206struct Token {
207 tok: Tok,
208 /// 1-based character position within the expression.
209 pos: usize,
210 /// The token as written, for error messages.
211 text: String,
212}
213
214fn located(message: &str, pos: usize, src: &str) -> String {
215 format!("{message}. Located at position {pos} within expression: {src}")
216}
217
218fn lex(src: &str) -> Result<Vec<Token>, String> {
219 let chars: Vec<char> = src.chars().collect();
220 let mut out: Vec<Token> = Vec::new();
221 let mut i = 0;
222 while i < chars.len() {
223 let c = chars[i];
224 if c.is_whitespace() {
225 i += 1;
226 continue;
227 }
228 let start = i;
229 let next = chars.get(i + 1).copied();
230 // Whether a value (rather than an operator) may come next, which
231 // decides whether `.5` is a number or a dereference.
232 let value_may_start = out.last().is_none_or(|t| {
233 !matches!(
234 t.tok,
235 Tok::Ident(_)
236 | Tok::Number(_)
237 | Tok::Str(_)
238 | Tok::Null
239 | Tok::True
240 | Tok::False
241 | Tok::RParen
242 | Tok::RBracket
243 | Tok::Star
244 )
245 });
246 let starts_number = c.is_ascii_digit()
247 || ((c == '-' || c == '+') && next.is_some_and(|n| n.is_ascii_digit() || n == '.'))
248 || (c == '.' && value_may_start && next.is_some_and(|n| n.is_ascii_digit()));
249 let tok = if starts_number {
250 i += 1;
251 while i < chars.len() {
252 let d = chars[i];
253 let so_far: String = chars[start..i].iter().collect();
254 let hex = so_far
255 .trim_start_matches(['-', '+'])
256 .to_ascii_lowercase()
257 .starts_with("0x");
258 let exponent_sign =
259 (d == '+' || d == '-') && matches!(chars[i - 1], 'e' | 'E') && !hex;
260 if d.is_ascii_alphanumeric() || d == '.' || d == '_' || exponent_sign {
261 i += 1;
262 } else {
263 break;
264 }
265 }
266 let text: String = chars[start..i].iter().collect();
267 match parse_number(&text, false) {
268 Some(n) => Tok::Number(n),
269 None => {
270 return Err(located(
271 &format!("Unexpected symbol: '{text}'"),
272 start + 1,
273 src,
274 ));
275 }
276 }
277 } else if c.is_alphabetic() || c == '_' {
278 i += 1;
279 while i < chars.len()
280 && (chars[i].is_alphanumeric() || chars[i] == '_' || chars[i] == '-')
281 {
282 i += 1;
283 }
284 let word: String = chars[start..i].iter().collect();
285 match word.as_str() {
286 "null" => Tok::Null,
287 "true" => Tok::True,
288 "false" => Tok::False,
289 _ => Tok::Ident(word),
290 }
291 } else if c == '\'' {
292 i += 1;
293 let mut s = String::new();
294 loop {
295 match chars.get(i) {
296 None => {
297 let text: String = chars[start..].iter().collect();
298 return Err(located(
299 &format!("Unexpected symbol: '{text}'"),
300 start + 1,
301 src,
302 ));
303 }
304 Some('\'') if chars.get(i + 1) == Some(&'\'') => {
305 s.push('\'');
306 i += 2;
307 }
308 Some('\'') => {
309 i += 1;
310 break;
311 }
312 Some(&ch) => {
313 s.push(ch);
314 i += 1;
315 }
316 }
317 }
318 Tok::Str(s)
319 } else {
320 let two = |a: char, b: char| c == a && next == Some(b);
321 let (tok, len) = if two('=', '=') {
322 (Tok::Eq, 2)
323 } else if two('!', '=') {
324 (Tok::Ne, 2)
325 } else if two('<', '=') {
326 (Tok::Le, 2)
327 } else if two('>', '=') {
328 (Tok::Ge, 2)
329 } else if two('&', '&') {
330 (Tok::And, 2)
331 } else if two('|', '|') {
332 (Tok::Or, 2)
333 } else {
334 let tok = match c {
335 '.' => Tok::Dot,
336 '*' => Tok::Star,
337 '[' => Tok::LBracket,
338 ']' => Tok::RBracket,
339 '(' => Tok::LParen,
340 ')' => Tok::RParen,
341 ',' => Tok::Comma,
342 '!' => Tok::Not,
343 '<' => Tok::Lt,
344 '>' => Tok::Gt,
345 _ => {
346 return Err(located(
347 &format!("Unexpected symbol: '{c}'"),
348 start + 1,
349 src,
350 ));
351 }
352 };
353 (tok, 1)
354 };
355 i += len;
356 tok
357 };
358 out.push(Token {
359 tok,
360 pos: start + 1,
361 text: chars[start..i].iter().collect(),
362 });
363 }
364 Ok(out)
365}
366
367/// Parses a number. Literals (`lenient == false`) must be exactly a number;
368/// strings being coerced (`lenient == true`) may be padded with whitespace,
369/// and the empty string is 0.
370fn parse_number(text: &str, lenient: bool) -> Option<f64> {
371 let s = if lenient { text.trim() } else { text };
372 if s.is_empty() {
373 return lenient.then_some(0.0);
374 }
375 let (negative, body) = match s.as_bytes()[0] {
376 b'-' => (true, &s[1..]),
377 b'+' => (false, &s[1..]),
378 _ => (false, s),
379 };
380 let sign = if negative { -1.0 } else { 1.0 };
381 let lower = body.to_ascii_lowercase();
382 if let Some(hex) = lower.strip_prefix("0x") {
383 return u64::from_str_radix(hex, 16).ok().map(|n| sign * n as f64);
384 }
385 if let Some(oct) = lower.strip_prefix("0o") {
386 return u64::from_str_radix(oct, 8).ok().map(|n| sign * n as f64);
387 }
388 if lenient && body == "Infinity" {
389 return Some(sign * f64::INFINITY);
390 }
391 // digits [. digits] [e [+-] digits], with at least one mantissa digit.
392 let bytes = body.as_bytes();
393 let mut i = 0;
394 let mut mantissa_digits = 0;
395 while i < bytes.len() && bytes[i].is_ascii_digit() {
396 i += 1;
397 mantissa_digits += 1;
398 }
399 if i < bytes.len() && bytes[i] == b'.' {
400 i += 1;
401 while i < bytes.len() && bytes[i].is_ascii_digit() {
402 i += 1;
403 mantissa_digits += 1;
404 }
405 }
406 if mantissa_digits == 0 {
407 return None;
408 }
409 if i < bytes.len() && (bytes[i] == b'e' || bytes[i] == b'E') {
410 i += 1;
411 if i < bytes.len() && (bytes[i] == b'+' || bytes[i] == b'-') {
412 i += 1;
413 }
414 let digits_start = i;
415 while i < bytes.len() && bytes[i].is_ascii_digit() {
416 i += 1;
417 }
418 if i == digits_start {
419 return None;
420 }
421 }
422 if i != bytes.len() {
423 return None;
424 }
425 let normalized = if body.starts_with('.') {
426 format!("0{body}")
427 } else {
428 body.to_string()
429 };
430 normalized.parse::<f64>().ok().map(|n| sign * n)
431}
432
433// ---------------------------------------------------------------------------
434// Parsing
435
436#[derive(Clone, Copy, Debug, PartialEq, Eq)]
437enum Func {
438 Contains,
439 StartsWith,
440 EndsWith,
441 Format,
442 Join,
443 ToJson,
444 FromJson,
445 HashFiles,
446 Success,
447 Always,
448 Cancelled,
449 Failure,
450}
451
452/// Name, function, fewest and most arguments.
453const FUNCTIONS: &[(&str, Func, usize, usize)] = &[
454 ("contains", Func::Contains, 2, 2),
455 ("startsWith", Func::StartsWith, 2, 2),
456 ("endsWith", Func::EndsWith, 2, 2),
457 ("format", Func::Format, 1, usize::MAX),
458 ("join", Func::Join, 1, 2),
459 ("toJSON", Func::ToJson, 1, 1),
460 ("fromJSON", Func::FromJson, 1, 1),
461 ("hashFiles", Func::HashFiles, 1, usize::MAX),
462 ("success", Func::Success, 0, 0),
463 ("always", Func::Always, 0, 0),
464 ("cancelled", Func::Cancelled, 0, 0),
465 ("failure", Func::Failure, 0, 0),
466];
467
468#[derive(Clone, Copy, Debug)]
469enum CmpOp {
470 Lt,
471 Le,
472 Gt,
473 Ge,
474 Eq,
475 Ne,
476}
477
478#[derive(Debug)]
479enum Expr {
480 Literal(Value),
481 Named(String),
482 Property(Box<Expr>, String),
483 Index(Box<Expr>, Box<Expr>),
484 Wildcard(Box<Expr>),
485 Not(Box<Expr>),
486 Compare(CmpOp, Box<Expr>, Box<Expr>),
487 And(Box<Expr>, Box<Expr>),
488 Or(Box<Expr>, Box<Expr>),
489 Call(Func, Vec<Expr>),
490}
491
492fn parse(src: &str, contexts: &Map<String, Value>) -> Result<Expr, String> {
493 let toks = lex(src)?;
494 if toks.is_empty() {
495 return Err(format!("An expression was expected: '{src}'"));
496 }
497 let mut parser = Parser {
498 toks,
499 i: 0,
500 src,
501 contexts,
502 };
503 let expr = parser.or()?;
504 if parser.i < parser.toks.len() {
505 return Err(parser.unexpected());
506 }
507 Ok(expr)
508}
509
510struct Parser<'s> {
511 toks: Vec<Token>,
512 i: usize,
513 src: &'s str,
514 contexts: &'s Map<String, Value>,
515}
516
517impl Parser<'_> {
518 fn peek(&self) -> Option<&Tok> {
519 self.toks.get(self.i).map(|t| &t.tok)
520 }
521
522 fn eat(&mut self, tok: &Tok) -> bool {
523 if self.peek() == Some(tok) {
524 self.i += 1;
525 true
526 } else {
527 false
528 }
529 }
530
531 fn unexpected(&self) -> String {
532 match self.toks.get(self.i) {
533 Some(t) => located(&format!("Unexpected symbol: '{}'", t.text), t.pos, self.src),
534 None => match self.toks.last() {
535 Some(t) => located(
536 &format!("Unexpected end of expression: '{}'", t.text),
537 t.pos,
538 self.src,
539 ),
540 None => format!("An expression was expected: '{}'", self.src),
541 },
542 }
543 }
544
545 fn or(&mut self) -> Result<Expr, String> {
546 let mut left = self.and()?;
547 while self.eat(&Tok::Or) {
548 let right = self.and()?;
549 left = Expr::Or(Box::new(left), Box::new(right));
550 }
551 Ok(left)
552 }
553
554 fn and(&mut self) -> Result<Expr, String> {
555 let mut left = self.equality()?;
556 while self.eat(&Tok::And) {
557 let right = self.equality()?;
558 left = Expr::And(Box::new(left), Box::new(right));
559 }
560 Ok(left)
561 }
562
563 fn equality(&mut self) -> Result<Expr, String> {
564 let mut left = self.comparison()?;
565 loop {
566 let op = match self.peek() {
567 Some(Tok::Eq) => CmpOp::Eq,
568 Some(Tok::Ne) => CmpOp::Ne,
569 _ => return Ok(left),
570 };
571 self.i += 1;
572 let right = self.comparison()?;
573 left = Expr::Compare(op, Box::new(left), Box::new(right));
574 }
575 }
576
577 fn comparison(&mut self) -> Result<Expr, String> {
578 let mut left = self.unary()?;
579 loop {
580 let op = match self.peek() {
581 Some(Tok::Lt) => CmpOp::Lt,
582 Some(Tok::Le) => CmpOp::Le,
583 Some(Tok::Gt) => CmpOp::Gt,
584 Some(Tok::Ge) => CmpOp::Ge,
585 _ => return Ok(left),
586 };
587 self.i += 1;
588 let right = self.unary()?;
589 left = Expr::Compare(op, Box::new(left), Box::new(right));
590 }
591 }
592
593 fn unary(&mut self) -> Result<Expr, String> {
594 if self.eat(&Tok::Not) {
595 return Ok(Expr::Not(Box::new(self.unary()?)));
596 }
597 self.postfix()
598 }
599
600 fn postfix(&mut self) -> Result<Expr, String> {
601 let mut expr = self.primary()?;
602 loop {
603 if self.eat(&Tok::Dot) {
604 let token = self.toks.get(self.i).cloned();
605 expr = match token.map(|t| (t.tok, t.text)) {
606 Some((Tok::Star, _)) => Expr::Wildcard(Box::new(expr)),
607 Some((Tok::Ident(name), _)) => Expr::Property(Box::new(expr), name),
608 Some((Tok::True | Tok::False | Tok::Null, text)) => {
609 Expr::Property(Box::new(expr), text)
610 }
611 _ => return Err(self.unexpected()),
612 };
613 self.i += 1;
614 } else if self.eat(&Tok::LBracket) {
615 if self.peek() == Some(&Tok::Star)
616 && self.toks.get(self.i + 1).map(|t| &t.tok) == Some(&Tok::RBracket)
617 {
618 self.i += 2;
619 expr = Expr::Wildcard(Box::new(expr));
620 } else {
621 let index = self.or()?;
622 if !self.eat(&Tok::RBracket) {
623 return Err(self.unexpected());
624 }
625 expr = Expr::Index(Box::new(expr), Box::new(index));
626 }
627 } else {
628 return Ok(expr);
629 }
630 }
631 }
632
633 fn primary(&mut self) -> Result<Expr, String> {
634 let Some(token) = self.toks.get(self.i).cloned() else {
635 return Err(self.unexpected());
636 };
637 self.i += 1;
638 match token.tok {
639 Tok::Null => Ok(Expr::Literal(Value::Null)),
640 Tok::True => Ok(Expr::Literal(Value::Bool(true))),
641 Tok::False => Ok(Expr::Literal(Value::Bool(false))),
642 Tok::Number(n) => Ok(Expr::Literal(number(n))),
643 Tok::Str(s) => Ok(Expr::Literal(Value::String(s))),
644 Tok::LParen => {
645 let inner = self.or()?;
646 if !self.eat(&Tok::RParen) {
647 return Err(self.unexpected());
648 }
649 Ok(inner)
650 }
651 Tok::Ident(name) if self.peek() == Some(&Tok::LParen) => {
652 self.i += 1;
653 self.call(&name, token.pos)
654 }
655 Tok::Ident(name) => {
656 let known = NAMED_VALUES.iter().any(|n| n.eq_ignore_ascii_case(&name))
657 || self.contexts.keys().any(|k| k.eq_ignore_ascii_case(&name));
658 if !known {
659 return Err(located(
660 &format!("Unrecognized named-value: '{name}'"),
661 token.pos,
662 self.src,
663 ));
664 }
665 Ok(Expr::Named(name))
666 }
667 _ => {
668 self.i -= 1;
669 Err(self.unexpected())
670 }
671 }
672 }
673
674 fn call(&mut self, name: &str, pos: usize) -> Result<Expr, String> {
675 let Some(&(canonical, func, min, max)) = FUNCTIONS
676 .iter()
677 .find(|(n, ..)| n.eq_ignore_ascii_case(name))
678 else {
679 return Err(located(
680 &format!("Unrecognized function: '{name}'"),
681 pos,
682 self.src,
683 ));
684 };
685 let mut args = Vec::new();
686 if !self.eat(&Tok::RParen) {
687 loop {
688 args.push(self.or()?);
689 if self.eat(&Tok::Comma) {
690 continue;
691 }
692 if self.eat(&Tok::RParen) {
693 break;
694 }
695 return Err(self.unexpected());
696 }
697 }
698 if args.len() < min {
699 return Err(located(
700 &format!("Too few parameters supplied: '{canonical}'"),
701 pos,
702 self.src,
703 ));
704 }
705 if args.len() > max {
706 return Err(located(
707 &format!("Too many parameters supplied: '{canonical}'"),
708 pos,
709 self.src,
710 ));
711 }
712 Ok(Expr::Call(func, args))
713 }
714}
715
716/// Whether the expression calls success(), failure(), cancelled() or always().
717fn uses_status(expr: &Expr) -> bool {
718 match expr {
719 Expr::Literal(_) | Expr::Named(_) => false,
720 Expr::Property(base, _) | Expr::Wildcard(base) | Expr::Not(base) => uses_status(base),
721 Expr::Index(a, b) | Expr::Compare(_, a, b) | Expr::And(a, b) | Expr::Or(a, b) => {
722 uses_status(a) || uses_status(b)
723 }
724 Expr::Call(func, args) => {
725 matches!(
726 func,
727 Func::Success | Func::Failure | Func::Cancelled | Func::Always
728 ) || args.iter().any(uses_status)
729 }
730 }
731}
732
733// ---------------------------------------------------------------------------
734// Evaluation
735
736/// A value while evaluating: borrowed from the contexts where possible, and
737/// a filtered array (the result of a `*`) kept apart, since property access on
738/// it applies to every item.
739enum Ev<'c> {
740 One(Cow<'c, Value>),
741 Filtered(Vec<Cow<'c, Value>>),
742}
743
744impl Ev<'_> {
745 fn into_value(self) -> Value {
746 match self {
747 Ev::One(v) => v.into_owned(),
748 Ev::Filtered(items) => Value::Array(items.into_iter().map(Cow::into_owned).collect()),
749 }
750 }
751
752 fn truthy(&self) -> bool {
753 match self {
754 Ev::One(v) => truthy(v),
755 Ev::Filtered(_) => true,
756 }
757 }
758}
759
760fn owned<'c>(value: Value) -> Ev<'c> {
761 Ev::One(Cow::Owned(value))
762}
763
764/// A key on an object: the exact key if present, otherwise ignoring ASCII case.
765fn find_key<'m>(map: &'m Map<String, Value>, key: &str) -> Option<&'m String> {
766 if let Some((k, _)) = map.get_key_value(key) {
767 return Some(k);
768 }
769 map.keys().find(|k| k.eq_ignore_ascii_case(key))
770}
771
772enum Key {
773 Name(String),
774 Index(usize),
775}
776
777fn child<'c>(value: Cow<'c, Value>, key: &Key) -> Option<Cow<'c, Value>> {
778 match (value, key) {
779 (Cow::Borrowed(Value::Object(map)), Key::Name(name)) => find_key(map, name)
780 .and_then(|k| map.get(k))
781 .map(Cow::Borrowed),
782 (Cow::Owned(Value::Object(mut map)), Key::Name(name)) => {
783 let k = find_key(&map, name)?.clone();
784 map.remove(&k).map(Cow::Owned)
785 }
786 (Cow::Borrowed(Value::Array(items)), Key::Index(i)) => items.get(*i).map(Cow::Borrowed),
787 (Cow::Owned(Value::Array(items)), Key::Index(i)) => {
788 items.into_iter().nth(*i).map(Cow::Owned)
789 }
790 _ => None,
791 }
792}
793
794fn children(value: Cow<'_, Value>) -> Vec<Cow<'_, Value>> {
795 match value {
796 Cow::Borrowed(Value::Array(items)) => items.iter().map(Cow::Borrowed).collect(),
797 Cow::Borrowed(Value::Object(map)) => map.values().map(Cow::Borrowed).collect(),
798 Cow::Owned(Value::Array(items)) => items.into_iter().map(Cow::Owned).collect(),
799 Cow::Owned(Value::Object(map)) => map.into_iter().map(|(_, v)| Cow::Owned(v)).collect(),
800 _ => Vec::new(),
801 }
802}
803
804/// The key `index` selects on `target`: a position on an array, a name on an object.
805fn key_for(target: &Value, index: &Value) -> Option<Key> {
806 match target {
807 Value::Array(_) => {
808 let n = to_number(index);
809 (n.is_finite() && n >= 0.0).then(|| Key::Index(n.trunc() as usize))
810 }
811 Value::Object(_) => Some(Key::Name(to_text(index))),
812 _ => None,
813 }
814}
815
816fn eval<'c>(expr: &Expr, scope: &Scope<'c>) -> Result<Ev<'c>, String> {
817 Ok(match expr {
818 Expr::Literal(v) => owned(v.clone()),
819 Expr::Named(name) => {
820 let contexts: &'c Map<String, Value> = scope.contexts;
821 match find_key(contexts, name).and_then(|k| contexts.get(k)) {
822 Some(v) => Ev::One(Cow::Borrowed(v)),
823 None => owned(Value::Null),
824 }
825 }
826 Expr::Property(base, name) => {
827 let key = Key::Name(name.clone());
828 match eval(base, scope)? {
829 Ev::One(v) => Ev::One(child(v, &key).unwrap_or(Cow::Owned(Value::Null))),
830 Ev::Filtered(items) => {
831 Ev::Filtered(items.into_iter().filter_map(|it| child(it, &key)).collect())
832 }
833 }
834 }
835 Expr::Index(base, index) => {
836 let base = eval(base, scope)?;
837 let index = eval(index, scope)?.into_value();
838 match base {
839 Ev::One(v) => {
840 let found = key_for(&v, &index).and_then(|key| child(v, &key));
841 Ev::One(found.unwrap_or(Cow::Owned(Value::Null)))
842 }
843 Ev::Filtered(items) => Ev::Filtered(
844 items
845 .into_iter()
846 .filter_map(|it| key_for(&it, &index).and_then(|key| child(it, &key)))
847 .collect(),
848 ),
849 }
850 }
851 Expr::Wildcard(base) => match eval(base, scope)? {
852 Ev::One(v) => Ev::Filtered(children(v)),
853 Ev::Filtered(items) => Ev::Filtered(items.into_iter().flat_map(children).collect()),
854 },
855 Expr::Not(inner) => owned(Value::Bool(!eval(inner, scope)?.truthy())),
856 Expr::And(a, b) => {
857 let left = eval(a, scope)?;
858 if !left.truthy() {
859 return Ok(left);
860 }
861 eval(b, scope)?
862 }
863 Expr::Or(a, b) => {
864 let left = eval(a, scope)?;
865 if left.truthy() {
866 return Ok(left);
867 }
868 eval(b, scope)?
869 }
870 Expr::Compare(op, a, b) => {
871 let left = eval(a, scope)?.into_value();
872 let right = eval(b, scope)?.into_value();
873 let result = match op {
874 CmpOp::Eq => loose_eq(&left, &right),
875 CmpOp::Ne => !loose_eq(&left, &right),
876 CmpOp::Lt => compare(&left, &right) == Some(Ordering::Less),
877 CmpOp::Le => matches!(
878 compare(&left, &right),
879 Some(Ordering::Less | Ordering::Equal)
880 ),
881 CmpOp::Gt => compare(&left, &right) == Some(Ordering::Greater),
882 CmpOp::Ge => {
883 matches!(
884 compare(&left, &right),
885 Some(Ordering::Greater | Ordering::Equal)
886 )
887 }
888 };
889 owned(Value::Bool(result))
890 }
891 Expr::Call(func, args) => owned(call(*func, args, scope)?),
892 })
893}
894
895fn call(func: Func, args: &[Expr], scope: &Scope) -> Result<Value, String> {
896 let status = scope.status;
897 match func {
898 Func::Success => return Ok(Value::Bool(status == Status::Success)),
899 Func::Failure => return Ok(Value::Bool(status == Status::Failure)),
900 Func::Cancelled => return Ok(Value::Bool(status == Status::Cancelled)),
901 Func::Always => return Ok(Value::Bool(true)),
902 _ => {}
903 }
904 let values: Vec<Value> = args
905 .iter()
906 .map(|a| eval(a, scope).map(Ev::into_value))
907 .collect::<Result<_, _>>()?;
908 Ok(match func {
909 Func::Contains => Value::Bool(match &values[0] {
910 Value::Array(items) => items.iter().any(|item| loose_eq(item, &values[1])),
911 search => upper(&to_text(search)).contains(&upper(&to_text(&values[1]))),
912 }),
913 Func::StartsWith => {
914 Value::Bool(upper(&to_text(&values[0])).starts_with(&upper(&to_text(&values[1]))))
915 }
916 Func::EndsWith => {
917 Value::Bool(upper(&to_text(&values[0])).ends_with(&upper(&to_text(&values[1]))))
918 }
919 Func::Format => Value::String(format_string(&values)?),
920 Func::Join => {
921 let separator = values.get(1).map_or_else(|| ",".to_string(), to_text);
922 Value::String(match &values[0] {
923 Value::Array(items) => items
924 .iter()
925 .map(to_text)
926 .collect::<Vec<_>>()
927 .join(&separator),
928 other => to_text(other),
929 })
930 }
931 Func::ToJson => Value::String(to_json(&values[0])),
932 Func::FromJson => {
933 let text = to_text(&values[0]);
934 let parsed: Value = serde_json::from_str(&text)
935 .map_err(|e| format!("Error from function 'fromJSON': {e}. Input: '{text}'"))?;
936 normalize(parsed)
937 }
938 Func::HashFiles => {
939 let patterns: Vec<String> = values.iter().map(to_text).collect();
940 Value::String(scope.hash_files.map(|f| f(&patterns)).unwrap_or_default())
941 }
942 Func::Success | Func::Failure | Func::Cancelled | Func::Always => unreachable!(),
943 })
944}
945
946/// format('{0} {1}', ...): `{N}` is the Nth argument after the format string,
947/// `{{` and `}}` are literal braces, anything else with a brace is an error.
948fn format_string(values: &[Value]) -> Result<String, String> {
949 let template = to_text(&values[0]);
950 let args: Vec<String> = values[1..].iter().map(to_text).collect();
951 let invalid = || format!("The following format string is invalid: '{template}'");
952 let chars: Vec<char> = template.chars().collect();
953 let mut out = String::new();
954 let mut i = 0;
955 while i < chars.len() {
956 match chars[i] {
957 '{' if chars.get(i + 1) == Some(&'{') => {
958 out.push('{');
959 i += 2;
960 }
961 '}' if chars.get(i + 1) == Some(&'}') => {
962 out.push('}');
963 i += 2;
964 }
965 '{' => {
966 let start = i + 1;
967 let mut end = start;
968 while end < chars.len() && chars[end].is_ascii_digit() {
969 end += 1;
970 }
971 if end == start || chars.get(end) != Some(&'}') {
972 return Err(invalid());
973 }
974 let digits: String = chars[start..end].iter().collect();
975 let index: usize = digits.parse().map_err(|_| invalid())?;
976 let arg = args.get(index).ok_or_else(|| {
977 format!(
978 "The following format string references more arguments than were supplied: '{template}'"
979 )
980 })?;
981 out.push_str(arg);
982 i = end + 1;
983 }
984 '}' => return Err(invalid()),
985 c => {
986 out.push(c);
987 i += 1;
988 }
989 }
990 }
991 Ok(out)
992}
993
994fn upper(s: &str) -> String {
995 s.to_uppercase()
996}
997
998/// A value coerced to a number, as GitHub does when operand types differ.
999fn to_number(value: &Value) -> f64 {
1000 match value {
1001 Value::Null => 0.0,
1002 Value::Bool(b) => f64::from(u8::from(*b)),
1003 Value::Number(n) => n.as_f64().unwrap_or(f64::NAN),
1004 Value::String(s) => parse_number(s, true).unwrap_or(f64::NAN),
1005 Value::Array(_) | Value::Object(_) => f64::NAN,
1006 }
1007}
1008
1009/// GitHub's `==`: same types compare directly (strings ignoring case; arrays
1010/// and objects are never equal, since they cannot be the same instance here);
1011/// different types are both coerced to numbers, and NaN equals nothing.
1012fn loose_eq(a: &Value, b: &Value) -> bool {
1013 match (a, b) {
1014 (Value::Null, Value::Null) => true,
1015 (Value::Bool(x), Value::Bool(y)) => x == y,
1016 (Value::Number(_), Value::Number(_)) => to_number(a) == to_number(b),
1017 (Value::String(x), Value::String(y)) => upper(x) == upper(y),
1018 (Value::Array(_), Value::Array(_)) | (Value::Object(_), Value::Object(_)) => false,
1019 _ => to_number(a) == to_number(b),
1020 }
1021}
1022
1023/// GitHub's ordering for `<`, `<=`, `>`, `>=`; None when they do not compare.
1024fn compare(a: &Value, b: &Value) -> Option<Ordering> {
1025 match (a, b) {
1026 (Value::Null, Value::Null) => Some(Ordering::Equal),
1027 (Value::String(x), Value::String(y)) => Some(upper(x).cmp(&upper(y))),
1028 (Value::Array(_) | Value::Object(_), _) | (_, Value::Array(_) | Value::Object(_)) => None,
1029 _ => to_number(a).partial_cmp(&to_number(b)),
1030 }
1031}
1032
1033/// A number as a JSON value, integral numbers as integers so they print and
1034/// serialize without a trailing ".0".
1035fn number(n: f64) -> Value {
1036 if n.fract() == 0.0 && n.abs() < 9_007_199_254_740_992.0 {
1037 Value::from(n as i64)
1038 } else {
1039 serde_json::Number::from_f64(n).map_or(Value::Null, Value::Number)
1040 }
1041}
1042
1043/// Integral floats as integers, all the way down.
1044fn normalize(value: Value) -> Value {
1045 match value {
1046 Value::Number(n) if n.is_f64() => number(n.as_f64().unwrap_or(f64::NAN)),
1047 Value::Array(items) => Value::Array(items.into_iter().map(normalize).collect()),
1048 Value::Object(map) => {
1049 Value::Object(map.into_iter().map(|(k, v)| (k, normalize(v))).collect())
1050 }
1051 other => other,
1052 }
1053}
1054
1055fn to_json(value: &Value) -> String {
1056 serde_json::to_string_pretty(&normalize(value.clone())).unwrap_or_default()
1057}
1058
1059/// A number the way GitHub (.NET's "G15") writes it: up to 15 significant
1060/// digits, no trailing zeros, and scientific notation (`1E+15`, `1E-07`) for
1061/// very large or very small magnitudes.
1062fn format_number(n: f64) -> String {
1063 if n.is_nan() {
1064 return "NaN".to_string();
1065 }
1066 if n.is_infinite() {
1067 return if n > 0.0 { "Infinity" } else { "-Infinity" }.to_string();
1068 }
1069 if n == 0.0 {
1070 return "0".to_string();
1071 }
1072 let scientific = format!("{:.14e}", n.abs());
1073 let (mantissa, exponent) = scientific.split_once('e').unwrap_or((&scientific, "0"));
1074 let exponent: i32 = exponent.parse().unwrap_or(0);
1075 let mut digits: String = mantissa.chars().filter(char::is_ascii_digit).collect();
1076 while digits.len() > 1 && digits.ends_with('0') {
1077 digits.pop();
1078 }
1079 let mut out = String::new();
1080 if n < 0.0 {
1081 out.push('-');
1082 }
1083 if !(-5..15).contains(&exponent) {
1084 out.push_str(&digits[..1]);
1085 if digits.len() > 1 {
1086 out.push('.');
1087 out.push_str(&digits[1..]);
1088 }
1089 out.push('E');
1090 out.push(if exponent < 0 { '-' } else { '+' });
1091 out.push_str(&format!("{:02}", exponent.abs()));
1092 } else if exponent >= 0 {
1093 let whole = exponent as usize + 1;
1094 if digits.len() > whole {
1095 out.push_str(&digits[..whole]);
1096 out.push('.');
1097 out.push_str(&digits[whole..]);
1098 } else {
1099 out.push_str(&digits);
1100 out.push_str(&"0".repeat(whole - digits.len()));
1101 }
1102 } else {
1103 out.push_str("0.");
1104 out.push_str(&"0".repeat((-exponent - 1) as usize));
1105 out.push_str(&digits);
1106 }
1107 out
1108}
1109
1110// ---------------------------------------------------------------------------
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::*;
1115 use serde_json::json;
1116
1117 fn contexts() -> Map<String, Value> {
1118 let value = json!({
1119 "github": {
1120 "ref": "refs/heads/main",
1121 "ref_name": "main",
1122 "event_name": "push",
1123 "repository": "flagon-io/g1t",
1124 "actor": "dependabot[bot]",
1125 "event": {
1126 "pull_request": {
1127 "number": 42,
1128 "draft": false,
1129 "title": "Fix the thing",
1130 "head": { "ref": "feature/x" },
1131 "labels": [{ "name": "bug" }, { "name": "Enhancement" }]
1132 },
1133 "issues": [
1134 { "labels": [{ "name": "a" }, { "name": "b" }] },
1135 { "labels": [{ "name": "c" }] }
1136 ],
1137 "head_commit": { "message": "fix: x [skip ci]" }
1138 }
1139 },
1140 "env": { "NODE_VERSION": "18", "EMPTY": "" },
1141 "vars": { "DEPLOY": "yes" },
1142 "secrets": { "TOKEN": "s3cret" },
1143 "inputs": { "debug": "true", "flag": true, "count": 3, "environment": "staging" },
1144 "matrix": { "os": "ubuntu-latest", "node": 18, "experimental": false },
1145 "strategy": { "fail-fast": true, "job-index": 0 },
1146 "steps": {
1147 "build": { "outputs": { "version": "1.2.3" }, "outcome": "success", "conclusion": "success" },
1148 "test": { "outputs": {}, "outcome": "failure", "conclusion": "success" },
1149 "my-step": { "outputs": { "cache-hit": "true" } }
1150 },
1151 "needs": {
1152 "setup": {
1153 "result": "success",
1154 "outputs": { "matrix": "{\"os\":[\"ubuntu-latest\",\"windows-latest\"],\"node\":[18,20]}" }
1155 }
1156 },
1157 "runner": { "os": "Linux", "arch": "X64" },
1158 "job": { "status": "success" }
1159 });
1160 match value {
1161 Value::Object(map) => map,
1162 _ => unreachable!(),
1163 }
1164 }
1165
1166 fn with<T>(status: Status, f: impl FnOnce(&Scope) -> T) -> T {
1167 let contexts = contexts();
1168 let hash = |patterns: &[String]| format!("hash({})", patterns.join("|"));
1169 let scope = Scope {
1170 contexts: &contexts,
1171 status,
1172 hash_files: Some(&hash),
1173 };
1174 f(&scope)
1175 }
1176
1177 fn ev(expression: &str) -> Value {
1178 with(Status::Success, |s| evaluate(expression, s))
1179 .unwrap_or_else(|e| panic!("{expression}: {e}"))
1180 }
1181
1182 fn err(expression: &str) -> String {
1183 with(Status::Success, |s| evaluate(expression, s)).unwrap_err()
1184 }
1185
1186 fn cond(status: Status, text: &str) -> bool {
1187 with(status, |s| condition(text, s)).unwrap_or_else(|e| panic!("{text}: {e}"))
1188 }
1189
1190 #[test]
1191 fn literals() {
1192 assert_eq!(ev("null"), Value::Null);
1193 assert_eq!(ev("true"), json!(true));
1194 assert_eq!(ev("false"), json!(false));
1195 assert_eq!(ev("711"), json!(711));
1196 assert_eq!(ev("-9.2"), json!(-9.2));
1197 assert_eq!(ev("0xff"), json!(255));
1198 assert_eq!(ev("-2.99e-2"), json!(-0.0299));
1199 assert_eq!(ev("1e3"), json!(1000));
1200 assert_eq!(ev("'Mona the Octocat'"), json!("Mona the Octocat"));
1201 assert_eq!(ev("'It''s open source!'"), json!("It's open source!"));
1202 }
1203
1204 #[test]
1205 fn double_quotes_are_an_error() {
1206 let e = err("github.ref == \"main\"");
1207 assert!(
1208 e.starts_with("Unexpected symbol: '\"'. Located at position 15 within expression:"),
1209 "{e}"
1210 );
1211 }
1212
1213 #[test]
1214 fn ref_is_main() {
1215 assert_eq!(ev("github.ref == 'refs/heads/main'"), json!(true));
1216 assert_eq!(ev("github.ref != 'refs/heads/main'"), json!(false));
1217 }
1218
1219 #[test]
1220 fn starts_with_tag() {
1221 assert_eq!(ev("startsWith(github.ref, 'refs/tags/v')"), json!(false));
1222 assert_eq!(ev("startsWith(github.ref, 'REFS/heads/')"), json!(true));
1223 assert_eq!(ev("endsWith(github.repository, '/G1T')"), json!(true));
1224 }
1225
1226 #[test]
1227 fn contains_labels_filter() {
1228 assert_eq!(
1229 ev("contains(github.event.pull_request.labels.*.name, 'bug')"),
1230 json!(true)
1231 );
1232 assert_eq!(
1233 ev("contains(github.event.pull_request.labels.*.name, 'enhancement')"),
1234 json!(true)
1235 );
1236 assert_eq!(
1237 ev("contains(github.event.pull_request.labels.*.name, 'docs')"),
1238 json!(false)
1239 );
1240 }
1241
1242 #[test]
1243 fn nested_object_filters_flatten() {
1244 assert_eq!(
1245 ev("github.event.issues.*.labels.*.name"),
1246 json!(["a", "b", "c"])
1247 );
1248 assert_eq!(
1249 ev("github.event.pull_request.labels[*].name"),
1250 json!(["bug", "Enhancement"])
1251 );
1252 assert_eq!(
1253 ev("github.event.pull_request.*"),
1254 ev("github.event.pull_request.*")
1255 );
1256 assert_eq!(ev("matrix.nothing.*"), json!([]));
1257 }
1258
1259 #[test]
1260 fn matrix_and() {
1261 assert_eq!(
1262 ev("matrix.os == 'ubuntu-latest' && matrix.node >= 18"),
1263 json!(true)
1264 );
1265 assert_eq!(
1266 ev("matrix.os == 'windows-latest' && matrix.node >= 18"),
1267 json!(false)
1268 );
1269 }
1270
1271 #[test]
1272 fn step_outputs() {
1273 assert_eq!(ev("steps.build.outputs.version"), json!("1.2.3"));
1274 assert_eq!(
1275 ev("steps.my-step.outputs.cache-hit != 'true'"),
1276 json!(false)
1277 );
1278 assert_eq!(ev("steps.missing.outputs.version"), Value::Null);
1279 }
1280
1281 #[test]
1282 fn format_with_hash_files() {
1283 assert_eq!(
1284 ev("format('{0}-{1}', runner.os, hashFiles('**/package-lock.json'))"),
1285 json!("Linux-hash(**/package-lock.json)")
1286 );
1287 assert_eq!(ev("hashFiles('a', 'b')"), json!("hash(a|b)"));
1288 }
1289
1290 #[test]
1291 fn hash_files_without_sandbox_is_empty() {
1292 let contexts = contexts();
1293 let scope = Scope {
1294 contexts: &contexts,
1295 status: Status::Success,
1296 hash_files: None,
1297 };
1298 assert_eq!(
1299 evaluate("hashFiles('**/*.lock')", &scope).unwrap(),
1300 json!("")
1301 );
1302 }
1303
1304 #[test]
1305 fn format_escapes_and_errors() {
1306 assert_eq!(
1307 ev("format('{{Hello {0} {1} {2}!}}', 'Mona', 'the', 'Octocat')"),
1308 json!("{Hello Mona the Octocat!}")
1309 );
1310 assert_eq!(ev("format('{0}{0}', 1)"), json!("11"));
1311 assert!(err("format('{1}', 'a')").contains("more arguments than were supplied"));
1312 assert!(err("format('{0', 'a')").contains("invalid"));
1313 }
1314
1315 #[test]
1316 fn from_json_matrix() {
1317 assert_eq!(
1318 ev("fromJSON(needs.setup.outputs.matrix)"),
1319 json!({ "os": ["ubuntu-latest", "windows-latest"], "node": [18, 20] })
1320 );
1321 assert_eq!(
1322 ev("fromJSON(needs.setup.outputs.matrix).node[1]"),
1323 json!(20)
1324 );
1325 assert_eq!(ev("fromJSON('true')"), json!(true));
1326 assert_eq!(ev("fromJSON('3.0')"), json!(3));
1327 assert!(err("fromJSON('{nope')").contains("fromJSON"));
1328 }
1329
1330 #[test]
1331 fn event_name_or() {
1332 assert_eq!(
1333 ev("github.event_name == 'push' || github.event_name == 'workflow_dispatch'"),
1334 json!(true)
1335 );
1336 }
1337
1338 #[test]
1339 fn and_or_return_operands() {
1340 assert_eq!(ev("matrix.os && 'yes'"), json!("yes"));
1341 assert_eq!(ev("env.EMPTY && 'yes'"), json!(""));
1342 assert_eq!(ev("env.EMPTY || 'fallback'"), json!("fallback"));
1343 assert_eq!(ev("inputs.environment || 'production'"), json!("staging"));
1344 assert_eq!(ev("github.event.pull_request.draft || null"), Value::Null);
1345 }
1346
1347 #[test]
1348 fn short_circuit_skips_errors() {
1349 assert_eq!(ev("false && fromJSON('{bad')"), json!(false));
1350 assert_eq!(ev("true || fromJSON('{bad')"), json!(true));
1351 }
1352
1353 #[test]
1354 fn not_cancelled() {
1355 assert!(cond(Status::Success, "!cancelled()"));
1356 assert!(cond(Status::Failure, "!cancelled()"));
1357 assert!(!cond(Status::Cancelled, "!cancelled()"));
1358 }
1359
1360 #[test]
1361 fn failure_and_outcome() {
1362 assert!(cond(
1363 Status::Failure,
1364 "failure() && steps.test.outcome == 'failure'"
1365 ));
1366 assert!(!cond(
1367 Status::Success,
1368 "failure() && steps.test.outcome == 'failure'"
1369 ));
1370 assert!(!cond(
1371 Status::Failure,
1372 "failure() && steps.build.outcome == 'failure'"
1373 ));
1374 }
1375
1376 #[test]
1377 fn string_input_is_not_true() {
1378 // The famous gotcha: 'true' coerces to NaN when compared with a bool.
1379 assert_eq!(ev("inputs.debug == true"), json!(false));
1380 assert_eq!(ev("inputs.debug == 'true'"), json!(true));
1381 assert_eq!(ev("inputs.flag == true"), json!(true));
1382 }
1383
1384 #[test]
1385 fn coercions() {
1386 assert_eq!(ev("'' == 0"), json!(true));
1387 assert_eq!(ev("null == false"), json!(true));
1388 assert_eq!(ev("null == 0"), json!(true));
1389 assert_eq!(ev("1 == '1'"), json!(true));
1390 assert_eq!(ev("'1.0' == 1"), json!(true));
1391 assert_eq!(ev("' 2 ' == 2"), json!(true));
1392 assert_eq!(ev("'0x10' == 16"), json!(true));
1393 assert_eq!(ev("true == 1"), json!(true));
1394 assert_eq!(ev("'abc' == 0"), json!(false));
1395 assert_eq!(ev("'abc' != 0"), json!(true));
1396 assert_eq!(ev("null == ''"), json!(true));
1397 }
1398
1399 #[test]
1400 fn hex_and_exponent() {
1401 assert_eq!(ev("0x10 == 16"), json!(true));
1402 assert_eq!(ev("1e3 == 1000"), json!(true));
1403 assert_eq!(ev("-0x10 < 0"), json!(true));
1404 }
1405
1406 #[test]
1407 fn case_insensitive_strings() {
1408 assert_eq!(ev("'ABC' == 'abc'"), json!(true));
1409 assert_eq!(ev("contains('Hello World', 'WORLD')"), json!(true));
1410 assert_eq!(ev("'a' < 'B'"), json!(true));
1411 }
1412
1413 #[test]
1414 fn case_insensitive_names() {
1415 assert_eq!(ev("GitHub.Event_Name"), json!("push"));
1416 assert_eq!(ev("ENV.node_version"), json!("18"));
1417 assert_eq!(ev("StartsWith(github.ref, 'refs/')"), json!(true));
1418 assert_eq!(ev("TOJSON(1)"), json!("1"));
1419 }
1420
1421 #[test]
1422 fn objects_and_arrays_are_never_equal() {
1423 assert_eq!(ev("github.event == github.event"), json!(false));
1424 assert_eq!(ev("fromJSON('[]') == fromJSON('[]')"), json!(false));
1425 assert_eq!(ev("fromJSON('[]') == 0"), json!(false));
1426 assert_eq!(ev("fromJSON('{}') < 1"), json!(false));
1427 }
1428
1429 #[test]
1430 fn nan_compares_false() {
1431 assert_eq!(ev("'abc' < 1"), json!(false));
1432 assert_eq!(ev("'abc' >= 1"), json!(false));
1433 assert_eq!(ev("'abc' == 'abc'"), json!(true));
1434 }
1435
1436 #[test]
1437 fn comparisons() {
1438 assert_eq!(ev("matrix.node > 16"), json!(true));
1439 assert_eq!(ev("matrix.node <= '18'"), json!(true));
1440 assert_eq!(ev("inputs.count < 3"), json!(false));
1441 assert_eq!(ev("null <= null"), json!(true));
1442 assert_eq!(ev("false < true"), json!(true));
1443 }
1444
1445 #[test]
1446 fn precedence() {
1447 // ! binds tighter than ==, == tighter than &&, && tighter than ||.
1448 assert_eq!(ev("!matrix.experimental == true"), json!(true));
1449 assert_eq!(ev("true || false && false"), json!(true));
1450 assert_eq!(ev("(true || false) && false"), json!(false));
1451 assert_eq!(ev("1 < 2 == true"), json!(true));
1452 assert_eq!(ev("!!'x'"), json!(true));
1453 }
1454
1455 #[test]
1456 fn indexing() {
1457 assert_eq!(ev("github['event']['pull_request']['number']"), json!(42));
1458 assert_eq!(ev("github.event.pull_request.labels[0].name"), json!("bug"));
1459 assert_eq!(ev("github.event.pull_request.labels[5]"), Value::Null);
1460 assert_eq!(ev("matrix['os']"), json!("ubuntu-latest"));
1461 assert_eq!(ev("strategy.fail-fast"), json!(true));
1462 assert_eq!(ev("strategy['job-index']"), json!(0));
1463 }
1464
1465 #[test]
1466 fn missing_properties_are_null() {
1467 assert_eq!(ev("github.event.release.tag_name"), Value::Null);
1468 assert_eq!(ev("github.ref.nope"), Value::Null);
1469 assert_eq!(ev("jobs.anything"), Value::Null);
1470 }
1471
1472 #[test]
1473 fn unrecognized_named_value() {
1474 let e = err("foo.bar == 1");
1475 assert_eq!(
1476 e,
1477 "Unrecognized named-value: 'foo'. Located at position 1 within expression: foo.bar == 1"
1478 );
1479 // Found at parse time, even in a branch that never runs.
1480 assert!(err("false && bogus").contains("Unrecognized named-value: 'bogus'"));
1481 }
1482
1483 #[test]
1484 fn function_errors() {
1485 assert!(err("nope(1)").starts_with("Unrecognized function: 'nope'"));
1486 assert!(err("contains('a')").starts_with("Too few parameters supplied: 'contains'"));
1487 assert!(err("success(1)").starts_with("Too many parameters supplied: 'success'"));
1488 assert!(err("toJSON()").starts_with("Too few parameters supplied: 'toJSON'"));
1489 }
1490
1491 #[test]
1492 fn syntax_errors() {
1493 assert!(err("github.ref ==").starts_with("Unexpected end of expression: '=='"));
1494 assert!(err("(true").starts_with("Unexpected end of expression"));
1495 assert!(err("true false").starts_with("Unexpected symbol: 'false'. Located at position 6"));
1496 assert!(err("'open").starts_with("Unexpected symbol: ''open'"));
1497 assert!(err("a = b").contains("Unexpected symbol"));
1498 assert!(err("github.").starts_with("Unexpected end of expression"));
1499 assert!(err("").contains("expression was expected"));
1500 }
1501
1502 #[test]
1503 fn contains_array_uses_loose_equality() {
1504 assert_eq!(ev("contains(fromJSON('[1, 2, 3]'), '2')"), json!(true));
1505 assert_eq!(
1506 ev("contains(fromJSON('[\"push\", \"pull_request\"]'), github.event_name)"),
1507 json!(true)
1508 );
1509 assert_eq!(
1510 ev("contains(github.event.head_commit.message, '[skip ci]')"),
1511 json!(true)
1512 );
1513 assert_eq!(ev("contains(github.actor, '[bot]')"), json!(true));
1514 }
1515
1516 #[test]
1517 fn join() {
1518 assert_eq!(
1519 ev("join(github.event.pull_request.labels.*.name)"),
1520 json!("bug,Enhancement")
1521 );
1522 assert_eq!(
1523 ev("join(github.event.pull_request.labels.*.name, ', ')"),
1524 json!("bug, Enhancement")
1525 );
1526 assert_eq!(ev("join('abc', '-')"), json!("abc"));
1527 assert_eq!(
1528 ev("join(fromJSON('[1, true, null]'), ' ')"),
1529 json!("1 true ")
1530 );
1531 }
1532
1533 #[test]
1534 fn to_json_pretty() {
1535 assert_eq!(
1536 ev("toJSON(steps.build.outputs)"),
1537 json!("{\n \"version\": \"1.2.3\"\n}")
1538 );
1539 assert_eq!(ev("toJSON('a')"), json!("\"a\""));
1540 assert_eq!(ev("toJSON(null)"), json!("null"));
1541 assert_eq!(ev("toJSON(0x10)"), json!("16"));
1542 }
1543
1544 #[test]
1545 fn truthiness() {
1546 assert!(!truthy(&json!(false)));
1547 assert!(!truthy(&json!(0)));
1548 assert!(!truthy(&json!(-0.0)));
1549 assert!(!truthy(&json!("")));
1550 assert!(!truthy(&Value::Null));
1551 assert!(truthy(&json!("0")));
1552 assert!(truthy(&json!("false")));
1553 assert!(truthy(&json!([])));
1554 assert!(truthy(&json!({})));
1555 assert!(truthy(&json!(0.5)));
1556 }
1557
1558 #[test]
1559 fn text_conversion() {
1560 assert_eq!(to_text(&Value::Null), "");
1561 assert_eq!(to_text(&json!(true)), "true");
1562 assert_eq!(to_text(&json!(3.0)), "3");
1563 assert_eq!(to_text(&json!(1.5)), "1.5");
1564 assert_eq!(to_text(&json!(-0.0299)), "-0.0299");
1565 assert_eq!(to_text(&json!(1e20)), "1E+20");
1566 assert_eq!(to_text(&json!(0.0000001)), "1E-07");
1567 assert_eq!(to_text(&json!(123456789012345_i64)), "123456789012345");
1568 assert_eq!(to_text(&json!(["a", 1])), "Array");
1569 assert_eq!(to_text(&json!({ "a": 1 })), "Object");
1570 assert_eq!(to_text(&json!("as-is")), "as-is");
1571 }
1572
1573 #[test]
1574 fn condition_implicit_success() {
1575 assert!(cond(Status::Success, "github.event_name == 'push'"));
1576 assert!(!cond(Status::Failure, "github.event_name == 'push'"));
1577 assert!(!cond(Status::Cancelled, "github.event_name == 'push'"));
1578 assert!(!cond(
1579 Status::Success,
1580 "github.event_name == 'pull_request'"
1581 ));
1582 }
1583
1584 #[test]
1585 fn condition_status_functions() {
1586 assert!(cond(Status::Failure, "always()"));
1587 assert!(cond(Status::Cancelled, "always()"));
1588 assert!(cond(Status::Failure, "failure()"));
1589 assert!(!cond(Status::Success, "failure()"));
1590 assert!(cond(Status::Cancelled, "cancelled()"));
1591 assert!(cond(Status::Success, "success()"));
1592 assert!(cond(
1593 Status::Failure,
1594 "${{ always() && github.ref == 'refs/heads/main' }}"
1595 ));
1596 // Nested inside another call still counts.
1597 assert!(cond(Status::Failure, "contains(toJSON(always()), 'true')"));
1598 }
1599
1600 #[test]
1601 fn condition_status_not_by_substring() {
1602 // 'failure()' inside a string is not a call; implicit success() applies.
1603 assert!(!cond(Status::Failure, "steps.test.outcome != 'failure()'"));
1604 assert!(cond(Status::Success, "steps.test.outcome != 'failure()'"));
1605 }
1606
1607 #[test]
1608 fn condition_wrapped_and_empty() {
1609 assert!(cond(
1610 Status::Success,
1611 "${{ github.ref == 'refs/heads/main' }}"
1612 ));
1613 assert!(!cond(
1614 Status::Success,
1615 " ${{ github.ref == 'refs/heads/dev' }} "
1616 ));
1617 assert!(cond(Status::Success, ""));
1618 assert!(!cond(Status::Failure, ""));
1619 assert!(cond(Status::Success, "${{ }}"));
1620 assert!(cond(Status::Success, "${{ matrix.os }}"));
1621 assert!(!cond(Status::Success, "${{ env.EMPTY }}"));
1622 assert!(cond(
1623 Status::Success,
1624 "vars.DEPLOY == 'yes' && !github.event.pull_request.draft"
1625 ));
1626 }
1627
1628 #[test]
1629 fn condition_with_text_around_is_a_string() {
1630 // On GitHub this is always true: it's the string "false && x", not an expression.
1631 assert!(cond(Status::Success, "${{ false }} && x"));
1632 }
1633
1634 #[test]
1635 fn interpolate_mixed_text() {
1636 let out = with(Status::Success, |s| {
1637 interpolate(
1638 "node-${{ matrix.node }}-${{ runner.os }}-${{ hashFiles('**/yarn.lock') }}",
1639 s,
1640 )
1641 })
1642 .unwrap();
1643 assert_eq!(out, "node-18-Linux-hash(**/yarn.lock)");
1644 let out = with(Status::Success, |s| {
1645 interpolate("echo \"PR #${{ github.event.pull_request.number }}: ${{ github.event.pull_request.title }}\"", s)
1646 })
1647 .unwrap();
1648 assert_eq!(out, "echo \"PR #42: Fix the thing\"");
1649 }
1650
1651 #[test]
1652 fn interpolate_edge_cases() {
1653 assert_eq!(
1654 with(Status::Success, |s| interpolate("plain $text {{ x }}", s)).unwrap(),
1655 "plain $text {{ x }}"
1656 );
1657 assert_eq!(
1658 with(Status::Success, |s| interpolate("${{ '}}' }}!", s)).unwrap(),
1659 "}}!"
1660 );
1661 assert_eq!(
1662 with(Status::Success, |s| interpolate("[${{ env.MISSING }}]", s)).unwrap(),
1663 "[]"
1664 );
1665 assert_eq!(
1666 with(Status::Success, |s| interpolate("${{ 1.50 }}", s)).unwrap(),
1667 "1.5"
1668 );
1669 assert!(
1670 with(Status::Success, |s| interpolate("oops ${{ github.ref", s))
1671 .unwrap_err()
1672 .contains("not closed")
1673 );
1674 assert!(with(Status::Success, |s| interpolate("${{ \"x\" }}", s)).is_err());
1675 }
1676
1677 #[test]
1678 fn interpolate_value_keeps_types() {
1679 let input = json!({
1680 "matrix": "${{ fromJSON(needs.setup.outputs.matrix) }}",
1681 "continue-on-error": "${{ matrix.experimental }}",
1682 "timeout-minutes": "${{ inputs.count }}",
1683 "name": "Build ${{ matrix.os }}",
1684 "env": { "VERSION_${{ matrix.node }}": "${{ steps.build.outputs.version }}" },
1685 "list": ["${{ github.event.pull_request.labels.*.name }}", 7, null],
1686 "plain": true
1687 });
1688 let out = with(Status::Success, |s| interpolate_value(&input, s)).unwrap();
1689 assert_eq!(
1690 out,
1691 json!({
1692 "matrix": { "os": ["ubuntu-latest", "windows-latest"], "node": [18, 20] },
1693 "continue-on-error": false,
1694 "timeout-minutes": 3,
1695 "name": "Build ubuntu-latest",
1696 "env": { "VERSION_18": "1.2.3" },
1697 "list": [["bug", "Enhancement"], 7, null],
1698 "plain": true
1699 })
1700 );
1701 }
1702
1703 #[test]
1704 fn has_expression_detects() {
1705 assert!(has_expression("a ${{ b }}"));
1706 assert!(!has_expression("a ${ b }"));
1707 assert!(!has_expression("{{ b }}"));
1708 }
1709
1710 #[test]
1711 fn dependabot_and_draft_guards() {
1712 assert!(!cond(Status::Success, "github.actor != 'dependabot[bot]'"));
1713 assert!(cond(
1714 Status::Success,
1715 "github.event.pull_request.draft == false"
1716 ));
1717 assert!(cond(
1718 Status::Success,
1719 "!contains(github.event.head_commit.message, '[skip deploy]')"
1720 ));
1721 }
1722
1723 #[test]
1724 fn tag_release_condition() {
1725 let contexts = {
1726 let mut c = contexts();
1727 c["github"]["ref"] = json!("refs/tags/v1.4.0");
1728 c["github"]["event_name"] = json!("push");
1729 c
1730 };
1731 let scope = Scope {
1732 contexts: &contexts,
1733 status: Status::Success,
1734 hash_files: None,
1735 };
1736 assert!(
1737 condition(
1738 "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')",
1739 &scope
1740 )
1741 .unwrap()
1742 );
1743 assert_eq!(
1744 interpolate("${{ format('release-{0}', github.ref_name) }}", &scope).unwrap(),
1745 "release-main"
1746 );
1747 }
1748
1749 #[test]
1750 fn needs_result_and_number_format() {
1751 assert!(cond(Status::Success, "needs.setup.result == 'success'"));
1752 assert_eq!(ev("format('{0}', 0.1)"), json!("0.1"));
1753 assert_eq!(ev("format('{0}', 100)"), json!("100"));
1754 assert_eq!(ev("format('{0}|{1}', null, true)"), json!("|true"));
1755 }
1756}