1#![forbid(unsafe_code)]
4
5use anyhow::{Result, anyhow};
10use itertools::Itertools;
11use pest::{Parser, iterators::Pair};
12use pest_derive::Parser;
13
14#[derive(Parser)]
16#[grammar = "expr.pest"]
17struct ExprParser;
18
19#[derive(Debug)]
26pub struct Context<'src> {
27 raw: &'src str,
28 pub components: Vec<Expr<'src>>,
30}
31
32impl<'src> Context<'src> {
33 fn new(raw: &'src str, components: impl Into<Vec<Expr<'src>>>) -> Self {
35 Self {
36 raw,
37 components: components.into(),
38 }
39 }
40
41 pub fn as_str(&self) -> &str {
43 self.raw
44 }
45
46 pub fn child_of(&self, parent: impl TryInto<Context<'src>>) -> bool {
51 let Ok(parent) = parent.try_into() else {
52 return false;
53 };
54
55 let mut parent_components = parent.components.iter().peekable();
56 let mut child_components = self.components.iter().peekable();
57
58 while let (Some(parent), Some(child)) = (parent_components.peek(), child_components.peek())
59 {
60 match (parent, child) {
61 (Expr::Identifier(parent), Expr::Identifier(child)) => {
62 if parent != child {
63 return false;
64 }
65 }
66 _ => return false,
67 }
68
69 parent_components.next();
70 child_components.next();
71 }
72
73 parent_components.next().is_none()
75 }
76
77 pub fn pop_if(&self, head: &str) -> Option<&str> {
79 match self.components.first()? {
80 Expr::Identifier(ident) if ident == head => Some(self.raw.split_once('.')?.1),
81 _ => None,
82 }
83 }
84}
85
86impl<'a> TryFrom<&'a str> for Context<'a> {
87 type Error = anyhow::Error;
88
89 fn try_from(val: &'a str) -> Result<Self> {
90 let expr = Expr::parse(val)?;
91
92 match expr {
93 Expr::Context(ctx) => Ok(ctx),
94 _ => Err(anyhow!("expected context, found {:?}", expr)),
95 }
96 }
97}
98
99impl PartialEq for Context<'_> {
100 fn eq(&self, other: &Self) -> bool {
101 self.raw.eq_ignore_ascii_case(other.raw)
102 }
103}
104
105impl PartialEq<str> for Context<'_> {
106 fn eq(&self, other: &str) -> bool {
107 self.raw.eq_ignore_ascii_case(other)
108 }
109}
110
111#[derive(Debug)]
115pub struct Function<'src>(&'src str);
116
117impl PartialEq for Function<'_> {
118 fn eq(&self, other: &Self) -> bool {
119 self.0.eq_ignore_ascii_case(other.0)
120 }
121}
122impl PartialEq<str> for Function<'_> {
123 fn eq(&self, other: &str) -> bool {
124 self.0.eq_ignore_ascii_case(other)
125 }
126}
127
128#[derive(Debug)]
133pub struct Identifier<'src>(&'src str);
134
135impl PartialEq for Identifier<'_> {
136 fn eq(&self, other: &Self) -> bool {
137 self.0.eq_ignore_ascii_case(other.0)
138 }
139}
140
141impl PartialEq<str> for Identifier<'_> {
142 fn eq(&self, other: &str) -> bool {
143 self.0.eq_ignore_ascii_case(other)
144 }
145}
146
147#[derive(Debug, PartialEq)]
149pub enum BinOp {
150 And,
152 Or,
154 Eq,
156 Neq,
158 Gt,
160 Ge,
162 Lt,
164 Le,
166}
167
168#[derive(Debug, PartialEq)]
170pub enum UnOp {
171 Not,
172}
173
174#[derive(Debug, PartialEq)]
176pub enum Expr<'src> {
177 Number(f64),
179 String(String),
181 Boolean(bool),
183 Null,
185 Star,
187 Call {
189 func: Function<'src>,
190 args: Vec<Expr<'src>>,
191 },
192 Identifier(Identifier<'src>),
194 Index(Box<Expr<'src>>),
196 Context(Context<'src>),
198 BinOp {
200 lhs: Box<Expr<'src>>,
201 op: BinOp,
202 rhs: Box<Expr<'src>>,
203 },
204 UnOp { op: UnOp, expr: Box<Expr<'src>> },
206}
207
208impl<'src> Expr<'src> {
209 fn string(s: impl Into<String>) -> Box<Self> {
211 Self::String(s.into()).into()
212 }
213
214 fn ident(i: &'src str) -> Self {
216 Self::Identifier(Identifier(i))
217 }
218
219 fn context(r: &'src str, components: impl Into<Vec<Expr<'src>>>) -> Self {
221 Self::Context(Context::new(r, components))
222 }
223
224 fn is_literal(&self) -> bool {
226 matches!(
227 self,
228 Expr::Number(_) | Expr::String(_) | Expr::Boolean(_) | Expr::Null
229 )
230 }
231
232 pub fn constant_reducible(&self) -> bool {
251 match self {
252 Expr::Number(_) | Expr::String(_) | Expr::Boolean(_) | Expr::Null => true,
254 Expr::BinOp { lhs, op: _, rhs } => lhs.constant_reducible() && rhs.constant_reducible(),
256 Expr::UnOp { op: _, expr } => expr.constant_reducible(),
258 Expr::Call { func, args } => {
259 if func == "format"
261 || func == "contains"
262 || func == "startsWith"
263 || func == "endsWith"
264 {
265 args.iter().all(Expr::constant_reducible)
266 } else {
267 false
269 }
270 }
271 _ => false,
273 }
274 }
275
276 pub fn has_constant_reducible_subexpr(&self) -> bool {
284 if !self.is_literal() && self.constant_reducible() {
285 return true;
286 }
287
288 match self {
289 Expr::Call { func: _, args } => args.iter().any(|a| a.has_constant_reducible_subexpr()),
290 Expr::Context(ctx) => {
291 ctx.components
294 .iter()
295 .any(|c| c.has_constant_reducible_subexpr())
296 }
297 Expr::BinOp { lhs, op: _, rhs } => {
298 lhs.has_constant_reducible_subexpr() || rhs.has_constant_reducible_subexpr()
299 }
300 Expr::UnOp { op: _, expr } => expr.has_constant_reducible_subexpr(),
301
302 Expr::Index(expr) => expr.has_constant_reducible_subexpr(),
303 _ => false,
304 }
305 }
306
307 pub fn dataflow_contexts(&self) -> Vec<&Context> {
316 let mut contexts = vec![];
317
318 match self {
319 Expr::Call { func, args } => {
320 if func == "toJSON" || func == "format" || func == "join" {
324 for arg in args {
325 contexts.extend(arg.dataflow_contexts());
326 }
327 }
328 }
329 Expr::Context(ctx) => contexts.push(ctx),
336 Expr::BinOp { lhs, op, rhs } => match op {
337 BinOp::And => {
340 contexts.extend(rhs.dataflow_contexts());
341 }
342 BinOp::Or => {
344 contexts.extend(lhs.dataflow_contexts());
345 contexts.extend(rhs.dataflow_contexts());
346 }
347 _ => (),
348 },
349 _ => (),
350 }
351
352 contexts
353 }
354
355 pub fn parse(expr: &str) -> Result<Expr> {
357 let or_expr = ExprParser::parse(Rule::expression, expr)?
359 .next()
360 .unwrap()
361 .into_inner()
362 .next()
363 .unwrap();
364
365 fn parse_pair(pair: Pair<'_, Rule>) -> Result<Box<Expr>> {
366 match pair.as_rule() {
378 Rule::or_expr => {
379 let mut pairs = pair.into_inner();
380 let lhs = parse_pair(pairs.next().unwrap())?;
381 pairs.try_fold(lhs, |expr, next| {
382 Ok(Expr::BinOp {
383 lhs: expr,
384 op: BinOp::Or,
385 rhs: parse_pair(next)?,
386 }
387 .into())
388 })
389 }
390 Rule::and_expr => {
391 let mut pairs = pair.into_inner();
392 let lhs = parse_pair(pairs.next().unwrap())?;
393 pairs.try_fold(lhs, |expr, next| {
394 Ok(Expr::BinOp {
395 lhs: expr,
396 op: BinOp::And,
397 rhs: parse_pair(next)?,
398 }
399 .into())
400 })
401 }
402 Rule::eq_expr => {
403 let mut pairs = pair.into_inner();
407 let lhs = parse_pair(pairs.next().unwrap())?;
408
409 let pair_chunks = pairs.chunks(2);
410 pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
411 let eq_op = next.next().unwrap();
412 let comp_expr = next.next().unwrap();
413
414 let eq_op = match eq_op.as_str() {
415 "==" => BinOp::Eq,
416 "!=" => BinOp::Neq,
417 _ => unreachable!(),
418 };
419
420 Ok(Expr::BinOp {
421 lhs: expr,
422 op: eq_op,
423 rhs: parse_pair(comp_expr)?,
424 }
425 .into())
426 })
427 }
428 Rule::comp_expr => {
429 let mut pairs = pair.into_inner();
431 let lhs = parse_pair(pairs.next().unwrap())?;
432
433 let pair_chunks = pairs.chunks(2);
434 pair_chunks.into_iter().try_fold(lhs, |expr, mut next| {
435 let comp_op = next.next().unwrap();
436 let unary_expr = next.next().unwrap();
437
438 let eq_op = match comp_op.as_str() {
439 ">" => BinOp::Gt,
440 ">=" => BinOp::Ge,
441 "<" => BinOp::Lt,
442 "<=" => BinOp::Le,
443 _ => unreachable!(),
444 };
445
446 Ok(Expr::BinOp {
447 lhs: expr,
448 op: eq_op,
449 rhs: parse_pair(unary_expr)?,
450 }
451 .into())
452 })
453 }
454 Rule::unary_expr => {
455 let mut pairs = pair.into_inner();
456 let pair = pairs.next().unwrap();
457
458 match pair.as_rule() {
459 Rule::unary_op => Ok(Expr::UnOp {
460 op: UnOp::Not,
461 expr: parse_pair(pairs.next().unwrap())?,
462 }
463 .into()),
464 Rule::primary_expr => parse_pair(pair),
465 _ => unreachable!(),
466 }
467 }
468 Rule::primary_expr => {
469 parse_pair(pair.into_inner().next().unwrap())
471 }
472 Rule::number => Ok(Expr::Number(pair.as_str().parse().unwrap()).into()),
473 Rule::string => Ok(Expr::string(
474 pair.into_inner()
476 .next()
477 .unwrap()
478 .as_str()
479 .replace("''", "'"),
480 )),
481 Rule::boolean => Ok(Expr::Boolean(pair.as_str().parse().unwrap()).into()),
482 Rule::null => Ok(Expr::Null.into()),
483 Rule::star => Ok(Expr::Star.into()),
484 Rule::function_call => {
485 let mut pairs = pair.into_inner();
486
487 let identifier = pairs.next().unwrap();
488 let args = pairs
489 .map(|pair| parse_pair(pair).map(|e| *e))
490 .collect::<Result<_, _>>()?;
491
492 Ok(Expr::Call {
493 func: Function(identifier.as_str()),
494 args,
495 }
496 .into())
497 }
498 Rule::identifier => Ok(Expr::ident(pair.as_str()).into()),
499 Rule::index => {
500 Ok(Expr::Index(parse_pair(pair.into_inner().next().unwrap())?).into())
501 }
502 Rule::context => {
503 let raw = pair.as_str();
504 let pairs = pair.into_inner();
505
506 let mut inner: Vec<Expr> = pairs
507 .map(|pair| parse_pair(pair).map(|e| *e))
508 .collect::<Result<_, _>>()?;
509
510 if inner.len() == 1 && matches!(inner[0], Expr::Call { .. }) {
514 Ok(inner.remove(0).into())
515 } else {
516 Ok(Expr::context(raw, inner).into())
517 }
518 }
519 r => panic!("unrecognized rule: {r:?}"),
520 }
521 }
522
523 parse_pair(or_expr).map(|e| *e)
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use anyhow::Result;
530 use pest::Parser as _;
531 use pretty_assertions::assert_eq;
532
533 use super::{BinOp, Context, Expr, ExprParser, Function, Rule, UnOp};
534
535 #[test]
536 fn test_function_eq() {
537 let func = Function("foo");
538 assert_eq!(&func, "foo");
539 assert_eq!(&func, "FOO");
540 assert_eq!(&func, "Foo");
541
542 assert_eq!(func, Function("FOO"));
543 }
544
545 #[test]
546 fn test_context_eq() {
547 let ctx = Context::try_from("foo.bar.baz").unwrap();
548 assert_eq!(&ctx, "foo.bar.baz");
549 assert_eq!(&ctx, "FOO.BAR.BAZ");
550 assert_eq!(&ctx, "Foo.Bar.Baz");
551 }
552
553 #[test]
554 fn test_context_child_of() {
555 let ctx = Context::try_from("foo.bar.baz").unwrap();
556
557 for (case, child) in &[
558 ("foo", true),
560 ("foo.bar", true),
561 ("FOO", true),
563 ("FOO.BAR", true),
564 ("Foo", true),
565 ("Foo.Bar", true),
566 ("foo.bar.baz", true),
568 ("foo.bar.baz.qux", false),
570 ("foo.bar.qux", false),
571 ("foo.qux", false),
572 ("qux", false),
573 ("foo.", false),
575 (".", false),
576 ("", false),
577 ] {
578 assert_eq!(ctx.child_of(*case), *child);
579 }
580 }
581
582 #[test]
583 fn test_context_pop_if() {
584 let ctx = Context::try_from("foo.bar.baz").unwrap();
585
586 for (case, expected) in &[
587 ("foo", Some("bar.baz")),
588 ("Foo", Some("bar.baz")),
589 ("FOO", Some("bar.baz")),
590 ("foo.", None),
591 ("bar", None),
592 ] {
593 assert_eq!(ctx.pop_if(case), *expected);
594 }
595 }
596
597 #[test]
598 fn test_parse_string_rule() {
599 let cases = &[
600 ("''", ""),
601 ("' '", " "),
602 ("''''", "''"),
603 ("'test'", "test"),
604 ("'spaces are ok'", "spaces are ok"),
605 ("'escaping '' works'", "escaping '' works"),
606 ];
607
608 for (case, expected) in cases {
609 let s = ExprParser::parse(Rule::string, case)
610 .unwrap()
611 .next()
612 .unwrap();
613
614 assert_eq!(s.into_inner().next().unwrap().as_str(), *expected);
615 }
616 }
617
618 #[test]
619 fn test_parse_context_rule() {
620 let cases = &[
621 "foo.bar",
622 "github.action_path",
623 "inputs.foo-bar",
624 "inputs.also--valid",
625 "inputs.this__too",
626 "inputs.this__too",
627 "secrets.GH_TOKEN",
628 "foo.*.bar",
629 "github.event.issue.labels.*.name",
630 ];
631
632 for case in cases {
633 assert_eq!(
634 ExprParser::parse(Rule::context, case)
635 .unwrap()
636 .next()
637 .unwrap()
638 .as_str(),
639 *case
640 );
641 }
642 }
643
644 #[test]
645 fn test_parse_call_rule() {
646 let cases = &[
647 "foo()",
648 "foo(bar)",
649 "foo(bar())",
650 "foo(1.23)",
651 "foo(1,2)",
652 "foo(1, 2)",
653 "foo(1, 2, secret.GH_TOKEN)",
654 "foo( )",
655 "fromJSON(inputs.free-threading)",
656 ];
657
658 for case in cases {
659 assert_eq!(
660 ExprParser::parse(Rule::function_call, case)
661 .unwrap()
662 .next()
663 .unwrap()
664 .as_str(),
665 *case
666 );
667 }
668 }
669
670 #[test]
671 fn test_parse_expr_rule() -> Result<()> {
672 let multiline = "github.repository_owner == 'Homebrew' &&
674 ((github.event_name == 'pull_request_review' && github.event.review.state == 'approved') ||
675 (github.event_name == 'pull_request_target' &&
676 (github.event.action == 'ready_for_review' || github.event.label.name == 'automerge-skip')))";
677
678 let cases = &[
679 "fromJSON(inputs.free-threading) && '--disable-gil' || ''",
680 "foo || bar || baz",
681 "foo || bar && baz || foo && 1 && 2 && 3 || 4",
682 "(github.actor != 'github-actions[bot]' && github.actor) || 'BrewTestBot'",
683 "(true || false) == true",
684 "!(!true || false)",
685 "!(!true || false) == true",
686 "(true == false) == true",
687 "(true == (false || true && (true || false))) == true",
688 "(github.actor != 'github-actions[bot]' && github.actor) == 'BrewTestBot'",
689 "foo()[0]",
690 "fromJson(steps.runs.outputs.data).workflow_runs[0].id",
691 multiline,
692 "'a' == 'b' && 'c' || 'd'",
693 "github.event['a']",
694 "github.event['a' == 'b']",
695 "github.event['a' == 'b' && 'c' || 'd']",
696 "github['event']['inputs']['dry-run']",
697 "github[format('{0}', 'event')]",
698 "github['event']['inputs'][github.event.inputs.magic]",
699 "github['event']['inputs'].*",
700 ];
701
702 for case in cases {
703 assert_eq!(
704 ExprParser::parse(Rule::expression, case)?
705 .next()
706 .unwrap()
707 .as_str(),
708 *case
709 );
710 }
711
712 Ok(())
713 }
714
715 #[test]
716 fn test_parse() {
717 let cases = &[
718 (
719 "!true || false || true",
720 Expr::BinOp {
721 lhs: Expr::BinOp {
722 lhs: Expr::UnOp {
723 op: UnOp::Not,
724 expr: Expr::Boolean(true).into(),
725 }
726 .into(),
727 op: BinOp::Or,
728 rhs: Expr::Boolean(false).into(),
729 }
730 .into(),
731 op: BinOp::Or,
732 rhs: Expr::Boolean(true).into(),
733 },
734 ),
735 ("'foo '' bar'", *Expr::string("foo ' bar")),
736 ("('foo '' bar')", *Expr::string("foo ' bar")),
737 ("((('foo '' bar')))", *Expr::string("foo ' bar")),
738 (
739 "foo(1, 2, 3)",
740 Expr::Call {
741 func: Function("foo"),
742 args: vec![Expr::Number(1.0), Expr::Number(2.0), Expr::Number(3.0)],
743 },
744 ),
745 (
746 "foo.bar.baz",
747 Expr::context(
748 "foo.bar.baz",
749 [Expr::ident("foo"), Expr::ident("bar"), Expr::ident("baz")],
750 ),
751 ),
752 (
753 "foo.bar.baz[1][2]",
754 Expr::context(
755 "foo.bar.baz[1][2]",
756 [
757 Expr::ident("foo"),
758 Expr::ident("bar"),
759 Expr::ident("baz"),
760 Expr::Index(Expr::Number(1.0).into()),
761 Expr::Index(Expr::Number(2.0).into()),
762 ],
763 ),
764 ),
765 (
766 "foo.bar.baz[*]",
767 Expr::context(
768 "foo.bar.baz[*]",
769 [
770 Expr::ident("foo"),
771 Expr::ident("bar"),
772 Expr::ident("baz"),
773 Expr::Index(Expr::Star.into()),
774 ],
775 ),
776 ),
777 (
778 "vegetables.*.ediblePortions",
779 Expr::context(
780 "vegetables.*.ediblePortions",
781 vec![
782 Expr::ident("vegetables"),
783 Expr::Star,
784 Expr::ident("ediblePortions"),
785 ],
786 ),
787 ),
788 (
789 "github.ref == 'refs/heads/main' && 'value_for_main_branch' || 'value_for_other_branches'",
792 Expr::BinOp {
793 lhs: Expr::BinOp {
794 lhs: Expr::BinOp {
795 lhs: Expr::context(
796 "github.ref",
797 [Expr::ident("github"), Expr::ident("ref")],
798 )
799 .into(),
800 op: BinOp::Eq,
801 rhs: Expr::string("refs/heads/main"),
802 }
803 .into(),
804 op: BinOp::And,
805 rhs: Expr::string("value_for_main_branch"),
806 }
807 .into(),
808 op: BinOp::Or,
809 rhs: Expr::string("value_for_other_branches"),
810 },
811 ),
812 (
813 "(true || false) == true",
814 Expr::BinOp {
815 lhs: Expr::BinOp {
816 lhs: Expr::Boolean(true).into(),
817 op: BinOp::Or,
818 rhs: Expr::Boolean(false).into(),
819 }
820 .into(),
821 op: BinOp::Eq,
822 rhs: Expr::Boolean(true).into(),
823 },
824 ),
825 (
826 "!(!true || false)",
827 Expr::UnOp {
828 op: UnOp::Not,
829 expr: Expr::BinOp {
830 lhs: Expr::UnOp {
831 op: UnOp::Not,
832 expr: Expr::Boolean(true).into(),
833 }
834 .into(),
835 op: BinOp::Or,
836 rhs: Expr::Boolean(false).into(),
837 }
838 .into(),
839 },
840 ),
841 (
842 "foobar[format('{0}', 'event')]",
843 Expr::context(
844 "foobar[format('{0}', 'event')]",
845 [
846 Expr::ident("foobar"),
847 Expr::Index(
848 Expr::Call {
849 func: Function("format"),
850 args: vec![*Expr::string("{0}"), *Expr::string("event")],
851 }
852 .into(),
853 ),
854 ],
855 ),
856 ),
857 ];
858
859 for (case, expr) in cases {
860 assert_eq!(Expr::parse(case).unwrap(), *expr);
861 }
862 }
863
864 #[test]
865 fn test_expr_constant_reducible() -> Result<()> {
866 for (expr, reducible) in &[
867 ("'foo'", true),
868 ("1", true),
869 ("true", true),
870 ("null", true),
871 ("!true", true),
874 ("!null", true),
875 ("true && false", true),
876 ("true || false", true),
877 ("null && !null && true", true),
878 ("format('{0} {1}', 'foo', 'bar')", true),
881 ("format('{0} {1}', 1, 2)", true),
882 ("format('{0} {1}', 1, '2')", true),
883 ("contains('foo', 'bar')", true),
884 ("startsWith('foo', 'bar')", true),
885 ("endsWith('foo', 'bar')", true),
886 ("startsWith(some.context, 'bar')", false),
887 ("endsWith(some.context, 'bar')", false),
888 ("format('{0} {1}', '1', format('{0}', null))", true),
890 ("format('{0} {1}', '1', startsWith('foo', 'foo'))", true),
891 ("format('{0} {1}', '1', startsWith(foo.bar, 'foo'))", false),
892 ("foo", false),
893 ("foo.bar", false),
894 ("foo.bar[1]", false),
895 ("foo.bar == 'bar'", false),
896 ("foo.bar || bar || baz", false),
897 ("foo.bar && bar && baz", false),
898 ] {
899 let expr = Expr::parse(expr)?;
900 assert_eq!(expr.constant_reducible(), *reducible);
901 }
902
903 Ok(())
904 }
905
906 #[test]
907 fn test_expr_has_constant_reducible_subexpr() -> Result<()> {
908 for (expr, reducible) in &[
909 ("'foo'", false),
911 ("1", false),
912 ("true", false),
913 ("null", false),
914 (
916 "format('{0}, {1}', github.event.number, format('{0}', 'abc'))",
917 true,
918 ),
919 ("foobar[format('{0}', 'event')]", true),
920 ] {
921 let expr = Expr::parse(expr)?;
922 assert_eq!(expr.has_constant_reducible_subexpr(), *reducible);
923 }
924 Ok(())
925 }
926
927 #[test]
928 fn test_expr_dataflow_contexts() -> Result<()> {
929 let expr = Expr::parse("foo.bar")?;
931 assert_eq!(expr.dataflow_contexts(), ["foo.bar"]);
932
933 let expr = Expr::parse("foo.bar[1]")?;
934 assert_eq!(expr.dataflow_contexts(), ["foo.bar[1]"]);
935
936 let expr = Expr::parse("foo.bar == 'bar'")?;
938 assert!(expr.dataflow_contexts().is_empty());
939
940 let expr = Expr::parse("foo.bar || abc || d.e.f")?;
942 assert_eq!(expr.dataflow_contexts(), ["foo.bar", "abc", "d.e.f"]);
943
944 let expr = Expr::parse("foo.bar && abc && d.e.f")?;
946 assert_eq!(expr.dataflow_contexts(), ["d.e.f"]);
947
948 let expr = Expr::parse("foo.bar == 'bar' && foo.bar || 'false'")?;
949 assert_eq!(expr.dataflow_contexts(), ["foo.bar"]);
950
951 let expr = Expr::parse("foo.bar == 'bar' && foo.bar || foo.baz")?;
952 assert_eq!(expr.dataflow_contexts(), ["foo.bar", "foo.baz"]);
953
954 let expr = Expr::parse("fromJson(steps.runs.outputs.data).workflow_runs[0].id")?;
955 assert_eq!(
956 expr.dataflow_contexts(),
957 ["fromJson(steps.runs.outputs.data).workflow_runs[0].id"]
958 );
959
960 let expr = Expr::parse("format('{0} {1} {2}', foo.bar, tojson(github), toJSON(github))")?;
961 assert_eq!(expr.dataflow_contexts(), ["foo.bar", "github", "github"]);
962
963 Ok(())
964 }
965}