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