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