Skip to main content

oxdock_parser/
lib.rs

1//! Parser and AST definitions for the [OxDock](https://github.com/jzombie/rust-oxdock) DSL.
2//!
3//! The full command reference below is generated by `docs-gen` from the
4//! command metadata registry (`declare_commands!` in `oxdock-parser`).
5//!
6//! The reference's fenced examples use `oxdock …` info strings consumed
7//! by the docs-conformance harness (not Rust code), which rustdoc
8//! legitimately flags — hence the targeted allow below.
9//!
10//! The `oxdock_parser` self-alias lets `#[oxdock_type]`-generated code
11//! inside this crate resolve `::oxdock_parser::` paths exactly like
12//! downstream crates do.
13#![allow(rustdoc::invalid_codeblock_attributes)]
14#![doc = include_str!("../docs/command_reference.md")]
15
16extern crate self as oxdock_parser;
17
18pub mod ast;
19pub mod command;
20pub mod commands;
21pub mod constants;
22pub mod error;
23mod lexer;
24#[cfg(feature = "proc-macro-api")]
25mod macro_input;
26pub mod markdown;
27pub mod parser;
28pub mod strip_flags;
29pub mod value;
30
31pub use ast::*;
32pub use command::{
33    ArgSpec, ArgType, CommandMeta, CommandSpec, Example, FlagSpec, FlagValueType, IoDirection,
34    Stream,
35};
36pub use commands::{all_metadata, all_structural_metadata, lower_command};
37pub use constants::*;
38pub use error::{ParseError, ParseErrorKind, ParseResult, SpanContext};
39pub use lexer::LANGUAGE_SPEC;
40#[cfg(feature = "proc-macro-api")]
41pub use macro_input::{
42    DslMacroInput, ScriptSource, parse_braced_tokens, script_from_braced_tokens,
43    split_modules_prefix,
44};
45pub use markdown::{BlockMetadata, FencedBlock, expect_error_from_info, extract_fenced_blocks};
46pub use parser::{
47    parse_guard_expr_str, parse_script, parse_script_with_modules, parse_script_with_preseed,
48};
49pub use strip_flags::strip_flags;
50
51/// Shared mock lowering for parser tests.
52/// Centralizes AST lowering so unit tests, integration tests, and macro_input tests
53/// all exercise the same command set against the same grammar.
54pub mod test_lower_mock {
55    use crate::error::{ParseError, SpanContext};
56    use crate::{Arg, AssertTarget, ParseResult, StepKind, WorkspaceTarget};
57
58    fn validation(cmd: &str, msg: &str) -> ParseError {
59        ParseError::validation(cmd, msg.to_string(), &SpanContext::line_only(0))
60    }
61
62    pub fn lower(name: &str, args: Vec<Arg>) -> ParseResult<StepKind> {
63        match name {
64            "CWD" => Ok(StepKind::Cwd),
65            "WRITE" => {
66                let mut it = args.into_iter();
67                let path = it
68                    .next()
69                    .ok_or_else(|| validation("WRITE", "WRITE requires path"))?;
70                let remaining: Vec<_> = it.collect();
71                let contents = if remaining.is_empty() {
72                    None
73                } else {
74                    let joined = remaining
75                        .iter()
76                        .map(|a| a.as_str())
77                        .collect::<Vec<_>>()
78                        .join(" ");
79                    Some(Arg::String(joined, false))
80                };
81                Ok(StepKind::Write { path, contents })
82            }
83            "HASH_SHA256" => {
84                let mut a = args;
85                if a.first().map(|a| a.as_str()) == Some("--hash") {
86                    a.remove(0);
87                    let hash = a
88                        .first()
89                        .ok_or_else(|| validation("HASH_SHA256", "--hash requires value"))?
90                        .as_str()
91                        .to_string();
92                    a.remove(0);
93                    let path = a
94                        .first()
95                        .ok_or_else(|| validation("HASH_SHA256", "HASH_SHA256 requires path"))?
96                        .clone();
97                    Ok(StepKind::AssertEq {
98                        hash: Some(hash),
99                        actual: AssertTarget::Value(path),
100                        expected: None,
101                    })
102                } else {
103                    let path = a
104                        .first()
105                        .ok_or_else(|| validation("HASH_SHA256", "HASH_SHA256 requires path"))?
106                        .clone();
107                    let contents = a.get(1).cloned();
108                    Ok(StepKind::AssertEq {
109                        hash: None,
110                        actual: AssertTarget::Value(path),
111                        expected: contents,
112                    })
113                }
114            }
115            "ENV" => crate::commands::lower_env_assignment(args)
116                .map_err(|e| validation("ENV", &e.to_string())),
117            "WORKSPACE" => {
118                let target = args
119                    .into_iter()
120                    .next()
121                    .ok_or_else(|| validation("WORKSPACE", "requires target"))?;
122                match target.as_str() {
123                    "SNAPSHOT" | "snapshot" | "A" => {
124                        Ok(StepKind::Workspace(WorkspaceTarget::Snapshot))
125                    }
126                    "LOCAL" | "local" | "B" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
127                    _ => Err(validation("WORKSPACE", "unknown workspace target")),
128                }
129            }
130            "INHERIT_ENV" => {
131                let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
132                Ok(StepKind::InheritEnv { keys })
133            }
134            "ECHO" => {
135                let msg = args
136                    .into_iter()
137                    .next()
138                    .ok_or_else(|| validation("ECHO", "ECHO requires arg"))?;
139                Ok(StepKind::Echo(msg))
140            }
141            "RUN" => {
142                let cmd = args
143                    .into_iter()
144                    .next()
145                    .ok_or_else(|| validation("RUN", "RUN requires arg"))?;
146                Ok(StepKind::Run(cmd))
147            }
148            "WORKDIR" => {
149                let path = args
150                    .into_iter()
151                    .next()
152                    .ok_or_else(|| validation("WORKDIR", "requires path"))?;
153                Ok(StepKind::Workdir(path))
154            }
155            _ => Err(ParseError::unknown_command(
156                name,
157                format!("unknown command: {name}"),
158                None,
159                &SpanContext::line_only(0),
160            )),
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use indoc::indoc;
169    #[cfg(feature = "proc-macro-api")]
170    use quote::quote;
171    use std::collections::HashMap;
172
173    /// Mock lowering — tests grammar mechanics, not domain commands.
174    fn test_lower(name: &str, args: Vec<Arg>) -> ParseResult<StepKind> {
175        crate::test_lower_mock::lower(name, args)
176    }
177
178    fn guard_text(step: &Step) -> Option<String> {
179        step.guard.as_ref().map(|g| g.to_string())
180    }
181
182    #[test]
183    fn commands_are_case_sensitive() {
184        for bad in ["cwd hi", "Cwd hi", "cwd foo"] {
185            parse_script(bad, test_lower).expect_err("mixed/lowercase commands must fail");
186        }
187    }
188
189    #[test]
190    fn string_dsl_supports_rust_style_comments() {
191        let script = indoc! {r#"
192            // leading comment line
193            CWD // inline comment
194            WRITE 'echo "keep // literal"'
195            /* block comment
196               CWD ignored
197               /* nested inner */
198               WRITE ignored as well
199            */
200            WRITE "echo final"
201            WRITE "echo 'literal /* stay */ value'"
202        "#};
203        let steps = parse_script(script, test_lower).expect("parse ok");
204        assert_eq!(steps.len(), 4, "expected 4 executable steps");
205        assert!(matches!(&steps[0].kind, StepKind::Cwd));
206        assert!(matches!(&steps[1].kind, StepKind::Write { .. }));
207        assert!(matches!(&steps[2].kind, StepKind::Write { .. }));
208        assert!(matches!(&steps[3].kind, StepKind::Write { .. }));
209    }
210
211    #[test]
212    fn string_dsl_errors_on_unclosed_block_comment() {
213        let script = indoc! {r#"
214            WRITE echo hi
215            /* unclosed
216        "#};
217        parse_script(script, test_lower).expect_err("should fail");
218    }
219
220    #[test]
221    fn semicolon_splits_instructions() {
222        let script = "WRITE \"echo hi\"; WRITE \"echo bye\"";
223        let steps = parse_script(script, test_lower).expect("parse ok");
224        assert_eq!(steps.len(), 2);
225    }
226
227    #[test]
228    fn guard_supports_colon_separator() {
229        let script = "[env:FOO] WRITE \"echo hi\"";
230        let steps = parse_script(script, test_lower).expect("parse ok");
231        assert_eq!(steps.len(), 1);
232        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:FOO"));
233    }
234
235    #[test]
236    fn guard_lines_chain_before_block() {
237        let script = indoc! {r#"
238            [env:A]
239            [env:B]
240            {
241                WRITE ok.txt hi
242            }
243        "#};
244        let steps = parse_script(script, test_lower).expect("parse ok");
245        assert_eq!(steps.len(), 1);
246        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
247    }
248
249    #[test]
250    fn guard_block_must_contain_command() {
251        let script = indoc! {r#"
252            [env.A] {
253            }
254        "#};
255        parse_script(script, test_lower).expect_err("empty block should fail");
256    }
257
258    #[test]
259    fn with_io_rejects_non_variable_bindings() {
260        // Bindings are `$var`-only: anything else fails naming the
261        // binding and the line.
262        let err = parse_script(
263            "WITH_IO [stdin, stdout=pipe:setup, stderr=pipe:errors] WRITE \"echo hi\"",
264            test_lower,
265        )
266        .expect_err("non-variable bindings must fail");
267        let msg = err.to_string();
268        assert!(
269            msg.contains("pipe:setup"),
270            "error must name the bad binding: {msg}"
271        );
272        assert_eq!(err.line(), 1, "error must name the failing line");
273    }
274
275    #[test]
276    fn with_io_supports_variable_pipes() {
277        let script = "WITH_IO [stdout=$p, stdin=$q] WRITE \"echo hi\"";
278        let steps = parse_script(script, test_lower).expect("parse ok");
279        assert_eq!(steps.len(), 1);
280        match &steps[0].kind {
281            StepKind::WithIo { bindings, cmd } => {
282                assert_eq!(bindings.len(), 2);
283                assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdout)
284                    && b.pipe == Some(PipeTarget::Var("p".to_string()))));
285                assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdin)
286                    && b.pipe == Some(PipeTarget::Var("q".to_string()))));
287                assert!(matches!(cmd.as_ref(), StepKind::Write { .. }));
288            }
289            other => panic!("expected WITH_IO, saw {:?}", other),
290        }
291        // Display round-trips the variable form.
292        assert_eq!(
293            steps[0].kind.to_string(),
294            "WITH_IO [stdout=$p, stdin=$q] WRITE \"echo hi\""
295        );
296    }
297
298    #[test]
299    fn colon_text_in_expression_fails() {
300        // `pipe:ch` is not expression syntax: `pipe` lexes as a bare word
301        // and `:ch` strands, so the whole RHS fails on line 1.
302        let err = parse_script("LET $p: PIPE = pipe:ch", test_lower)
303            .expect_err("colon text in expression must fail");
304        assert_eq!(err.line(), 1, "error must name the failing line");
305    }
306
307    #[test]
308    fn colon_text_in_assert_is_a_plain_value() {
309        // Bare `pipe:ch` in argument position keeps its literal reading:
310        // it lowers to a plain string value, never a stream marker. The
311        // mock lower knows no commands, so this goes through the real
312        // dispatcher.
313        let steps = parse_script("ASSERT_EQ pipe:ch \"x\"", crate::commands::lower_command)
314            .expect("colon text parses as a literal");
315        assert_eq!(steps.len(), 1);
316        match &steps[0].kind {
317            StepKind::AssertEq { actual, .. } => {
318                assert_eq!(
319                    actual,
320                    &AssertTarget::Value(Arg::String("pipe:ch".to_string(), false)),
321                    "colon text must stay a literal value, got {actual:?}"
322                );
323            }
324            other => panic!("expected AssertEq, got {other:?}"),
325        }
326    }
327
328    #[test]
329    fn bare_let_pipe_declares_fresh_backend() {
330        let steps = parse_script("LET $p: PIPE", test_lower).expect("bare LET $p: PIPE parses");
331        assert_eq!(steps.len(), 1);
332        match &steps[0].kind {
333            StepKind::Assign {
334                var,
335                decl_type,
336                expr,
337            } => {
338                assert_eq!(var, "p");
339                assert_eq!(decl_type, "PIPE");
340                assert!(
341                    matches!(expr, Expr::FreshPipe),
342                    "expected FreshPipe, got {expr:?}"
343                );
344            }
345            other => panic!("expected Assign, got {other:?}"),
346        }
347        // Display round-trips the bare form (no initializer).
348        assert_eq!(steps[0].kind.to_string(), "LET $p: PIPE");
349        let again =
350            parse_script(&steps[0].kind.to_string(), test_lower).expect("Display round-trips");
351        assert_eq!(again, steps);
352        // Every other type still requires an initializer.
353        let err = parse_script("LET $x: STRING", test_lower).expect_err("bare STRING must fail");
354        assert!(
355            err.to_string().contains("requires an expression"),
356            "unexpected error: {err}"
357        );
358    }
359
360    #[test]
361    fn brace_blocks_require_guard() {
362        let script = indoc! {r#"
363            {
364                WRITE nope.txt hi
365            }
366        "#};
367        parse_script(script, test_lower).expect_err("unguarded block should fail");
368    }
369
370    #[test]
371    fn multi_line_guard_blocks_apply_to_next_command() {
372        let script = indoc! {r#"
373            [
374                env:A,
375                env:B
376            ]
377            WRITE "echo guarded"
378        "#};
379        let steps = parse_script(script, test_lower).expect("parse ok");
380        assert_eq!(steps.len(), 1);
381        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
382    }
383
384    #[test]
385    fn guarded_brace_blocks_apply_to_all_inner_steps() {
386        let script = indoc! {r#"
387            [env:A] {
388                WRITE one.txt 1
389                WRITE two.txt 2
390            }
391        "#};
392        let steps = parse_script(script, test_lower).expect("parse ok");
393        assert_eq!(steps.len(), 2);
394        assert!(steps.iter().all(|s| s.guard.is_some()));
395    }
396
397    #[test]
398    fn nested_guard_blocks_stack() {
399        let script = indoc! {r#"
400            [env:A] {
401                WRITE outer.txt no
402                [env:B] {
403                    WRITE nested.txt yes
404                }
405            }
406        "#};
407        let steps = parse_script(script, test_lower).expect("parse ok");
408        assert_eq!(steps.len(), 2);
409        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A"));
410        assert_eq!(guard_text(&steps[1]).as_deref(), Some("env:A, env:B"));
411    }
412
413    #[test]
414    fn nested_guard_block_scopes_stack_counts() {
415        let script = indoc! {r#"
416            [env:A] {
417                WRITE outer.txt ok
418                [env:B] {
419                    WRITE deep.txt ok
420                }
421                WRITE outer_again.txt ok
422            }
423        "#};
424        let steps = parse_script(script, test_lower).expect("parse ok");
425        assert_eq!(steps.len(), 3);
426        assert_eq!(steps[0].scope_enter, 1);
427        assert_eq!(steps[0].scope_exit, 0);
428        assert_eq!(steps[1].scope_enter, 1);
429        assert_eq!(steps[1].scope_exit, 1);
430        assert_eq!(steps[2].scope_enter, 0);
431        assert_eq!(steps[2].scope_exit, 1);
432    }
433
434    #[test]
435    fn guard_or_and_and_compose_as_expected() {
436        let script = indoc! {r#"
437            [env:A]
438            [any(env:B, env:C)]
439            WRITE "echo complex"
440        "#};
441        let steps = parse_script(script, test_lower).expect("parse ok");
442        assert_eq!(steps.len(), 1);
443        let guard = steps[0].guard.as_ref().expect("missing guard");
444        assert_eq!(guard.to_string(), "env:A, any(env:B, env:C)");
445
446        let mut env = HashMap::new();
447        env.insert("A".into(), "1".into());
448        env.insert("B".into(), "1".into());
449        assert!(guard_expr_allows(guard, &env), "A && B should pass");
450
451        env.remove("B");
452        env.insert("C".into(), "1".into());
453        assert!(guard_expr_allows(guard, &env), "A && C should pass");
454
455        env.remove("C");
456        assert!(!guard_expr_allows(guard, &env), "A without B/C should fail");
457    }
458
459    #[test]
460    fn guard_or_requires_at_least_one_branch() {
461        let expr = GuardExpr::or(vec![
462            Guard::EnvExists {
463                key: "MISSING".into(),
464            }
465            .into(),
466            Guard::EnvExists {
467                key: "ALSO_MISSING".into(),
468            }
469            .into(),
470        ]);
471        assert!(!guard_expr_allows(&expr, &HashMap::new()));
472        let mut env = HashMap::new();
473        env.insert("MISSING".into(), "1".into());
474        assert!(guard_expr_allows(&expr, &env));
475    }
476
477    #[test]
478    fn guard_or_can_chain_with_additional_predicates() {
479        let script = "[any(env:A, linux), mac] WRITE \"echo hi\"";
480        let steps = parse_script(script, test_lower).expect("parse ok");
481        assert_eq!(steps.len(), 1);
482        let guard = steps[0].guard.as_ref().expect("missing guard");
483        assert_eq!(guard.to_string(), "any(env:A, linux), macos");
484        let GuardExpr::All(children) = guard else {
485            panic!("expected ALL guard");
486        };
487        assert!(matches!(children[0], GuardExpr::Or(_)));
488        match &children[1] {
489            GuardExpr::Predicate(Guard::Platform {
490                target: PlatformGuard::Macos,
491            }) => {}
492            other => panic!("unexpected trailing guard: {other:?}"),
493        }
494    }
495
496    #[test]
497    fn guard_or_guard_line_parses() {
498        use crate::lexer::{LanguageParser, Rule};
499        use pest::Parser;
500        LanguageParser::parse(Rule::guard_line, "[any(linux, env:FOO)]")
501            .expect("guard guard line should parse");
502    }
503
504    #[test]
505    fn env_equals_guard_with_not_wrapper() {
506        let g = GuardExpr::Not(Box::new(GuardExpr::Predicate(Guard::EnvEquals {
507            key: "A".into(),
508            value: "1".into(),
509        })));
510        let mut env = HashMap::new();
511        env.insert("A".into(), "1".into());
512        assert!(!guard_expr_allows(&g, &env));
513        env.insert("A".into(), "2".into());
514        assert!(guard_expr_allows(&g, &env));
515    }
516
517    #[test]
518    fn guard_block_emits_scope_markers() {
519        let script = indoc! {r#"
520            ENV RUN=1
521            [env:RUN] {
522                WRITE one.txt 1
523                WRITE two.txt 2
524            }
525            WRITE three.txt 3
526        "#};
527        let steps = parse_script(script, test_lower).expect("parse ok");
528        assert_eq!(steps.len(), 4);
529        assert_eq!(steps[1].scope_enter, 1);
530        assert_eq!(steps[1].scope_exit, 0);
531        assert_eq!(steps[2].scope_enter, 0);
532        assert_eq!(steps[2].scope_exit, 1);
533        assert_eq!(steps[3].scope_enter, 0);
534        assert_eq!(steps[3].scope_exit, 0);
535    }
536
537    #[test]
538    fn mock_hash_form_parses() {
539        let script = "HASH_SHA256 --hash aabb path.txt";
540        let steps = parse_script(script, test_lower).expect("parse ok");
541        match &steps[0].kind {
542            StepKind::AssertEq {
543                hash,
544                actual,
545                expected,
546            } => {
547                assert_eq!(hash.as_deref(), Some("aabb"));
548                assert_eq!(
549                    actual,
550                    &AssertTarget::Value(Arg::String("path.txt".to_string(), false))
551                );
552                assert_eq!(expected, &None);
553            }
554            other => panic!("expected AssertEq, saw {:?}", other),
555        }
556    }
557
558    #[test]
559    fn mock_commands_parse_and_round_trip() {
560        let script = indoc! {r#"
561            WRITE "dist/hello.txt" "Built with OxDock"
562            WRITE "deeply/nested/tree"
563            WRITE "chained.txt"
564            WRITE "visible-after-comments"
565        "#};
566        let steps = parse_script(script, test_lower).expect("parse ok");
567        assert_eq!(steps.len(), 4);
568
569        // Verify each step's kind matches what we expect
570        for step in &steps {
571            assert!(
572                matches!(&step.kind, StepKind::Write { .. }),
573                "expected Write variant"
574            );
575        }
576    }
577
578    #[test]
579    fn quoted_string_content_preserved() {
580        let script = "WRITE 'echo \"a; b\"'";
581        let steps = parse_script(script, test_lower).expect("parse ok");
582        match &steps[0].kind {
583            StepKind::Write { path, .. } => assert_eq!(path, "echo \"a; b\""),
584            other => panic!("expected Write, saw {:?}", other),
585        }
586    }
587
588    #[test]
589    fn templated_argument_with_spaces() {
590        let script = "WRITE {{ env:OXBOOK_RUNNER_DIR }}";
591        let steps = parse_script(script, test_lower).expect("parse ok");
592        assert_eq!(steps.len(), 1);
593        match &steps[0].kind {
594            StepKind::Write { path, .. } => assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}"),
595            other => panic!("expected Write, saw {:?}", other),
596        }
597    }
598
599    #[test]
600    #[cfg(feature = "proc-macro-api")]
601    fn string_and_braced_scripts_produce_identical_ast() {
602        let mut cases = Vec::new();
603
604        cases.push((
605            indoc! {r#"
606                WRITE /tmp
607                WRITE hello
608            "#}
609            .trim()
610            .to_string(),
611            quote! {
612                WRITE /tmp
613                WRITE hello
614            },
615        ));
616
617        cases.push((
618            indoc! {r#"
619                [not(env:SKIP)]
620                [windows] WRITE win
621                [eq(env:MODE, beta), linux] WRITE combo
622            "#}
623            .trim()
624            .to_string(),
625            quote! {
626                [not(env:SKIP)]
627                [windows] WRITE win
628                [eq(env:MODE, beta), linux] WRITE combo
629            },
630        ));
631
632        cases.push((
633            indoc! {r#"
634                [env:OUTER] {
635                    WRITE nested
636                    [env:INNER] WRITE deep
637                }
638            "#}
639            .trim()
640            .to_string(),
641            quote! {
642                [env:OUTER] {
643                    WRITE nested
644                    [env:INNER] WRITE deep
645                }
646            },
647        ));
648
649        cases.push((
650            indoc! {r#"
651                [eq(env:TEST, 1)]
652                WITH_IO [stdout=$capture_case] WRITE hi
653                WITH_IO [stdin=$capture_case] WRITE out.txt
654            "#}
655            .trim()
656            .to_string(),
657            quote! {
658                [eq(env:TEST, 1)]
659                WITH_IO [stdout=$capture_case] WRITE hi
660                WITH_IO [stdin=$capture_case] WRITE out.txt
661            },
662        ));
663
664        for (idx, (literal, tokens)) in cases.iter().enumerate() {
665            let text = literal.trim();
666            let string_steps = parse_script(text, test_lower)
667                .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
668            let braced_steps = parse_braced_tokens(tokens, test_lower)
669                .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
670            assert_eq!(
671                string_steps, braced_steps,
672                "AST mismatch for case {idx} literal:\n{text}"
673            );
674        }
675    }
676
677    #[test]
678    fn let_assign_with_bare_word() {
679        let script = r#"LET $x: STRING = hello"#;
680        let steps = parse_script(script, test_lower).expect("parse ok");
681        assert_eq!(steps.len(), 1);
682        match &steps[0].kind {
683            StepKind::Assign {
684                var,
685                decl_type: _,
686                expr,
687            } => {
688                assert_eq!(var, "x");
689                assert_eq!(expr, &Expr::Literal(Value::string("hello".to_string())));
690            }
691            other => panic!("expected Assign, got {:?}", other),
692        }
693    }
694
695    #[test]
696    fn let_assign_with_quoted_string() {
697        let script = r#"LET $x: STRING = "hello world""#;
698        let steps = parse_script(script, test_lower).expect("parse ok");
699        assert_eq!(steps.len(), 1);
700        match &steps[0].kind {
701            StepKind::Assign {
702                var,
703                decl_type: _,
704                expr,
705            } => {
706                assert_eq!(var, "x");
707                assert_eq!(
708                    expr,
709                    &Expr::Literal(Value::string("hello world".to_string()))
710                );
711            }
712            other => panic!("expected Assign, got {:?}", other),
713        }
714    }
715
716    #[test]
717    fn let_assign_with_list_literal() {
718        let script = r#"LET $x: LIST = ["a", "b", "c"]"#;
719        let steps = parse_script(script, test_lower).expect("parse ok");
720        assert_eq!(steps.len(), 1);
721        match &steps[0].kind {
722            StepKind::Assign {
723                var,
724                decl_type: _,
725                expr,
726            } => {
727                assert_eq!(var, "x");
728                assert_eq!(
729                    expr,
730                    &Expr::List(vec![
731                        Expr::Literal(Value::string("a".to_string())),
732                        Expr::Literal(Value::string("b".to_string())),
733                        Expr::Literal(Value::string("c".to_string()))
734                    ])
735                );
736            }
737            other => panic!("expected Assign, got {:?}", other),
738        }
739    }
740
741    #[test]
742    fn let_assign_with_variable_ref() {
743        let script = r#"LET $x: STRING = $y"#;
744        let steps = parse_script(script, test_lower).expect("parse ok");
745        assert_eq!(steps.len(), 1);
746        match &steps[0].kind {
747            StepKind::Assign {
748                var,
749                decl_type: _,
750                expr,
751            } => {
752                assert_eq!(var, "x");
753                assert_eq!(expr, &Expr::Var("y".to_string()));
754            }
755            other => panic!("expected Assign, got {:?}", other),
756        }
757    }
758
759    #[test]
760    fn let_assign_with_block() {
761        let script = r#"LET $a: STRING = { RETURN "hello" }"#;
762        let steps = parse_script(script, test_lower).expect("parse ok");
763        assert_eq!(steps.len(), 1);
764        match &steps[0].kind {
765            StepKind::Assign { var, expr, .. } => {
766                assert_eq!(var, "a");
767                match expr {
768                    Expr::Block(body) => {
769                        assert_eq!(body.len(), 1);
770                        assert!(matches!(body[0].kind, StepKind::Return { .. }));
771                    }
772                    other => panic!("expected Block, got {:?}", other),
773                }
774            }
775            other => panic!("expected Assign, got {:?}", other),
776        }
777    }
778
779    #[test]
780    fn let_assign_multiline_block_with_nesting() {
781        let script = indoc! {r#"
782            LET $a: STRING = {
783            LET $b: STRING = { RETURN "hi" }
784            RETURN $b
785            }
786        "#};
787        let steps = parse_script(script, test_lower).expect("parse ok");
788        assert_eq!(steps.len(), 1);
789        match &steps[0].kind {
790            StepKind::Assign { expr, .. } => match expr {
791                Expr::Block(body) => {
792                    assert_eq!(body.len(), 2);
793                    assert!(matches!(body[0].kind, StepKind::Assign { .. }));
794                    assert!(matches!(body[1].kind, StepKind::Return { .. }));
795                    let StepKind::Assign { expr: inner, .. } = &body[0].kind else {
796                        panic!("expected inner Assign");
797                    };
798                    assert!(matches!(inner, Expr::Block(_)));
799                }
800                other => panic!("expected Block, got {:?}", other),
801            },
802            other => panic!("expected Assign, got {:?}", other),
803        }
804    }
805
806    #[test]
807    fn let_assign_map_literal_stays_map() {
808        let script = r#"LET $m: MAP = {a: 1, b: 2}"#;
809        let steps = parse_script(script, test_lower).expect("parse ok");
810        match &steps[0].kind {
811            StepKind::Assign { expr, .. } => {
812                assert!(matches!(expr, Expr::Map(entries) if entries.len() == 2));
813            }
814            other => panic!("expected Assign, got {:?}", other),
815        }
816    }
817
818    #[test]
819    fn map_literal_spans_lines_with_comments() {
820        let script = indoc! {r#"
821            LET $m: MAP = {
822            // leading comment
823            "a": 1, /* trailing */
824            // own line
825            b: 2
826            }
827        "#};
828        let steps = parse_script(script, test_lower).expect("parse ok");
829        match &steps[0].kind {
830            StepKind::Assign { expr, .. } => match expr {
831                Expr::Map(entries) => {
832                    assert_eq!(entries.len(), 2);
833                    assert_eq!(entries[0].0, "a");
834                    assert_eq!(entries[1].0, "b");
835                }
836                other => panic!("expected Map, got {:?}", other),
837            },
838            other => panic!("expected Assign, got {:?}", other),
839        }
840    }
841
842    #[test]
843    fn call_args_accept_comments_between_lines() {
844        let script = indoc! {r#"
845            FUNC SERVE($h: STRING, $u: STRING, $p: STRING, $o: MAP) {
846            RETURN $h
847            }
848            LET $s: STRING = SERVE(
849            // host port
850            "127.0.0.1:2251",
851            "test",
852            "test123", {
853            // workspace-relative key
854            key_path: "/temp/test_key"
855            }
856            )
857        "#};
858        let steps = parse_script(script, test_lower).expect("parse ok");
859        assert_eq!(steps.len(), 2);
860        match &steps[1].kind {
861            StepKind::Assign { expr, .. } => match expr {
862                Expr::Call { name, args } => {
863                    assert_eq!(name, "SCRIPT::SERVE");
864                    assert_eq!(args.len(), 4);
865                    assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
866                }
867                other => panic!("expected Call, got {:?}", other),
868            },
869            other => panic!("expected Assign, got {:?}", other),
870        }
871    }
872
873    #[test]
874    fn hash_comments_trail_map_entries() {
875        let script = indoc! {r#"
876            # 1. Ephemeral server setup
877            LET $m: MAP = {
878            key_path: "temp/test_key"  # Fixed: Relative workspace pathing
879            }
880        "#};
881        let steps = parse_script(script, test_lower).expect("parse ok");
882        match &steps[0].kind {
883            StepKind::Assign { expr, .. } => match expr {
884                Expr::Map(entries) => {
885                    assert_eq!(entries.len(), 1);
886                    assert_eq!(entries[0].0, "key_path");
887                }
888                other => panic!("expected Map, got {:?}", other),
889            },
890            other => panic!("expected Assign, got {:?}", other),
891        }
892    }
893
894    #[test]
895    fn hash_comments_span_call_args_like_slash_comments() {
896        let script = indoc! {r#"
897            FUNC SERVE($h: STRING, $o: MAP) {
898            RETURN $h
899            }
900            # leading hash comment
901            LET $s: STRING = SERVE(
902            # host port
903            "127.0.0.1:2251", {
904            # workspace-relative key
905            key_path: "temp/test_key"  # trailing hash comment
906            }
907            )
908        "#};
909        let steps = parse_script(script, test_lower).expect("parse ok");
910        assert_eq!(steps.len(), 2);
911        match &steps[1].kind {
912            StepKind::Assign { expr, .. } => match expr {
913                Expr::Call { name, args } => {
914                    assert_eq!(name, "SCRIPT::SERVE");
915                    assert_eq!(args.len(), 2);
916                    assert!(matches!(&args[1], Expr::Map(entries) if entries.len() == 1));
917                }
918                other => panic!("expected Call, got {:?}", other),
919            },
920            other => panic!("expected Assign, got {:?}", other),
921        }
922    }
923
924    #[test]
925    fn for_loop_parses() {
926        let script = indoc! {r#"
927            FOR $f: STRING IN ["x", "y"] {
928                WRITE $f
929            }
930        "#};
931        let steps = parse_script(script, test_lower).expect("parse ok");
932        assert_eq!(steps.len(), 1);
933        match &steps[0].kind {
934            StepKind::For {
935                key_var,
936                var,
937                in_expr,
938                body,
939                ..
940            } => {
941                assert!(key_var.is_none());
942                assert_eq!(var, "f");
943                assert_eq!(
944                    in_expr,
945                    &Expr::List(vec![
946                        Expr::Literal(Value::string("x".to_string())),
947                        Expr::Literal(Value::string("y".to_string()))
948                    ])
949                );
950                assert_eq!(body.len(), 1);
951            }
952            other => panic!("expected For, got {:?}", other),
953        }
954    }
955
956    #[test]
957    fn for_map_iteration_parses() {
958        let script = indoc! {r#"
959            FOR $k: STRING, $v: STRING IN $map {
960                WRITE $k
961            }
962        "#};
963        let steps = parse_script(script, test_lower).expect("parse ok");
964        assert_eq!(steps.len(), 1);
965        match &steps[0].kind {
966            StepKind::For {
967                key_var,
968                var,
969                in_expr,
970                body,
971                ..
972            } => {
973                assert_eq!(key_var.as_deref(), Some("k"));
974                assert_eq!(var, "v");
975                assert_eq!(in_expr, &Expr::Var("map".to_string()));
976                assert_eq!(body.len(), 1);
977            }
978            other => panic!("expected For, got {:?}", other),
979        }
980    }
981
982    #[test]
983    fn if_statement_parses() {
984        let script = "IF true { WRITE yes }\n";
985        let steps = parse_script(script, test_lower).expect("parse ok");
986        assert_eq!(steps.len(), 1);
987        match &steps[0].kind {
988            StepKind::If { .. } => {}
989            other => panic!("expected If, got {:?}", other),
990        }
991    }
992
993    #[test]
994    fn if_keyword_matches_directly() {
995        use crate::lexer::{LanguageParser, Rule};
996        use pest::Parser;
997        let result = LanguageParser::parse(Rule::if_keyword, "IF ");
998        assert!(
999            result.is_ok(),
1000            "if_keyword should match 'IF ': {:?}",
1001            result.err()
1002        );
1003    }
1004
1005    #[test]
1006    fn if_statement_pest_matches() {
1007        use crate::lexer::{LanguageParser, Rule};
1008        use pest::Parser;
1009        let result = LanguageParser::parse(Rule::if_statement, "IF true {\n    WRITE yes\n}");
1010        assert!(
1011            result.is_ok(),
1012            "if_statement should match: {:?}",
1013            result.err()
1014        );
1015    }
1016
1017    #[test]
1018    fn not_expression_parses() {
1019        let script = r#"LET $x: BOOL = !true"#;
1020        let steps = parse_script(script, test_lower).expect("parse ok");
1021        match &steps[0].kind {
1022            StepKind::Assign {
1023                var,
1024                decl_type: _,
1025                expr,
1026            } => {
1027                assert_eq!(var, "x");
1028                assert_eq!(expr, &Expr::Not(Box::new(Expr::Literal(Value::bool(true)))));
1029            }
1030            other => panic!("expected Assign, got {:?}", other),
1031        }
1032
1033        // Double negation nests.
1034        let steps = parse_script(r#"LET $x: BOOL = !!false"#, test_lower).expect("parse ok");
1035        match &steps[0].kind {
1036            StepKind::Assign { expr, .. } => {
1037                assert_eq!(
1038                    expr,
1039                    &Expr::Not(Box::new(Expr::Not(Box::new(Expr::Literal(Value::bool(
1040                        false
1041                    ))))))
1042                );
1043            }
1044            other => panic!("expected Assign, got {:?}", other),
1045        }
1046
1047        // `!` binds tighter than `==`: `!true == false` is `(!true) == false`.
1048        let steps = parse_script(r#"LET $x: BOOL = !true == false"#, test_lower).expect("parse ok");
1049        match &steps[0].kind {
1050            StepKind::Assign { expr, .. } => {
1051                assert!(matches!(expr, Expr::Compare { .. }), "got {expr:?}");
1052                if let Expr::Compare { left, .. } = expr {
1053                    assert!(matches!(left.as_ref(), Expr::Not(_)), "got {left:?}");
1054                }
1055            }
1056            other => panic!("expected Assign, got {:?}", other),
1057        }
1058
1059        // Parentheses invert the grouping: `!(true == false)`.
1060        let steps =
1061            parse_script(r#"LET $x: BOOL = !(true == false)"#, test_lower).expect("parse ok");
1062        match &steps[0].kind {
1063            StepKind::Assign { expr, .. } => {
1064                assert!(matches!(expr, Expr::Not(_)), "got {expr:?}");
1065            }
1066            other => panic!("expected Assign, got {:?}", other),
1067        }
1068    }
1069
1070    #[test]
1071    fn not_expression_display_round_trips() {
1072        for script in [
1073            "LET $x: BOOL = !true",
1074            "LET $x: BOOL = !!false",
1075            "LET $x: BOOL = !(true == false)",
1076            "IF !true {\n    WRITE yes\n}",
1077        ] {
1078            let steps = parse_script(script, test_lower).expect("parse");
1079            let rendered: Vec<String> = steps.iter().map(|s| s.to_string()).collect();
1080            let reparsed = parse_script(&rendered.join("\n"), test_lower).expect("reparse");
1081            assert_eq!(steps, reparsed, "Display round-trip failed for {script}");
1082        }
1083    }
1084
1085    #[test]
1086    fn guard_block_with_mock_command() {
1087        let script = "CWD\n[env:GATE] {\n    WRITE gated\n}\n[eq(env:A, 1)] WRITE eq\n";
1088        let steps = parse_script(script, test_lower).expect("parse should succeed");
1089        assert!(
1090            steps.len() >= 2,
1091            "expected at least 2 steps, got {}",
1092            steps.len()
1093        );
1094    }
1095
1096    #[test]
1097    fn single_line_blocks() {
1098        let test_cases = [
1099            "IF true { WRITE \"hello\" }",
1100            "IF true { WRITE \"cargo test\" }",
1101            "IF true { WRITE \"/app\" }",
1102        ];
1103        for script in test_cases {
1104            assert!(
1105                parse_script(script, test_lower).is_ok(),
1106                "Failed to parse: {}",
1107                script
1108            );
1109        }
1110    }
1111
1112    #[test]
1113    fn async_run_parses() {
1114        let script = "ASYNC RUN \"echo hello\"";
1115        let steps = parse_script(script, test_lower).expect("parse should succeed");
1116        assert_eq!(steps.len(), 1);
1117        assert!(matches!(&steps[0].kind, StepKind::AsyncBlock { .. }));
1118    }
1119
1120    #[test]
1121    fn async_block_parses() {
1122        let script = indoc! {r#"
1123            ASYNC {
1124                RUN "echo one"
1125                RUN "echo two"
1126            }
1127        "#};
1128        let steps = parse_script(script, test_lower).expect("parse should succeed");
1129        assert_eq!(steps.len(), 1);
1130        match &steps[0].kind {
1131            StepKind::AsyncBlock { body } => {
1132                assert_eq!(body.len(), 2);
1133            }
1134            other => panic!("expected AsyncBlock, got {:?}", other),
1135        }
1136    }
1137
1138    #[test]
1139    fn nested_async_parses() {
1140        let script = "ASYNC ASYNC RUN \"echo nested\"";
1141        let steps = parse_script(script, test_lower).expect("parse should succeed");
1142        assert_eq!(steps.len(), 1);
1143        match &steps[0].kind {
1144            StepKind::AsyncBlock { body } => {
1145                assert_eq!(body.len(), 1);
1146                match &body[0].kind {
1147                    StepKind::AsyncBlock { body } => {
1148                        assert_eq!(body.len(), 1);
1149                        assert!(matches!(&body[0].kind, StepKind::Run(_)));
1150                    }
1151                    other => panic!("expected inner AsyncBlock, got {:?}", other),
1152                }
1153            }
1154            other => panic!("expected outer AsyncBlock, got {:?}", other),
1155        }
1156    }
1157
1158    #[test]
1159    fn nested_async_block_form_parses() {
1160        let script = indoc! {r#"
1161            ASYNC {
1162                ASYNC {
1163                    RUN "echo nested"
1164                }
1165            }
1166        "#};
1167        let steps = parse_script(script, test_lower).expect("parse should succeed");
1168        assert_eq!(steps.len(), 1);
1169        match &steps[0].kind {
1170            StepKind::AsyncBlock { body } => {
1171                assert_eq!(body.len(), 1);
1172                match &body[0].kind {
1173                    StepKind::AsyncBlock { body } => {
1174                        assert_eq!(body.len(), 1);
1175                        assert!(matches!(&body[0].kind, StepKind::Run(_)));
1176                    }
1177                    other => panic!("expected inner AsyncBlock, got {:?}", other),
1178                }
1179            }
1180            other => panic!("expected outer AsyncBlock, got {:?}", other),
1181        }
1182    }
1183
1184    #[test]
1185    fn with_io_wrapping_async_parses() {
1186        let script = "WITH_IO [stdout] ASYNC RUN \"echo test\"";
1187        let steps = parse_script(script, test_lower).expect("parse should succeed");
1188        assert_eq!(steps.len(), 1);
1189        match &steps[0].kind {
1190            StepKind::WithIo { cmd, .. } => {
1191                assert!(matches!(cmd.as_ref(), StepKind::AsyncBlock { .. }));
1192            }
1193            other => panic!("expected WithIo, got {:?}", other),
1194        }
1195    }
1196
1197    #[test]
1198    fn with_io_async_block_nested_for_parses() {
1199        // Regression: structural statements nested inside a WITH_IO-wrapped
1200        // ASYNC block must parse. WITH_IO is compound-atomic (implicit
1201        // whitespace suppressed), so nested rules carry explicit gaps.
1202        let script = indoc! {r#"
1203            WITH_IO [stdout=$out] ASYNC {
1204                FOR $x: INT IN [0, 1] {
1205                    ECHO hi
1206                }
1207            }
1208        "#};
1209        let steps = parse_script(script, test_lower).expect("parse should succeed");
1210        assert_eq!(steps.len(), 1);
1211        match &steps[0].kind {
1212            StepKind::WithIo { bindings, cmd } => {
1213                assert_eq!(bindings.len(), 1);
1214                assert!(matches!(bindings[0].stream, IoStream::Stdout));
1215                assert_eq!(bindings[0].pipe, Some(PipeTarget::Var("out".to_string())));
1216                match cmd.as_ref() {
1217                    StepKind::AsyncBlock { body } => {
1218                        assert_eq!(body.len(), 1);
1219                        match &body[0].kind {
1220                            StepKind::For { var, body, .. } => {
1221                                assert_eq!(var, "x");
1222                                assert_eq!(body.len(), 1);
1223                                assert!(matches!(&body[0].kind, StepKind::Echo(_)));
1224                            }
1225                            other => panic!("expected For, got {:?}", other),
1226                        }
1227                    }
1228                    other => panic!("expected AsyncBlock, got {:?}", other),
1229                }
1230            }
1231            other => panic!("expected WithIo, got {:?}", other),
1232        }
1233    }
1234
1235    #[test]
1236    fn for_int_key_parses_for_list_enumeration() {
1237        let script = indoc! {r#"
1238            FOR $i: INT, $v: STRING IN $items {
1239                WRITE $v
1240            }
1241        "#};
1242        let steps = parse_script(script, test_lower).expect("parse ok");
1243        match &steps[0].kind {
1244            StepKind::For {
1245                key_var,
1246                key_type,
1247                var,
1248                var_type,
1249                ..
1250            } => {
1251                assert_eq!(key_var.as_deref(), Some("i"));
1252                assert_eq!(*key_type, Some("INT".to_string()));
1253                assert_eq!(var, "v");
1254                assert_eq!(*var_type, "STRING".to_string());
1255            }
1256            other => panic!("expected For, got {:?}", other),
1257        }
1258    }
1259
1260    #[test]
1261    fn for_non_index_key_type_is_rejected() {
1262        let err = parse_script(
1263            "FOR $k: BOOL, $v: STRING IN $map { WRITE $v }\n",
1264            test_lower,
1265        )
1266        .expect_err("BOOL key must fail");
1267        assert!(
1268            err.to_string().contains("must be INT or STRING"),
1269            "unexpected error: {err}"
1270        );
1271    }
1272
1273    #[test]
1274    fn with_io_async_block_nested_if_else_parses() {
1275        // Spaced comparison and ELSE chain inside a WITH_IO-wrapped block.
1276        let script = indoc! {r#"
1277            WITH_IO [stdout=$out] ASYNC {
1278                IF $a == $b {
1279                    ECHO yes
1280                } ELSE {
1281                    ECHO no
1282                }
1283            }
1284        "#};
1285        let steps = parse_script(script, test_lower).expect("parse should succeed");
1286        match &steps[0].kind {
1287            StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1288                StepKind::AsyncBlock { body } => {
1289                    assert!(matches!(&body[0].kind, StepKind::If { .. }));
1290                    match &body[0].kind {
1291                        StepKind::If { else_body, .. } => {
1292                            assert_eq!(else_body.as_ref().map(Vec::len), Some(1));
1293                        }
1294                        other => panic!("expected If, got {:?}", other),
1295                    }
1296                }
1297                other => panic!("expected AsyncBlock, got {:?}", other),
1298            },
1299            other => panic!("expected WithIo, got {:?}", other),
1300        }
1301    }
1302
1303    #[test]
1304    fn timeout_block_nested_for_parses() {
1305        // TIMEOUT is compound-atomic too; nested structural statements must
1306        // parse inside its block form.
1307        let script = indoc! {r#"
1308            TIMEOUT 30s {
1309                FOR $x: INT IN [1] {
1310                    ECHO hi
1311                }
1312            }
1313        "#};
1314        let steps = parse_script(script, test_lower).expect("parse should succeed");
1315        match &steps[0].kind {
1316            StepKind::Timeout { body, .. } => {
1317                assert_eq!(body.len(), 1);
1318                assert!(matches!(&body[0].kind, StepKind::For { .. }));
1319            }
1320            other => panic!("expected Timeout, got {:?}", other),
1321        }
1322    }
1323
1324    #[test]
1325    fn with_io_async_block_nested_let_map_parses() {
1326        // LET with a spaced map literal inside a WITH_IO-wrapped block.
1327        let script = indoc! {r#"
1328            WITH_IO [stdout] ASYNC {
1329                LET $m: MAP = {a: 1, b: 2}
1330            }
1331        "#};
1332        let steps = parse_script(script, test_lower).expect("parse should succeed");
1333        match &steps[0].kind {
1334            StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1335                StepKind::AsyncBlock { body } => {
1336                    assert!(matches!(&body[0].kind, StepKind::Assign { .. }));
1337                }
1338                other => panic!("expected AsyncBlock, got {:?}", other),
1339            },
1340            other => panic!("expected WithIo, got {:?}", other),
1341        }
1342    }
1343
1344    #[test]
1345    fn variable_sigil_binds_tightly() {
1346        // `$` and its identifier are one atomic unit: whitespace between the
1347        // sigil and the name must not parse as a variable reference. (In
1348        // argument position this already failed; in expression position the
1349        // old non-atomic `variable` rule accepted `$   y` via implicit
1350        // whitespace.)
1351        parse_script("ECHO $   x", test_lower).expect_err("spaced sigil must fail");
1352        parse_script("LET $x: STRING = $   y", test_lower).expect_err("spaced sigil must fail");
1353        let steps = parse_script("ECHO $x", test_lower).expect("tight sigil parses");
1354        assert!(matches!(&steps[0].kind, StepKind::Echo(_)));
1355    }
1356
1357    #[test]
1358    fn let_async_block_parses() {
1359        let script = indoc! {r#"
1360            LET $task: HANDLE = ASYNC {
1361                RUN "echo hello"
1362            }
1363        "#};
1364        let steps = parse_script(script, test_lower).expect("parse should succeed");
1365        assert_eq!(steps.len(), 1);
1366        match &steps[0].kind {
1367            StepKind::AssignAsync { var, body, .. } => {
1368                assert_eq!(var, "task");
1369                assert_eq!(body.len(), 1);
1370                assert!(matches!(&body[0].kind, StepKind::Run(_)));
1371            }
1372            other => panic!("expected AssignAsync, got {:?}", other),
1373        }
1374    }
1375
1376    #[test]
1377    fn let_async_inline_parses() {
1378        let script = "LET $t: HANDLE = ASYNC RUN \"echo hi\"";
1379        let steps = parse_script(script, test_lower).expect("parse should succeed");
1380        assert_eq!(steps.len(), 1);
1381        match &steps[0].kind {
1382            StepKind::AssignAsync { var, body, .. } => {
1383                assert_eq!(var, "t");
1384                assert_eq!(body.len(), 1);
1385                assert!(matches!(&body[0].kind, StepKind::Run(_)));
1386            }
1387            other => panic!("expected AssignAsync, got {:?}", other),
1388        }
1389    }
1390
1391    #[test]
1392    fn await_parses() {
1393        let script = "AWAIT $task";
1394        let steps = parse_script(script, test_lower).expect("parse should succeed");
1395        assert_eq!(steps.len(), 1);
1396        match &steps[0].kind {
1397            StepKind::Await { var } => {
1398                assert_eq!(var, "task");
1399            }
1400            other => panic!("expected Await, got {:?}", other),
1401        }
1402    }
1403}