1#![forbid(unsafe_code)]
4#![deny(missing_docs)]
5
6use std::borrow::Cow;
7
8use crate::context::Context;
9
10use self::parser::{ExprParser, Rule};
11use anyhow::Result;
12use itertools::Itertools;
13use pest::{Parser, iterators::Pair};
14
15pub mod context;
16
17mod parser {
21 use pest_derive::Parser;
22
23 #[derive(Parser)]
25 #[grammar = "expr.pest"]
26 pub struct ExprParser;
27}
28
29#[derive(Debug)]
33pub struct Function<'src>(pub(crate) &'src str);
34
35impl PartialEq for Function<'_> {
36 fn eq(&self, other: &Self) -> bool {
37 self.0.eq_ignore_ascii_case(other.0)
38 }
39}
40impl PartialEq<str> for Function<'_> {
41 fn eq(&self, other: &str) -> bool {
42 self.0.eq_ignore_ascii_case(other)
43 }
44}
45
46#[derive(Debug)]
51pub struct Identifier<'src>(&'src str);
52
53impl Identifier<'_> {
54 pub fn as_str(&self) -> &str {
60 self.0
61 }
62}
63
64impl PartialEq for Identifier<'_> {
65 fn eq(&self, other: &Self) -> bool {
66 self.0.eq_ignore_ascii_case(other.0)
67 }
68}
69
70impl PartialEq<str> for Identifier<'_> {
71 fn eq(&self, other: &str) -> bool {
72 self.0.eq_ignore_ascii_case(other)
73 }
74}
75
76#[derive(Debug, PartialEq)]
78pub enum BinOp {
79 And,
81 Or,
83 Eq,
85 Neq,
87 Gt,
89 Ge,
91 Lt,
93 Le,
95}
96
97#[derive(Debug, PartialEq)]
99pub enum UnOp {
100 Not,
102}
103
104#[derive(Debug, PartialEq)]
106pub enum Literal<'src> {
107 Number(f64),
109 String(Cow<'src, str>),
111 Boolean(bool),
113 Null,
115}
116
117impl<'src> Literal<'src> {
118 pub fn as_str(&self) -> Cow<'src, str> {
125 match self {
126 Literal::String(s) => s.clone(),
127 Literal::Number(n) => Cow::Owned(n.to_string()),
128 Literal::Boolean(b) => Cow::Owned(b.to_string()),
129 Literal::Null => Cow::Borrowed("null"),
130 }
131 }
132}
133
134#[derive(Debug, PartialEq)]
136pub enum Expr<'src> {
137 Literal(Literal<'src>),
139 Star,
141 Call {
143 func: Function<'src>,
145 args: Vec<Expr<'src>>,
147 },
148 Identifier(Identifier<'src>),
150 Index(Box<Expr<'src>>),
152 Context(Context<'src>),
154 BinOp {
156 lhs: Box<Expr<'src>>,
158 op: BinOp,
160 rhs: Box<Expr<'src>>,
162 },
163 UnOp {
165 op: UnOp,
167 expr: Box<Expr<'src>>,
169 },
170}
171
172impl<'src> Expr<'src> {
173 fn ident(i: &'src str) -> Self {
175 Self::Identifier(Identifier(i))
176 }
177
178 fn context(r: &'src str, components: impl Into<Vec<Expr<'src>>>) -> Self {
180 Self::Context(Context::new(r, components))
181 }
182
183 fn is_literal(&self) -> bool {
185 matches!(self, Expr::Literal(_))
186 }
187
188 pub fn constant_reducible(&self) -> bool {
207 match self {
208 Expr::Literal(_) => true,
210 Expr::BinOp { lhs, op: _, rhs } => lhs.constant_reducible() && rhs.constant_reducible(),
212 Expr::UnOp { op: _, expr } => expr.constant_reducible(),
214 Expr::Call { func, args } => {
215 if func == "format"
217 || func == "contains"
218 || func == "startsWith"
219 || func == "endsWith"
220 {
221 args.iter().all(Expr::constant_reducible)
222 } else {
223 false
225 }
226 }
227 _ => false,
229 }
230 }
231
232 pub fn has_constant_reducible_subexpr(&self) -> bool {
240 if !self.is_literal() && self.constant_reducible() {
241 return true;
242 }
243
244 match self {
245 Expr::Call { func: _, args } => args.iter().any(|a| a.has_constant_reducible_subexpr()),
246 Expr::Context(ctx) => {
247 ctx.parts.iter().any(|c| c.has_constant_reducible_subexpr())
250 }
251 Expr::BinOp { lhs, op: _, rhs } => {
252 lhs.has_constant_reducible_subexpr() || rhs.has_constant_reducible_subexpr()
253 }
254 Expr::UnOp { op: _, expr } => expr.has_constant_reducible_subexpr(),
255
256 Expr::Index(expr) => expr.has_constant_reducible_subexpr(),
257 _ => false,
258 }
259 }
260
261 pub fn dataflow_contexts(&self) -> Vec<&Context<'src>> {
270 let mut contexts = vec![];
271
272 match self {
273 Expr::Call { func, args } => {
274 if func == "toJSON" || func == "format" || func == "join" {
278 for arg in args {
279 contexts.extend(arg.dataflow_contexts());
280 }
281 }
282 }
283 Expr::Context(ctx) => contexts.push(ctx),
290 Expr::BinOp { lhs, op, rhs } => match op {
291 BinOp::And => {
294 contexts.extend(rhs.dataflow_contexts());
295 }
296 BinOp::Or => {
298 contexts.extend(lhs.dataflow_contexts());
299 contexts.extend(rhs.dataflow_contexts());
300 }
301 _ => (),
302 },
303 _ => (),
304 }
305
306 contexts
307 }
308
309 pub fn parse(expr: &str) -> Result<Expr> {
311 let or_expr = ExprParser::parse(Rule::expression, expr)?
313 .next()
314 .unwrap()
315 .into_inner()
316 .next()
317 .unwrap();
318
319 fn parse_pair(pair: Pair<'_, Rule>) -> Result<Box<Expr>> {
320 match pair.as_rule() {
332 Rule::or_expr => {
333 let mut pairs = pair.into_inner();
334 let lhs = parse_pair(pairs.next().unwrap())?;
335 pairs.try_fold(lhs, |expr, next| {
336 Ok(Expr::BinOp {
337 lhs: expr,
338 op: BinOp::Or,
339 rhs: parse_pair(next)?,
340 }
341 .into())
342 })
343 }
344 Rule::and_expr => {
345 let mut pairs = pair.into_inner();
346 let lhs = parse_pair(pairs.next().unwrap())?;
347 pairs.try_fold(lhs, |expr, next| {
348 Ok(Expr::BinOp {
349 lhs: expr,
350 op: BinOp::And,
351 rhs: parse_pair(next)?,
352 }
353 .into())
354 })
355 }
356 Rule::eq_expr => {
357 let mut pairs = pair.into_inner();
361 let lhs = parse_pair(pairs.next().unwrap())?;
362
363 let pair_chunks = pairs.chunks(2);
364 pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
365 let eq_op = next.next().unwrap();
366 let comp_expr = next.next().unwrap();
367
368 let eq_op = match eq_op.as_str() {
369 "==" => BinOp::Eq,
370 "!=" => BinOp::Neq,
371 _ => unreachable!(),
372 };
373
374 Ok(Expr::BinOp {
375 lhs: expr,
376 op: eq_op,
377 rhs: parse_pair(comp_expr)?,
378 }
379 .into())
380 })
381 }
382 Rule::comp_expr => {
383 let mut pairs = pair.into_inner();
385 let lhs = parse_pair(pairs.next().unwrap())?;
386
387 let pair_chunks = pairs.chunks(2);
388 pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
389 let comp_op = next.next().unwrap();
390 let unary_expr = next.next().unwrap();
391
392 let eq_op = match comp_op.as_str() {
393 ">" => BinOp::Gt,
394 ">=" => BinOp::Ge,
395 "<" => BinOp::Lt,
396 "<=" => BinOp::Le,
397 _ => unreachable!(),
398 };
399
400 Ok(Expr::BinOp {
401 lhs: expr,
402 op: eq_op,
403 rhs: parse_pair(unary_expr)?,
404 }
405 .into())
406 })
407 }
408 Rule::unary_expr => {
409 let mut pairs = pair.into_inner();
410 let pair = pairs.next().unwrap();
411
412 match pair.as_rule() {
413 Rule::unary_op => Ok(Expr::UnOp {
414 op: UnOp::Not,
415 expr: parse_pair(pairs.next().unwrap())?,
416 }
417 .into()),
418 Rule::primary_expr => parse_pair(pair),
419 _ => unreachable!(),
420 }
421 }
422 Rule::primary_expr => {
423 parse_pair(pair.into_inner().next().unwrap())
425 }
426 Rule::number => Ok(Box::new(pair.as_str().parse::<f64>().unwrap().into())),
427 Rule::string => {
428 let string_inner = pair.into_inner().next().unwrap().as_str();
430
431 if !string_inner.contains('\'') {
434 Ok(Box::new(string_inner.into()))
435 } else {
436 Ok(Box::new(string_inner.replace("''", "'").into()))
437 }
438 }
439 Rule::boolean => Ok(Box::new(pair.as_str().parse::<bool>().unwrap().into())),
440 Rule::null => Ok(Expr::Literal(Literal::Null).into()),
441 Rule::star => Ok(Expr::Star.into()),
442 Rule::function_call => {
443 let mut pairs = pair.into_inner();
444
445 let identifier = pairs.next().unwrap();
446 let args = pairs
447 .map(|pair| parse_pair(pair).map(|e| *e))
448 .collect::<Result<_, _>>()?;
449
450 Ok(Expr::Call {
451 func: Function(identifier.as_str()),
452 args,
453 }
454 .into())
455 }
456 Rule::identifier => Ok(Expr::ident(pair.as_str()).into()),
457 Rule::index => {
458 Ok(Expr::Index(parse_pair(pair.into_inner().next().unwrap())?).into())
459 }
460 Rule::context => {
461 let raw = pair.as_str();
462 let pairs = pair.into_inner();
463
464 let mut inner: Vec<Expr> = pairs
465 .map(|pair| parse_pair(pair).map(|e| *e))
466 .collect::<Result<_, _>>()?;
467
468 if inner.len() == 1 && matches!(inner[0], Expr::Call { .. }) {
472 Ok(inner.remove(0).into())
473 } else {
474 Ok(Expr::context(raw, inner).into())
475 }
476 }
477 r => panic!("unrecognized rule: {r:?}"),
478 }
479 }
480
481 parse_pair(or_expr).map(|e| *e)
482 }
483}
484
485impl<'src> From<&'src str> for Expr<'src> {
486 fn from(s: &'src str) -> Self {
487 Expr::Literal(Literal::String(s.into()))
488 }
489}
490
491impl From<String> for Expr<'_> {
492 fn from(s: String) -> Self {
493 Expr::Literal(Literal::String(s.into()))
494 }
495}
496
497impl From<f64> for Expr<'_> {
498 fn from(n: f64) -> Self {
499 Expr::Literal(Literal::Number(n))
500 }
501}
502
503impl From<bool> for Expr<'_> {
504 fn from(b: bool) -> Self {
505 Expr::Literal(Literal::Boolean(b))
506 }
507}
508
509#[cfg(test)]
510mod tests {
511 use std::borrow::Cow;
512
513 use anyhow::Result;
514 use pest::Parser as _;
515 use pretty_assertions::assert_eq;
516
517 use crate::Literal;
518
519 use super::{BinOp, Expr, ExprParser, Function, Rule, UnOp};
520
521 #[test]
522 fn test_literal_string_borrows() {
523 let cases = &[
524 ("'foo'", true),
525 ("'foo bar'", true),
526 ("'foo '' bar'", false),
527 ("'foo''bar'", false),
528 ("'foo''''bar'", false),
529 ];
530
531 for (expr, borrows) in cases {
532 let Expr::Literal(Literal::String(s)) = Expr::parse(expr).unwrap() else {
533 panic!("expected a literal string expression for {expr}");
534 };
535
536 assert!(matches!(
537 (s, borrows),
538 (Cow::Borrowed(_), true) | (Cow::Owned(_), false)
539 ));
540 }
541 }
542
543 #[test]
544 fn test_literal_as_str() {
545 let cases = &[
546 ("'foo'", "foo"),
547 ("'foo '' bar'", "foo ' bar"),
548 ("123", "123"),
549 ("123.000", "123"),
550 ("0.0", "0"),
551 ("0.1", "0.1"),
552 ("0.12345", "0.12345"),
553 ("true", "true"),
554 ("false", "false"),
555 ("null", "null"),
556 ];
557
558 for (expr, expected) in cases {
559 let Expr::Literal(expr) = Expr::parse(expr).unwrap() else {
560 panic!("expected a literal expression for {expr}");
561 };
562
563 assert_eq!(expr.as_str(), *expected);
564 }
565 }
566
567 #[test]
568 fn test_function_eq() {
569 let func = Function("foo");
570 assert_eq!(&func, "foo");
571 assert_eq!(&func, "FOO");
572 assert_eq!(&func, "Foo");
573
574 assert_eq!(func, Function("FOO"));
575 }
576
577 #[test]
578 fn test_parse_string_rule() {
579 let cases = &[
580 ("''", ""),
581 ("' '", " "),
582 ("''''", "''"),
583 ("'test'", "test"),
584 ("'spaces are ok'", "spaces are ok"),
585 ("'escaping '' works'", "escaping '' works"),
586 ];
587
588 for (case, expected) in cases {
589 let s = ExprParser::parse(Rule::string, case)
590 .unwrap()
591 .next()
592 .unwrap();
593
594 assert_eq!(s.into_inner().next().unwrap().as_str(), *expected);
595 }
596 }
597
598 #[test]
599 fn test_parse_context_rule() {
600 let cases = &[
601 "foo.bar",
602 "github.action_path",
603 "inputs.foo-bar",
604 "inputs.also--valid",
605 "inputs.this__too",
606 "inputs.this__too",
607 "secrets.GH_TOKEN",
608 "foo.*.bar",
609 "github.event.issue.labels.*.name",
610 ];
611
612 for case in cases {
613 assert_eq!(
614 ExprParser::parse(Rule::context, case)
615 .unwrap()
616 .next()
617 .unwrap()
618 .as_str(),
619 *case
620 );
621 }
622 }
623
624 #[test]
625 fn test_parse_call_rule() {
626 let cases = &[
627 "foo()",
628 "foo(bar)",
629 "foo(bar())",
630 "foo(1.23)",
631 "foo(1,2)",
632 "foo(1, 2)",
633 "foo(1, 2, secret.GH_TOKEN)",
634 "foo( )",
635 "fromJSON(inputs.free-threading)",
636 ];
637
638 for case in cases {
639 assert_eq!(
640 ExprParser::parse(Rule::function_call, case)
641 .unwrap()
642 .next()
643 .unwrap()
644 .as_str(),
645 *case
646 );
647 }
648 }
649
650 #[test]
651 fn test_parse_expr_rule() -> Result<()> {
652 let multiline = "github.repository_owner == 'Homebrew' &&
654 ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
655 (github.event_name == 'pull_request_target' &&
656 (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
657
658 let cases = &[
659 "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
660 "foo || bar || baz",
661 "foo || bar && baz || foo && 1 && 2 && 3 || 4",
662 "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
663 "(true || false) == true",
664 "!(!true || false)",
665 "!(!true || false) == true",
666 "(true == false) == true",
667 "(true == (false || true && (true || false))) == true",
668 "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
669 "foo()[0]",
670 "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
671 multiline,
672 "'a' == 'b' && 'c' || 'd'",
673 "github.event['a']",
674 "github.event['a' == 'b']",
675 "github.event['a' == 'b' && 'c' || 'd']",
676 "github['event']['inputs']['dry-run']",
677 "github[format('{0}', 'event')]",
678 "github['event']['inputs'][github.event.inputs.magic]",
679 "github['event']['inputs'].*",
680 ];
681
682 for case in cases {
683 assert_eq!(
684 ExprParser::parse(Rule::expression, case)?
685 .next()
686 .unwrap()
687 .as_str(),
688 *case
689 );
690 }
691
692 Ok(())
693 }
694
695 #[test]
696 fn test_parse() {
697 let cases = &[
698 (
699 "!true || false || true",
700 Expr::BinOp {
701 lhs: Expr::BinOp {
702 lhs: Expr::UnOp {
703 op: UnOp::Not,
704 expr: Box::new(true.into()),
705 }
706 .into(),
707 op: BinOp::Or,
708 rhs: Box::new(false.into()),
709 }
710 .into(),
711 op: BinOp::Or,
712 rhs: Box::new(true.into()),
713 },
714 ),
715 ("'foo '' bar'", "foo ' bar".into()),
716 ("('foo '' bar')", "foo ' bar".into()),
717 ("((('foo '' bar')))", "foo ' bar".into()),
718 (
719 "foo(1, 2, 3)",
720 Expr::Call {
721 func: Function("foo"),
722 args: vec![1.0.into(), 2.0.into(), 3.0.into()],
723 },
724 ),
725 (
726 "foo.bar.baz",
727 Expr::context(
728 "foo.bar.baz",
729 [Expr::ident("foo"), Expr::ident("bar"), Expr::ident("baz")],
730 ),
731 ),
732 (
733 "foo.bar.baz[1][2]",
734 Expr::context(
735 "foo.bar.baz[1][2]",
736 [
737 Expr::ident("foo"),
738 Expr::ident("bar"),
739 Expr::ident("baz"),
740 Expr::Index(Box::new(1.0.into())),
741 Expr::Index(Box::new(2.0.into())),
742 ],
743 ),
744 ),
745 (
746 "foo.bar.baz[*]",
747 Expr::context(
748 "foo.bar.baz[*]",
749 [
750 Expr::ident("foo"),
751 Expr::ident("bar"),
752 Expr::ident("baz"),
753 Expr::Index(Expr::Star.into()),
754 ],
755 ),
756 ),
757 (
758 "vegetables.*.ediblePortions",
759 Expr::context(
760 "vegetables.*.ediblePortions",
761 vec![
762 Expr::ident("vegetables"),
763 Expr::Star,
764 Expr::ident("ediblePortions"),
765 ],
766 ),
767 ),
768 (
769 "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
772 Expr::BinOp {
773 lhs: Expr::BinOp {
774 lhs: Expr::BinOp {
775 lhs: Expr::context(
776 "github.ref",
777 [Expr::ident("github"), Expr::ident("ref")],
778 )
779 .into(),
780 op: BinOp::Eq,
781 rhs: Box::new("refs/heads/main".into()),
782 }
783 .into(),
784 op: BinOp::And,
785 rhs: Box::new("value_for_main_branch".into()),
786 }
787 .into(),
788 op: BinOp::Or,
789 rhs: Box::new("value_for_other_branches".into()),
790 },
791 ),
792 (
793 "(true || false) == true",
794 Expr::BinOp {
795 lhs: Expr::BinOp {
796 lhs: Box::new(true.into()),
797 op: BinOp::Or,
798 rhs: Box::new(false.into()),
799 }
800 .into(),
801 op: BinOp::Eq,
802 rhs: Box::new(true.into()),
803 },
804 ),
805 (
806 "!(!true || false)",
807 Expr::UnOp {
808 op: UnOp::Not,
809 expr: Expr::BinOp {
810 lhs: Expr::UnOp {
811 op: UnOp::Not,
812 expr: Box::new(true.into()),
813 }
814 .into(),
815 op: BinOp::Or,
816 rhs: Box::new(false.into()),
817 }
818 .into(),
819 },
820 ),
821 (
822 "foobar[format('{0}', 'event')]",
823 Expr::context(
824 "foobar[format('{0}', 'event')]",
825 [
826 Expr::ident("foobar"),
827 Expr::Index(
828 Expr::Call {
829 func: Function("format"),
830 args: vec!["{0}".into(), "event".into()],
831 }
832 .into(),
833 ),
834 ],
835 ),
836 ),
837 (
838 "github.actor_id == '49699333'",
839 Expr::BinOp {
840 lhs: Expr::context(
841 "github.actor_id",
842 [Expr::ident("github"), Expr::ident("actor_id")],
843 )
844 .into(),
845 op: BinOp::Eq,
846 rhs: Box::new("49699333".into()),
847 },
848 ),
849 ];
850
851 for (case, expr) in cases {
852 assert_eq!(Expr::parse(case).unwrap(), *expr);
853 }
854 }
855
856 #[test]
857 fn test_expr_constant_reducible() -> Result<()> {
858 for (expr, reducible) in &[
859 ("'foo'", true),
860 ("1", true),
861 ("true", true),
862 ("null", true),
863 ("!true", true),
866 ("!null", true),
867 ("true && false", true),
868 ("true || false", true),
869 ("null && !null && true", true),
870 ("format('{0} {1}', 'foo', 'bar')", true),
873 ("format('{0} {1}', 1, 2)", true),
874 ("format('{0} {1}', 1, '2')", true),
875 ("contains('foo', 'bar')", true),
876 ("startsWith('foo', 'bar')", true),
877 ("endsWith('foo', 'bar')", true),
878 ("startsWith(some.context, 'bar')", false),
879 ("endsWith(some.context, 'bar')", false),
880 ("format('{0} {1}', '1', format('{0}', null))", true),
882 ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
883 ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
884 ("foo", false),
885 ("foo.bar", false),
886 ("foo.bar[1]", false),
887 ("foo.bar == 'bar'", false),
888 ("foo.bar || bar || baz", false),
889 ("foo.bar && bar && baz", false),
890 ] {
891 let expr = Expr::parse(expr)?;
892 assert_eq!(expr.constant_reducible(), *reducible);
893 }
894
895 Ok(())
896 }
897
898 #[test]
899 fn test_expr_has_constant_reducible_subexpr() -> Result<()> {
900 for (expr, reducible) in &[
901 ("'foo'", false),
903 ("1", false),
904 ("true", false),
905 ("null", false),
906 (
908 "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
909 true,
910 ),
911 ("foobar[format('{0}', 'event')]", true),
912 ] {
913 let expr = Expr::parse(expr)?;
914 assert_eq!(expr.has_constant_reducible_subexpr(), *reducible);
915 }
916 Ok(())
917 }
918
919 #[test]
920 fn test_expr_dataflow_contexts() -> Result<()> {
921 let expr = Expr::parse("foo.bar")?;
923 assert_eq!(expr.dataflow_contexts(), ["foo.bar"]);
924
925 let expr = Expr::parse("foo.bar[1]")?;
926 assert_eq!(expr.dataflow_contexts(), ["foo.bar[1]"]);
927
928 let expr = Expr::parse("foo.bar == 'bar'")?;
930 assert!(expr.dataflow_contexts().is_empty());
931
932 let expr = Expr::parse("foo.bar || abc || d.e.f")?;
934 assert_eq!(expr.dataflow_contexts(), ["foo.bar", "abc", "d.e.f"]);
935
936 let expr = Expr::parse("foo.bar && abc && d.e.f")?;
938 assert_eq!(expr.dataflow_contexts(), ["d.e.f"]);
939
940 let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
941 assert_eq!(expr.dataflow_contexts(), ["foo.bar"]);
942
943 let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
944 assert_eq!(expr.dataflow_contexts(), ["foo.bar", "foo.baz"]);
945
946 let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
947 assert_eq!(
948 expr.dataflow_contexts(),
949 ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
950 );
951
952 let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
953 assert_eq!(expr.dataflow_contexts(), ["foo.bar", "github", "github"]);
954
955 Ok(())
956 }
957}