Skip to main content

oxdock_parser/
lib.rs

1//! Parser and AST definitions for the OxDock DSL.
2//!
3//! The full command reference below is generated by `docs-gen` from the
4//! command metadata registry declared in this crate.
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#![allow(rustdoc::invalid_codeblock_attributes)]
10#![doc = include_str!("../docs/command_reference.md")]
11
12pub mod ast;
13pub mod command;
14pub mod commands;
15mod lexer;
16#[cfg(feature = "proc-macro-api")]
17mod macro_input;
18pub mod markdown;
19pub mod parser;
20pub mod strip_flags;
21
22pub use ast::*;
23pub use command::{
24    ArgSpec, ArgType, CommandMeta, CommandSpec, Example, FlagSpec, FlagValueType, IoDirection,
25    Stream,
26};
27pub use commands::{all_metadata, all_structural_metadata, lower_command};
28pub use lexer::LANGUAGE_SPEC;
29#[cfg(feature = "proc-macro-api")]
30pub use macro_input::{
31    DslMacroInput, ScriptSource, parse_braced_tokens, script_from_braced_tokens,
32};
33pub use markdown::{BlockMetadata, FencedBlock, extract_fenced_blocks};
34pub use parser::{parse_guard_expr_str, parse_script};
35pub use strip_flags::strip_flags;
36
37/// Shared mock lowering for parser tests.
38/// Centralizes AST lowering so unit tests, integration tests, and macro_input tests
39/// all exercise the same command set against the same grammar.
40pub mod test_lower_mock {
41    use crate::{Arg, StepKind, WorkspaceTarget};
42    use anyhow::{anyhow, bail};
43
44    pub fn lower(name: &str, args: Vec<Arg>) -> anyhow::Result<StepKind> {
45        match name {
46            "CWD" => Ok(StepKind::Cwd),
47            "WRITE" => {
48                let mut it = args.into_iter();
49                let path = it.next().ok_or_else(|| anyhow!("WRITE requires path"))?;
50                let remaining: Vec<_> = it.collect();
51                let contents = if remaining.is_empty() {
52                    None
53                } else {
54                    let joined = remaining
55                        .iter()
56                        .map(|a| a.as_str())
57                        .collect::<Vec<_>>()
58                        .join(" ");
59                    Some(Arg::String(joined, false))
60                };
61                Ok(StepKind::Write { path, contents })
62            }
63            "HASH_SHA256" => {
64                let mut a = args;
65                if a.first().map(|a| a.as_str()) == Some("--hash") {
66                    a.remove(0);
67                    let hash = a
68                        .first()
69                        .ok_or_else(|| anyhow!("--hash requires value"))?
70                        .as_str()
71                        .to_string();
72                    a.remove(0);
73                    let path = a
74                        .first()
75                        .ok_or_else(|| anyhow!("HASH_SHA256 requires path"))?
76                        .clone();
77                    Ok(StepKind::AssertFile {
78                        hash: Some(hash),
79                        path,
80                        contents: None,
81                    })
82                } else {
83                    let path = a
84                        .first()
85                        .ok_or_else(|| anyhow!("HASH_SHA256 requires path"))?
86                        .clone();
87                    let contents = a.get(1).cloned();
88                    Ok(StepKind::AssertFile {
89                        hash: None,
90                        path,
91                        contents,
92                    })
93                }
94            }
95            "ENV" => crate::commands::lower_env_assignment(args),
96            "WORKSPACE" => {
97                let target = args
98                    .into_iter()
99                    .next()
100                    .ok_or_else(|| anyhow!("requires target"))?;
101                match target.as_str() {
102                    "SNAPSHOT" | "snapshot" | "A" => {
103                        Ok(StepKind::Workspace(WorkspaceTarget::Snapshot))
104                    }
105                    "LOCAL" | "local" | "B" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
106                    _ => bail!("unknown workspace target"),
107                }
108            }
109            "INHERIT_ENV" => {
110                let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
111                Ok(StepKind::InheritEnv { keys })
112            }
113            "ECHO" => {
114                let msg = args
115                    .into_iter()
116                    .next()
117                    .ok_or_else(|| anyhow!("ECHO requires arg"))?;
118                Ok(StepKind::Echo(msg))
119            }
120            "RUN" => {
121                let cmd = args
122                    .into_iter()
123                    .next()
124                    .ok_or_else(|| anyhow!("RUN requires arg"))?;
125                Ok(StepKind::Run(cmd))
126            }
127            "WORKDIR" => {
128                let path = args
129                    .into_iter()
130                    .next()
131                    .ok_or_else(|| anyhow!("requires path"))?;
132                Ok(StepKind::Workdir(path))
133            }
134            _ => bail!("unknown command: {name}"),
135        }
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142    use indoc::indoc;
143    #[cfg(feature = "proc-macro-api")]
144    use quote::quote;
145    use std::collections::HashMap;
146
147    /// Mock lowering — tests grammar mechanics, not domain commands.
148    fn test_lower(name: &str, args: Vec<Arg>) -> anyhow::Result<StepKind> {
149        crate::test_lower_mock::lower(name, args)
150    }
151
152    fn guard_text(step: &Step) -> Option<String> {
153        step.guard.as_ref().map(|g| g.to_string())
154    }
155
156    #[test]
157    fn commands_are_case_sensitive() {
158        for bad in ["cwd hi", "Cwd hi", "cwd foo"] {
159            parse_script(bad, test_lower).expect_err("mixed/lowercase commands must fail");
160        }
161    }
162
163    #[test]
164    fn string_dsl_supports_rust_style_comments() {
165        let script = indoc! {r#"
166            // leading comment line
167            CWD // inline comment
168            WRITE 'echo "keep // literal"'
169            /* block comment
170               CWD ignored
171               /* nested inner */
172               WRITE ignored as well
173            */
174            WRITE "echo final"
175            WRITE "echo 'literal /* stay */ value'"
176        "#};
177        let steps = parse_script(script, test_lower).expect("parse ok");
178        assert_eq!(steps.len(), 4, "expected 4 executable steps");
179        assert!(matches!(&steps[0].kind, StepKind::Cwd));
180        assert!(matches!(&steps[1].kind, StepKind::Write { .. }));
181        assert!(matches!(&steps[2].kind, StepKind::Write { .. }));
182        assert!(matches!(&steps[3].kind, StepKind::Write { .. }));
183    }
184
185    #[test]
186    fn string_dsl_errors_on_unclosed_block_comment() {
187        let script = indoc! {r#"
188            WRITE echo hi
189            /* unclosed
190        "#};
191        parse_script(script, test_lower).expect_err("should fail");
192    }
193
194    #[test]
195    fn semicolon_splits_instructions() {
196        let script = "WRITE \"echo hi\"; WRITE \"echo bye\"";
197        let steps = parse_script(script, test_lower).expect("parse ok");
198        assert_eq!(steps.len(), 2);
199    }
200
201    #[test]
202    fn guard_supports_colon_separator() {
203        let script = "[env:FOO] WRITE \"echo hi\"";
204        let steps = parse_script(script, test_lower).expect("parse ok");
205        assert_eq!(steps.len(), 1);
206        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:FOO"));
207    }
208
209    #[test]
210    fn guard_lines_chain_before_block() {
211        let script = indoc! {r#"
212            [env:A]
213            [env:B]
214            {
215                WRITE ok.txt hi
216            }
217        "#};
218        let steps = parse_script(script, test_lower).expect("parse ok");
219        assert_eq!(steps.len(), 1);
220        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
221    }
222
223    #[test]
224    fn guard_block_must_contain_command() {
225        let script = indoc! {r#"
226            [env.A] {
227            }
228        "#};
229        parse_script(script, test_lower).expect_err("empty block should fail");
230    }
231
232    #[test]
233    fn with_io_supports_named_pipes() {
234        let script = "WITH_IO [stdin, stdout=pipe:setup, stderr=pipe:errors] WRITE \"echo hi\"";
235        let steps = parse_script(script, test_lower).expect("parse ok");
236        assert_eq!(steps.len(), 1);
237        match &steps[0].kind {
238            StepKind::WithIo { bindings, cmd } => {
239                assert_eq!(bindings.len(), 3);
240                assert!(
241                    bindings
242                        .iter()
243                        .any(|b| matches!(b.stream, IoStream::Stdin) && b.pipe.is_none())
244                );
245                assert!(
246                    bindings.iter().any(|b| matches!(b.stream, IoStream::Stdout)
247                        && b.pipe.as_deref() == Some("setup"))
248                );
249                assert!(
250                    bindings.iter().any(|b| matches!(b.stream, IoStream::Stderr)
251                        && b.pipe.as_deref() == Some("errors"))
252                );
253                assert!(matches!(cmd.as_ref(), StepKind::Write { .. }));
254            }
255            other => panic!("expected WITH_IO, saw {:?}", other),
256        }
257    }
258
259    #[test]
260    fn brace_blocks_require_guard() {
261        let script = indoc! {r#"
262            {
263                WRITE nope.txt hi
264            }
265        "#};
266        parse_script(script, test_lower).expect_err("unguarded block should fail");
267    }
268
269    #[test]
270    fn multi_line_guard_blocks_apply_to_next_command() {
271        let script = indoc! {r#"
272            [
273                env:A,
274                env:B
275            ]
276            WRITE "echo guarded"
277        "#};
278        let steps = parse_script(script, test_lower).expect("parse ok");
279        assert_eq!(steps.len(), 1);
280        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
281    }
282
283    #[test]
284    fn guarded_brace_blocks_apply_to_all_inner_steps() {
285        let script = indoc! {r#"
286            [env:A] {
287                WRITE one.txt 1
288                WRITE two.txt 2
289            }
290        "#};
291        let steps = parse_script(script, test_lower).expect("parse ok");
292        assert_eq!(steps.len(), 2);
293        assert!(steps.iter().all(|s| s.guard.is_some()));
294    }
295
296    #[test]
297    fn nested_guard_blocks_stack() {
298        let script = indoc! {r#"
299            [env:A] {
300                WRITE outer.txt no
301                [env:B] {
302                    WRITE nested.txt yes
303                }
304            }
305        "#};
306        let steps = parse_script(script, test_lower).expect("parse ok");
307        assert_eq!(steps.len(), 2);
308        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A"));
309        assert_eq!(guard_text(&steps[1]).as_deref(), Some("env:A, env:B"));
310    }
311
312    #[test]
313    fn nested_guard_block_scopes_stack_counts() {
314        let script = indoc! {r#"
315            [env:A] {
316                WRITE outer.txt ok
317                [env:B] {
318                    WRITE deep.txt ok
319                }
320                WRITE outer_again.txt ok
321            }
322        "#};
323        let steps = parse_script(script, test_lower).expect("parse ok");
324        assert_eq!(steps.len(), 3);
325        assert_eq!(steps[0].scope_enter, 1);
326        assert_eq!(steps[0].scope_exit, 0);
327        assert_eq!(steps[1].scope_enter, 1);
328        assert_eq!(steps[1].scope_exit, 1);
329        assert_eq!(steps[2].scope_enter, 0);
330        assert_eq!(steps[2].scope_exit, 1);
331    }
332
333    #[test]
334    fn guard_or_and_and_compose_as_expected() {
335        let script = indoc! {r#"
336            [env:A]
337            [any(env:B, env:C)]
338            WRITE "echo complex"
339        "#};
340        let steps = parse_script(script, test_lower).expect("parse ok");
341        assert_eq!(steps.len(), 1);
342        let guard = steps[0].guard.as_ref().expect("missing guard");
343        assert_eq!(guard.to_string(), "env:A, any(env:B, env:C)");
344
345        let mut env = HashMap::new();
346        env.insert("A".into(), "1".into());
347        env.insert("B".into(), "1".into());
348        assert!(guard_expr_allows(guard, &env), "A && B should pass");
349
350        env.remove("B");
351        env.insert("C".into(), "1".into());
352        assert!(guard_expr_allows(guard, &env), "A && C should pass");
353
354        env.remove("C");
355        assert!(!guard_expr_allows(guard, &env), "A without B/C should fail");
356    }
357
358    #[test]
359    fn guard_or_requires_at_least_one_branch() {
360        let expr = GuardExpr::or(vec![
361            Guard::EnvExists {
362                key: "MISSING".into(),
363            }
364            .into(),
365            Guard::EnvExists {
366                key: "ALSO_MISSING".into(),
367            }
368            .into(),
369        ]);
370        assert!(!guard_expr_allows(&expr, &HashMap::new()));
371        let mut env = HashMap::new();
372        env.insert("MISSING".into(), "1".into());
373        assert!(guard_expr_allows(&expr, &env));
374    }
375
376    #[test]
377    fn guard_or_can_chain_with_additional_predicates() {
378        let script = "[any(env:A, linux), mac] WRITE \"echo hi\"";
379        let steps = parse_script(script, test_lower).expect("parse ok");
380        assert_eq!(steps.len(), 1);
381        let guard = steps[0].guard.as_ref().expect("missing guard");
382        assert_eq!(guard.to_string(), "any(env:A, linux), macos");
383        let GuardExpr::All(children) = guard else {
384            panic!("expected ALL guard");
385        };
386        assert!(matches!(children[0], GuardExpr::Or(_)));
387        match &children[1] {
388            GuardExpr::Predicate(Guard::Platform {
389                target: PlatformGuard::Macos,
390            }) => {}
391            other => panic!("unexpected trailing guard: {other:?}"),
392        }
393    }
394
395    #[test]
396    fn guard_or_guard_line_parses() {
397        use crate::lexer::{LanguageParser, Rule};
398        use pest::Parser;
399        LanguageParser::parse(Rule::guard_line, "[any(linux, env:FOO)]")
400            .expect("guard guard line should parse");
401    }
402
403    #[test]
404    fn env_equals_guard_with_not_wrapper() {
405        let g = GuardExpr::Not(Box::new(GuardExpr::Predicate(Guard::EnvEquals {
406            key: "A".into(),
407            value: "1".into(),
408        })));
409        let mut env = HashMap::new();
410        env.insert("A".into(), "1".into());
411        assert!(!guard_expr_allows(&g, &env));
412        env.insert("A".into(), "2".into());
413        assert!(guard_expr_allows(&g, &env));
414    }
415
416    #[test]
417    fn guard_block_emits_scope_markers() {
418        let script = indoc! {r#"
419            ENV RUN=1
420            [env:RUN] {
421                WRITE one.txt 1
422                WRITE two.txt 2
423            }
424            WRITE three.txt 3
425        "#};
426        let steps = parse_script(script, test_lower).expect("parse ok");
427        assert_eq!(steps.len(), 4);
428        assert_eq!(steps[1].scope_enter, 1);
429        assert_eq!(steps[1].scope_exit, 0);
430        assert_eq!(steps[2].scope_enter, 0);
431        assert_eq!(steps[2].scope_exit, 1);
432        assert_eq!(steps[3].scope_enter, 0);
433        assert_eq!(steps[3].scope_exit, 0);
434    }
435
436    #[test]
437    fn mock_hash_form_parses() {
438        let script = "HASH_SHA256 --hash aabb path.txt";
439        let steps = parse_script(script, test_lower).expect("parse ok");
440        match &steps[0].kind {
441            StepKind::AssertFile {
442                hash,
443                path,
444                contents,
445            } => {
446                assert_eq!(hash.as_deref(), Some("aabb"));
447                assert_eq!(path.as_ref(), "path.txt");
448                assert!(contents.is_none());
449            }
450            other => panic!("expected AssertFile, saw {:?}", other),
451        }
452    }
453
454    #[test]
455    fn mock_commands_parse_and_round_trip() {
456        let script = indoc! {r#"
457            WRITE "dist/hello.txt" "Built with OxDock"
458            WRITE "deeply/nested/tree"
459            WRITE "chained.txt"
460            WRITE "visible-after-comments"
461        "#};
462        let steps = parse_script(script, test_lower).expect("parse ok");
463        assert_eq!(steps.len(), 4);
464
465        // Verify each step's kind matches what we expect
466        for step in &steps {
467            assert!(
468                matches!(&step.kind, StepKind::Write { .. }),
469                "expected Write variant"
470            );
471        }
472    }
473
474    #[test]
475    fn quoted_string_content_preserved() {
476        let script = "WRITE 'echo \"a; b\"'";
477        let steps = parse_script(script, test_lower).expect("parse ok");
478        match &steps[0].kind {
479            StepKind::Write { path, .. } => assert_eq!(path, "echo \"a; b\""),
480            other => panic!("expected Write, saw {:?}", other),
481        }
482    }
483
484    #[test]
485    fn templated_argument_with_spaces() {
486        let script = "WRITE {{ env:OXBOOK_RUNNER_DIR }}";
487        let steps = parse_script(script, test_lower).expect("parse ok");
488        assert_eq!(steps.len(), 1);
489        match &steps[0].kind {
490            StepKind::Write { path, .. } => assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}"),
491            other => panic!("expected Write, saw {:?}", other),
492        }
493    }
494
495    #[test]
496    #[cfg(feature = "proc-macro-api")]
497    fn string_and_braced_scripts_produce_identical_ast() {
498        let mut cases = Vec::new();
499
500        cases.push((
501            indoc! {r#"
502                WRITE /tmp
503                WRITE hello
504            "#}
505            .trim()
506            .to_string(),
507            quote! {
508                WRITE /tmp
509                WRITE hello
510            },
511        ));
512
513        cases.push((
514            indoc! {r#"
515                [not(env:SKIP)]
516                [windows] WRITE win
517                [eq(env:MODE, beta), linux] WRITE combo
518            "#}
519            .trim()
520            .to_string(),
521            quote! {
522                [not(env:SKIP)]
523                [windows] WRITE win
524                [eq(env:MODE, beta), linux] WRITE combo
525            },
526        ));
527
528        cases.push((
529            indoc! {r#"
530                [env:OUTER] {
531                    WRITE nested
532                    [env:INNER] WRITE deep
533                }
534            "#}
535            .trim()
536            .to_string(),
537            quote! {
538                [env:OUTER] {
539                    WRITE nested
540                    [env:INNER] WRITE deep
541                }
542            },
543        ));
544
545        cases.push((
546            indoc! {r#"
547                [eq(env:TEST, 1)]
548                WITH_IO [stdout=pipe:capture_case] WRITE hi
549                WITH_IO [stdin=pipe:capture_case] WRITE out.txt
550            "#}
551            .trim()
552            .to_string(),
553            quote! {
554                [eq(env:TEST, 1)]
555                WITH_IO [stdout=pipe:capture_case] WRITE hi
556                WITH_IO [stdin=pipe:capture_case] WRITE out.txt
557            },
558        ));
559
560        for (idx, (literal, tokens)) in cases.iter().enumerate() {
561            let text = literal.trim();
562            let string_steps = parse_script(text, test_lower)
563                .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
564            let braced_steps = parse_braced_tokens(tokens, test_lower)
565                .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
566            assert_eq!(
567                string_steps, braced_steps,
568                "AST mismatch for case {idx} literal:\n{text}"
569            );
570        }
571    }
572
573    #[test]
574    fn let_assign_with_bare_word() {
575        let script = r#"LET $x = hello"#;
576        let steps = parse_script(script, test_lower).expect("parse ok");
577        assert_eq!(steps.len(), 1);
578        match &steps[0].kind {
579            StepKind::Assign { var, expr } => {
580                assert_eq!(var, "x");
581                assert_eq!(expr, &Expr::Literal(Value::String("hello".to_string())));
582            }
583            other => panic!("expected Assign, got {:?}", other),
584        }
585    }
586
587    #[test]
588    fn let_assign_with_quoted_string() {
589        let script = r#"LET $x = "hello world""#;
590        let steps = parse_script(script, test_lower).expect("parse ok");
591        assert_eq!(steps.len(), 1);
592        match &steps[0].kind {
593            StepKind::Assign { var, expr } => {
594                assert_eq!(var, "x");
595                assert_eq!(
596                    expr,
597                    &Expr::Literal(Value::String("hello world".to_string()))
598                );
599            }
600            other => panic!("expected Assign, got {:?}", other),
601        }
602    }
603
604    #[test]
605    fn let_assign_with_list_literal() {
606        let script = r#"LET $x = ["a", "b", "c"]"#;
607        let steps = parse_script(script, test_lower).expect("parse ok");
608        assert_eq!(steps.len(), 1);
609        match &steps[0].kind {
610            StepKind::Assign { var, expr } => {
611                assert_eq!(var, "x");
612                assert_eq!(
613                    expr,
614                    &Expr::List(vec![
615                        Expr::Literal(Value::String("a".to_string())),
616                        Expr::Literal(Value::String("b".to_string())),
617                        Expr::Literal(Value::String("c".to_string()))
618                    ])
619                );
620            }
621            other => panic!("expected Assign, got {:?}", other),
622        }
623    }
624
625    #[test]
626    fn let_assign_with_variable_ref() {
627        let script = r#"LET $x = $y"#;
628        let steps = parse_script(script, test_lower).expect("parse ok");
629        assert_eq!(steps.len(), 1);
630        match &steps[0].kind {
631            StepKind::Assign { var, expr } => {
632                assert_eq!(var, "x");
633                assert_eq!(expr, &Expr::Var("y".to_string()));
634            }
635            other => panic!("expected Assign, got {:?}", other),
636        }
637    }
638
639    #[test]
640    fn for_loop_parses() {
641        let script = indoc! {r#"
642            FOR $f IN ["x", "y"] {
643                WRITE $f
644            }
645        "#};
646        let steps = parse_script(script, test_lower).expect("parse ok");
647        assert_eq!(steps.len(), 1);
648        match &steps[0].kind {
649            StepKind::For {
650                key_var,
651                var,
652                in_expr,
653                body,
654            } => {
655                assert!(key_var.is_none());
656                assert_eq!(var, "f");
657                assert_eq!(
658                    in_expr,
659                    &Expr::List(vec![
660                        Expr::Literal(Value::String("x".to_string())),
661                        Expr::Literal(Value::String("y".to_string()))
662                    ])
663                );
664                assert_eq!(body.len(), 1);
665            }
666            other => panic!("expected For, got {:?}", other),
667        }
668    }
669
670    #[test]
671    fn for_map_iteration_parses() {
672        let script = indoc! {r#"
673            FOR $k, $v IN $map {
674                WRITE $k
675            }
676        "#};
677        let steps = parse_script(script, test_lower).expect("parse ok");
678        assert_eq!(steps.len(), 1);
679        match &steps[0].kind {
680            StepKind::For {
681                key_var,
682                var,
683                in_expr,
684                body,
685            } => {
686                assert_eq!(key_var.as_deref(), Some("k"));
687                assert_eq!(var, "v");
688                assert_eq!(in_expr, &Expr::Var("map".to_string()));
689                assert_eq!(body.len(), 1);
690            }
691            other => panic!("expected For, got {:?}", other),
692        }
693    }
694
695    #[test]
696    fn if_statement_parses() {
697        let script = "IF true { WRITE yes }\n";
698        let steps = parse_script(script, test_lower).expect("parse ok");
699        assert_eq!(steps.len(), 1);
700        match &steps[0].kind {
701            StepKind::If { .. } => {}
702            other => panic!("expected If, got {:?}", other),
703        }
704    }
705
706    #[test]
707    fn if_keyword_matches_directly() {
708        use crate::lexer::{LanguageParser, Rule};
709        use pest::Parser;
710        let result = LanguageParser::parse(Rule::if_keyword, "IF ");
711        assert!(
712            result.is_ok(),
713            "if_keyword should match 'IF ': {:?}",
714            result.err()
715        );
716    }
717
718    #[test]
719    fn if_statement_pest_matches() {
720        use crate::lexer::{LanguageParser, Rule};
721        use pest::Parser;
722        let result = LanguageParser::parse(Rule::if_statement, "IF true {\n    WRITE yes\n}");
723        assert!(
724            result.is_ok(),
725            "if_statement should match: {:?}",
726            result.err()
727        );
728    }
729
730    #[test]
731    fn not_expression_parses() {
732        let script = r#"LET $x = !true"#;
733        let steps = parse_script(script, test_lower).expect("parse ok");
734        match &steps[0].kind {
735            StepKind::Assign { var, expr } => {
736                assert_eq!(var, "x");
737                assert_eq!(expr, &Expr::Not(Box::new(Expr::Literal(Value::Bool(true)))));
738            }
739            other => panic!("expected Assign, got {:?}", other),
740        }
741
742        // Double negation nests.
743        let steps = parse_script(r#"LET $x = !!false"#, test_lower).expect("parse ok");
744        match &steps[0].kind {
745            StepKind::Assign { expr, .. } => {
746                assert_eq!(
747                    expr,
748                    &Expr::Not(Box::new(Expr::Not(Box::new(Expr::Literal(Value::Bool(
749                        false
750                    ))))))
751                );
752            }
753            other => panic!("expected Assign, got {:?}", other),
754        }
755
756        // `!` binds tighter than `==`: `!true == false` is `(!true) == false`.
757        let steps = parse_script(r#"LET $x = !true == false"#, test_lower).expect("parse ok");
758        match &steps[0].kind {
759            StepKind::Assign { expr, .. } => {
760                assert!(matches!(expr, Expr::Compare { .. }), "got {expr:?}");
761                if let Expr::Compare { left, .. } = expr {
762                    assert!(matches!(left.as_ref(), Expr::Not(_)), "got {left:?}");
763                }
764            }
765            other => panic!("expected Assign, got {:?}", other),
766        }
767
768        // Parentheses invert the grouping: `!(true == false)`.
769        let steps = parse_script(r#"LET $x = !(true == false)"#, test_lower).expect("parse ok");
770        match &steps[0].kind {
771            StepKind::Assign { expr, .. } => {
772                assert!(matches!(expr, Expr::Not(_)), "got {expr:?}");
773            }
774            other => panic!("expected Assign, got {:?}", other),
775        }
776    }
777
778    #[test]
779    fn not_expression_display_round_trips() {
780        for script in [
781            "LET $x = !true",
782            "LET $x = !!false",
783            "LET $x = !(true == false)",
784            "IF !true {\n    WRITE yes\n}",
785        ] {
786            let steps = parse_script(script, test_lower).expect("parse");
787            let rendered: Vec<String> = steps.iter().map(|s| s.to_string()).collect();
788            let reparsed = parse_script(&rendered.join("\n"), test_lower).expect("reparse");
789            assert_eq!(steps, reparsed, "Display round-trip failed for {script}");
790        }
791    }
792
793    #[test]
794    fn guard_block_with_mock_command() {
795        let script = "CWD\n[env:GATE] {\n    WRITE gated\n}\n[eq(env:A, 1)] WRITE eq\n";
796        let steps = parse_script(script, test_lower).expect("parse should succeed");
797        assert!(
798            steps.len() >= 2,
799            "expected at least 2 steps, got {}",
800            steps.len()
801        );
802    }
803
804    #[test]
805    fn single_line_blocks() {
806        let test_cases = [
807            "IF true { WRITE \"hello\" }",
808            "IF true { WRITE \"cargo test\" }",
809            "IF true { WRITE \"/app\" }",
810        ];
811        for script in test_cases {
812            assert!(
813                parse_script(script, test_lower).is_ok(),
814                "Failed to parse: {}",
815                script
816            );
817        }
818    }
819
820    #[test]
821    fn async_run_parses() {
822        let script = "ASYNC RUN \"echo hello\"";
823        let steps = parse_script(script, test_lower).expect("parse should succeed");
824        assert_eq!(steps.len(), 1);
825        assert!(matches!(&steps[0].kind, StepKind::AsyncBlock { .. }));
826    }
827
828    #[test]
829    fn async_block_parses() {
830        let script = indoc! {r#"
831            ASYNC {
832                RUN "echo one"
833                RUN "echo two"
834            }
835        "#};
836        let steps = parse_script(script, test_lower).expect("parse should succeed");
837        assert_eq!(steps.len(), 1);
838        match &steps[0].kind {
839            StepKind::AsyncBlock { body } => {
840                assert_eq!(body.len(), 2);
841            }
842            other => panic!("expected AsyncBlock, got {:?}", other),
843        }
844    }
845
846    #[test]
847    fn nested_async_parses() {
848        let script = "ASYNC ASYNC RUN \"echo nested\"";
849        let steps = parse_script(script, test_lower).expect("parse should succeed");
850        assert_eq!(steps.len(), 1);
851        match &steps[0].kind {
852            StepKind::AsyncBlock { body } => {
853                assert_eq!(body.len(), 1);
854                match &body[0].kind {
855                    StepKind::AsyncBlock { body } => {
856                        assert_eq!(body.len(), 1);
857                        assert!(matches!(&body[0].kind, StepKind::Run(_)));
858                    }
859                    other => panic!("expected inner AsyncBlock, got {:?}", other),
860                }
861            }
862            other => panic!("expected outer AsyncBlock, got {:?}", other),
863        }
864    }
865
866    #[test]
867    fn nested_async_block_form_parses() {
868        let script = indoc! {r#"
869            ASYNC {
870                ASYNC {
871                    RUN "echo nested"
872                }
873            }
874        "#};
875        let steps = parse_script(script, test_lower).expect("parse should succeed");
876        assert_eq!(steps.len(), 1);
877        match &steps[0].kind {
878            StepKind::AsyncBlock { body } => {
879                assert_eq!(body.len(), 1);
880                match &body[0].kind {
881                    StepKind::AsyncBlock { body } => {
882                        assert_eq!(body.len(), 1);
883                        assert!(matches!(&body[0].kind, StepKind::Run(_)));
884                    }
885                    other => panic!("expected inner AsyncBlock, got {:?}", other),
886                }
887            }
888            other => panic!("expected outer AsyncBlock, got {:?}", other),
889        }
890    }
891
892    #[test]
893    fn with_io_wrapping_async_parses() {
894        let script = "WITH_IO [stdout] ASYNC RUN \"echo test\"";
895        let steps = parse_script(script, test_lower).expect("parse should succeed");
896        assert_eq!(steps.len(), 1);
897        match &steps[0].kind {
898            StepKind::WithIo { cmd, .. } => {
899                assert!(matches!(cmd.as_ref(), StepKind::AsyncBlock { .. }));
900            }
901            other => panic!("expected WithIo, got {:?}", other),
902        }
903    }
904
905    #[test]
906    fn with_io_async_block_nested_for_parses() {
907        // Regression: structural statements nested inside a WITH_IO-wrapped
908        // ASYNC block must parse. WITH_IO is compound-atomic (implicit
909        // whitespace suppressed), so nested rules carry explicit gaps.
910        let script = indoc! {r#"
911            WITH_IO [stdout=pipe:out] ASYNC {
912                FOR $x IN [0, 1] {
913                    ECHO hi
914                }
915            }
916        "#};
917        let steps = parse_script(script, test_lower).expect("parse should succeed");
918        assert_eq!(steps.len(), 1);
919        match &steps[0].kind {
920            StepKind::WithIo { bindings, cmd } => {
921                assert_eq!(bindings.len(), 1);
922                assert!(matches!(bindings[0].stream, IoStream::Stdout));
923                assert_eq!(bindings[0].pipe.as_deref(), Some("out"));
924                match cmd.as_ref() {
925                    StepKind::AsyncBlock { body } => {
926                        assert_eq!(body.len(), 1);
927                        match &body[0].kind {
928                            StepKind::For { var, body, .. } => {
929                                assert_eq!(var, "x");
930                                assert_eq!(body.len(), 1);
931                                assert!(matches!(&body[0].kind, StepKind::Echo(_)));
932                            }
933                            other => panic!("expected For, got {:?}", other),
934                        }
935                    }
936                    other => panic!("expected AsyncBlock, got {:?}", other),
937                }
938            }
939            other => panic!("expected WithIo, got {:?}", other),
940        }
941    }
942
943    #[test]
944    fn with_io_async_block_nested_if_else_parses() {
945        // Spaced comparison and ELSE chain inside a WITH_IO-wrapped block.
946        let script = indoc! {r#"
947            WITH_IO [stdout=pipe:out] ASYNC {
948                IF $a == $b {
949                    ECHO yes
950                } ELSE {
951                    ECHO no
952                }
953            }
954        "#};
955        let steps = parse_script(script, test_lower).expect("parse should succeed");
956        match &steps[0].kind {
957            StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
958                StepKind::AsyncBlock { body } => {
959                    assert!(matches!(&body[0].kind, StepKind::If { .. }));
960                    match &body[0].kind {
961                        StepKind::If { else_body, .. } => {
962                            assert_eq!(else_body.as_ref().map(Vec::len), Some(1));
963                        }
964                        other => panic!("expected If, got {:?}", other),
965                    }
966                }
967                other => panic!("expected AsyncBlock, got {:?}", other),
968            },
969            other => panic!("expected WithIo, got {:?}", other),
970        }
971    }
972
973    #[test]
974    fn timeout_block_nested_for_parses() {
975        // TIMEOUT is compound-atomic too; nested structural statements must
976        // parse inside its block form.
977        let script = indoc! {r#"
978            TIMEOUT 30s {
979                FOR $x IN [1] {
980                    ECHO hi
981                }
982            }
983        "#};
984        let steps = parse_script(script, test_lower).expect("parse should succeed");
985        match &steps[0].kind {
986            StepKind::Timeout { body, .. } => {
987                assert_eq!(body.len(), 1);
988                assert!(matches!(&body[0].kind, StepKind::For { .. }));
989            }
990            other => panic!("expected Timeout, got {:?}", other),
991        }
992    }
993
994    #[test]
995    fn with_io_async_block_nested_let_map_parses() {
996        // LET with a spaced map literal inside a WITH_IO-wrapped block.
997        let script = indoc! {r#"
998            WITH_IO [stdout] ASYNC {
999                LET $m = {a: 1, b: 2}
1000            }
1001        "#};
1002        let steps = parse_script(script, test_lower).expect("parse should succeed");
1003        match &steps[0].kind {
1004            StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1005                StepKind::AsyncBlock { body } => {
1006                    assert!(matches!(&body[0].kind, StepKind::Assign { .. }));
1007                }
1008                other => panic!("expected AsyncBlock, got {:?}", other),
1009            },
1010            other => panic!("expected WithIo, got {:?}", other),
1011        }
1012    }
1013
1014    #[test]
1015    fn variable_sigil_binds_tightly() {
1016        // `$` and its identifier are one atomic unit: whitespace between the
1017        // sigil and the name must not parse as a variable reference. (In
1018        // argument position this already failed; in expression position the
1019        // old non-atomic `variable` rule accepted `$   y` via implicit
1020        // whitespace.)
1021        parse_script("ECHO $   x", test_lower).expect_err("spaced sigil must fail");
1022        parse_script("LET $x = $   y", test_lower).expect_err("spaced sigil must fail");
1023        let steps = parse_script("ECHO $x", test_lower).expect("tight sigil parses");
1024        assert!(matches!(&steps[0].kind, StepKind::Echo(_)));
1025    }
1026
1027    #[test]
1028    fn let_async_block_parses() {
1029        let script = indoc! {r#"
1030            LET $task = ASYNC {
1031                RUN "echo hello"
1032            }
1033        "#};
1034        let steps = parse_script(script, test_lower).expect("parse should succeed");
1035        assert_eq!(steps.len(), 1);
1036        match &steps[0].kind {
1037            StepKind::AssignAsync { var, body } => {
1038                assert_eq!(var, "task");
1039                assert_eq!(body.len(), 1);
1040                assert!(matches!(&body[0].kind, StepKind::Run(_)));
1041            }
1042            other => panic!("expected AssignAsync, got {:?}", other),
1043        }
1044    }
1045
1046    #[test]
1047    fn let_async_inline_parses() {
1048        let script = "LET $t = ASYNC RUN \"echo hi\"";
1049        let steps = parse_script(script, test_lower).expect("parse should succeed");
1050        assert_eq!(steps.len(), 1);
1051        match &steps[0].kind {
1052            StepKind::AssignAsync { var, body } => {
1053                assert_eq!(var, "t");
1054                assert_eq!(body.len(), 1);
1055                assert!(matches!(&body[0].kind, StepKind::Run(_)));
1056            }
1057            other => panic!("expected AssignAsync, got {:?}", other),
1058        }
1059    }
1060
1061    #[test]
1062    fn await_parses() {
1063        let script = "AWAIT $task";
1064        let steps = parse_script(script, test_lower).expect("parse should succeed");
1065        assert_eq!(steps.len(), 1);
1066        match &steps[0].kind {
1067            StepKind::Await { var } => {
1068                assert_eq!(var, "task");
1069            }
1070            other => panic!("expected Await, got {:?}", other),
1071        }
1072    }
1073}