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