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