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