Skip to main content

oxdock_parser/
lib.rs

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