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