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!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdout)
246                    && b.pipe == Some(PipeTarget::Name("setup".to_string()))));
247                assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stderr)
248                    && b.pipe == Some(PipeTarget::Name("errors".to_string()))));
249                assert!(matches!(cmd.as_ref(), StepKind::Write { .. }));
250            }
251            other => panic!("expected WITH_IO, saw {:?}", other),
252        }
253    }
254
255    #[test]
256    fn with_io_supports_variable_pipes() {
257        let script = "WITH_IO [stdout=$p, stdin=pipe:in] WRITE \"echo hi\"";
258        let steps = parse_script(script, test_lower).expect("parse ok");
259        assert_eq!(steps.len(), 1);
260        match &steps[0].kind {
261            StepKind::WithIo { bindings, cmd } => {
262                assert_eq!(bindings.len(), 2);
263                assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdout)
264                    && b.pipe == Some(PipeTarget::Var("p".to_string()))));
265                assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdin)
266                    && b.pipe == Some(PipeTarget::Name("in".to_string()))));
267                assert!(matches!(cmd.as_ref(), StepKind::Write { .. }));
268            }
269            other => panic!("expected WITH_IO, saw {:?}", other),
270        }
271        // Display round-trips the variable form.
272        assert_eq!(
273            steps[0].kind.to_string(),
274            "WITH_IO [stdout=$p, stdin=pipe:in] WRITE \"echo hi\""
275        );
276    }
277
278    #[test]
279    fn brace_blocks_require_guard() {
280        let script = indoc! {r#"
281            {
282                WRITE nope.txt hi
283            }
284        "#};
285        parse_script(script, test_lower).expect_err("unguarded block should fail");
286    }
287
288    #[test]
289    fn multi_line_guard_blocks_apply_to_next_command() {
290        let script = indoc! {r#"
291            [
292                env:A,
293                env:B
294            ]
295            WRITE "echo guarded"
296        "#};
297        let steps = parse_script(script, test_lower).expect("parse ok");
298        assert_eq!(steps.len(), 1);
299        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
300    }
301
302    #[test]
303    fn guarded_brace_blocks_apply_to_all_inner_steps() {
304        let script = indoc! {r#"
305            [env:A] {
306                WRITE one.txt 1
307                WRITE two.txt 2
308            }
309        "#};
310        let steps = parse_script(script, test_lower).expect("parse ok");
311        assert_eq!(steps.len(), 2);
312        assert!(steps.iter().all(|s| s.guard.is_some()));
313    }
314
315    #[test]
316    fn nested_guard_blocks_stack() {
317        let script = indoc! {r#"
318            [env:A] {
319                WRITE outer.txt no
320                [env:B] {
321                    WRITE nested.txt yes
322                }
323            }
324        "#};
325        let steps = parse_script(script, test_lower).expect("parse ok");
326        assert_eq!(steps.len(), 2);
327        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A"));
328        assert_eq!(guard_text(&steps[1]).as_deref(), Some("env:A, env:B"));
329    }
330
331    #[test]
332    fn nested_guard_block_scopes_stack_counts() {
333        let script = indoc! {r#"
334            [env:A] {
335                WRITE outer.txt ok
336                [env:B] {
337                    WRITE deep.txt ok
338                }
339                WRITE outer_again.txt ok
340            }
341        "#};
342        let steps = parse_script(script, test_lower).expect("parse ok");
343        assert_eq!(steps.len(), 3);
344        assert_eq!(steps[0].scope_enter, 1);
345        assert_eq!(steps[0].scope_exit, 0);
346        assert_eq!(steps[1].scope_enter, 1);
347        assert_eq!(steps[1].scope_exit, 1);
348        assert_eq!(steps[2].scope_enter, 0);
349        assert_eq!(steps[2].scope_exit, 1);
350    }
351
352    #[test]
353    fn guard_or_and_and_compose_as_expected() {
354        let script = indoc! {r#"
355            [env:A]
356            [any(env:B, env:C)]
357            WRITE "echo complex"
358        "#};
359        let steps = parse_script(script, test_lower).expect("parse ok");
360        assert_eq!(steps.len(), 1);
361        let guard = steps[0].guard.as_ref().expect("missing guard");
362        assert_eq!(guard.to_string(), "env:A, any(env:B, env:C)");
363
364        let mut env = HashMap::new();
365        env.insert("A".into(), "1".into());
366        env.insert("B".into(), "1".into());
367        assert!(guard_expr_allows(guard, &env), "A && B should pass");
368
369        env.remove("B");
370        env.insert("C".into(), "1".into());
371        assert!(guard_expr_allows(guard, &env), "A && C should pass");
372
373        env.remove("C");
374        assert!(!guard_expr_allows(guard, &env), "A without B/C should fail");
375    }
376
377    #[test]
378    fn guard_or_requires_at_least_one_branch() {
379        let expr = GuardExpr::or(vec![
380            Guard::EnvExists {
381                key: "MISSING".into(),
382            }
383            .into(),
384            Guard::EnvExists {
385                key: "ALSO_MISSING".into(),
386            }
387            .into(),
388        ]);
389        assert!(!guard_expr_allows(&expr, &HashMap::new()));
390        let mut env = HashMap::new();
391        env.insert("MISSING".into(), "1".into());
392        assert!(guard_expr_allows(&expr, &env));
393    }
394
395    #[test]
396    fn guard_or_can_chain_with_additional_predicates() {
397        let script = "[any(env:A, linux), mac] WRITE \"echo hi\"";
398        let steps = parse_script(script, test_lower).expect("parse ok");
399        assert_eq!(steps.len(), 1);
400        let guard = steps[0].guard.as_ref().expect("missing guard");
401        assert_eq!(guard.to_string(), "any(env:A, linux), macos");
402        let GuardExpr::All(children) = guard else {
403            panic!("expected ALL guard");
404        };
405        assert!(matches!(children[0], GuardExpr::Or(_)));
406        match &children[1] {
407            GuardExpr::Predicate(Guard::Platform {
408                target: PlatformGuard::Macos,
409            }) => {}
410            other => panic!("unexpected trailing guard: {other:?}"),
411        }
412    }
413
414    #[test]
415    fn guard_or_guard_line_parses() {
416        use crate::lexer::{LanguageParser, Rule};
417        use pest::Parser;
418        LanguageParser::parse(Rule::guard_line, "[any(linux, env:FOO)]")
419            .expect("guard guard line should parse");
420    }
421
422    #[test]
423    fn env_equals_guard_with_not_wrapper() {
424        let g = GuardExpr::Not(Box::new(GuardExpr::Predicate(Guard::EnvEquals {
425            key: "A".into(),
426            value: "1".into(),
427        })));
428        let mut env = HashMap::new();
429        env.insert("A".into(), "1".into());
430        assert!(!guard_expr_allows(&g, &env));
431        env.insert("A".into(), "2".into());
432        assert!(guard_expr_allows(&g, &env));
433    }
434
435    #[test]
436    fn guard_block_emits_scope_markers() {
437        let script = indoc! {r#"
438            ENV RUN=1
439            [env:RUN] {
440                WRITE one.txt 1
441                WRITE two.txt 2
442            }
443            WRITE three.txt 3
444        "#};
445        let steps = parse_script(script, test_lower).expect("parse ok");
446        assert_eq!(steps.len(), 4);
447        assert_eq!(steps[1].scope_enter, 1);
448        assert_eq!(steps[1].scope_exit, 0);
449        assert_eq!(steps[2].scope_enter, 0);
450        assert_eq!(steps[2].scope_exit, 1);
451        assert_eq!(steps[3].scope_enter, 0);
452        assert_eq!(steps[3].scope_exit, 0);
453    }
454
455    #[test]
456    fn mock_hash_form_parses() {
457        let script = "HASH_SHA256 --hash aabb path.txt";
458        let steps = parse_script(script, test_lower).expect("parse ok");
459        match &steps[0].kind {
460            StepKind::AssertFile {
461                hash,
462                path,
463                contents,
464            } => {
465                assert_eq!(hash.as_deref(), Some("aabb"));
466                assert_eq!(path.as_ref(), "path.txt");
467                assert!(contents.is_none());
468            }
469            other => panic!("expected AssertFile, saw {:?}", other),
470        }
471    }
472
473    #[test]
474    fn mock_commands_parse_and_round_trip() {
475        let script = indoc! {r#"
476            WRITE "dist/hello.txt" "Built with OxDock"
477            WRITE "deeply/nested/tree"
478            WRITE "chained.txt"
479            WRITE "visible-after-comments"
480        "#};
481        let steps = parse_script(script, test_lower).expect("parse ok");
482        assert_eq!(steps.len(), 4);
483
484        // Verify each step's kind matches what we expect
485        for step in &steps {
486            assert!(
487                matches!(&step.kind, StepKind::Write { .. }),
488                "expected Write variant"
489            );
490        }
491    }
492
493    #[test]
494    fn quoted_string_content_preserved() {
495        let script = "WRITE 'echo \"a; b\"'";
496        let steps = parse_script(script, test_lower).expect("parse ok");
497        match &steps[0].kind {
498            StepKind::Write { path, .. } => assert_eq!(path, "echo \"a; b\""),
499            other => panic!("expected Write, saw {:?}", other),
500        }
501    }
502
503    #[test]
504    fn templated_argument_with_spaces() {
505        let script = "WRITE {{ env:OXBOOK_RUNNER_DIR }}";
506        let steps = parse_script(script, test_lower).expect("parse ok");
507        assert_eq!(steps.len(), 1);
508        match &steps[0].kind {
509            StepKind::Write { path, .. } => assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}"),
510            other => panic!("expected Write, saw {:?}", other),
511        }
512    }
513
514    #[test]
515    #[cfg(feature = "proc-macro-api")]
516    fn string_and_braced_scripts_produce_identical_ast() {
517        let mut cases = Vec::new();
518
519        cases.push((
520            indoc! {r#"
521                WRITE /tmp
522                WRITE hello
523            "#}
524            .trim()
525            .to_string(),
526            quote! {
527                WRITE /tmp
528                WRITE hello
529            },
530        ));
531
532        cases.push((
533            indoc! {r#"
534                [not(env:SKIP)]
535                [windows] WRITE win
536                [eq(env:MODE, beta), linux] WRITE combo
537            "#}
538            .trim()
539            .to_string(),
540            quote! {
541                [not(env:SKIP)]
542                [windows] WRITE win
543                [eq(env:MODE, beta), linux] WRITE combo
544            },
545        ));
546
547        cases.push((
548            indoc! {r#"
549                [env:OUTER] {
550                    WRITE nested
551                    [env:INNER] WRITE deep
552                }
553            "#}
554            .trim()
555            .to_string(),
556            quote! {
557                [env:OUTER] {
558                    WRITE nested
559                    [env:INNER] WRITE deep
560                }
561            },
562        ));
563
564        cases.push((
565            indoc! {r#"
566                [eq(env:TEST, 1)]
567                WITH_IO [stdout=pipe:capture_case] WRITE hi
568                WITH_IO [stdin=pipe:capture_case] WRITE out.txt
569            "#}
570            .trim()
571            .to_string(),
572            quote! {
573                [eq(env:TEST, 1)]
574                WITH_IO [stdout=pipe:capture_case] WRITE hi
575                WITH_IO [stdin=pipe:capture_case] WRITE out.txt
576            },
577        ));
578
579        for (idx, (literal, tokens)) in cases.iter().enumerate() {
580            let text = literal.trim();
581            let string_steps = parse_script(text, test_lower)
582                .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
583            let braced_steps = parse_braced_tokens(tokens, test_lower)
584                .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
585            assert_eq!(
586                string_steps, braced_steps,
587                "AST mismatch for case {idx} literal:\n{text}"
588            );
589        }
590    }
591
592    #[test]
593    fn let_assign_with_bare_word() {
594        let script = r#"LET $x: STRING = hello"#;
595        let steps = parse_script(script, test_lower).expect("parse ok");
596        assert_eq!(steps.len(), 1);
597        match &steps[0].kind {
598            StepKind::Assign {
599                var,
600                decl_type: _,
601                expr,
602            } => {
603                assert_eq!(var, "x");
604                assert_eq!(expr, &Expr::Literal(Value::String("hello".to_string())));
605            }
606            other => panic!("expected Assign, got {:?}", other),
607        }
608    }
609
610    #[test]
611    fn let_assign_with_quoted_string() {
612        let script = r#"LET $x: STRING = "hello world""#;
613        let steps = parse_script(script, test_lower).expect("parse ok");
614        assert_eq!(steps.len(), 1);
615        match &steps[0].kind {
616            StepKind::Assign {
617                var,
618                decl_type: _,
619                expr,
620            } => {
621                assert_eq!(var, "x");
622                assert_eq!(
623                    expr,
624                    &Expr::Literal(Value::String("hello world".to_string()))
625                );
626            }
627            other => panic!("expected Assign, got {:?}", other),
628        }
629    }
630
631    #[test]
632    fn let_assign_with_list_literal() {
633        let script = r#"LET $x: LIST = ["a", "b", "c"]"#;
634        let steps = parse_script(script, test_lower).expect("parse ok");
635        assert_eq!(steps.len(), 1);
636        match &steps[0].kind {
637            StepKind::Assign {
638                var,
639                decl_type: _,
640                expr,
641            } => {
642                assert_eq!(var, "x");
643                assert_eq!(
644                    expr,
645                    &Expr::List(vec![
646                        Expr::Literal(Value::String("a".to_string())),
647                        Expr::Literal(Value::String("b".to_string())),
648                        Expr::Literal(Value::String("c".to_string()))
649                    ])
650                );
651            }
652            other => panic!("expected Assign, got {:?}", other),
653        }
654    }
655
656    #[test]
657    fn let_assign_with_variable_ref() {
658        let script = r#"LET $x: STRING = $y"#;
659        let steps = parse_script(script, test_lower).expect("parse ok");
660        assert_eq!(steps.len(), 1);
661        match &steps[0].kind {
662            StepKind::Assign {
663                var,
664                decl_type: _,
665                expr,
666            } => {
667                assert_eq!(var, "x");
668                assert_eq!(expr, &Expr::Var("y".to_string()));
669            }
670            other => panic!("expected Assign, got {:?}", other),
671        }
672    }
673
674    #[test]
675    fn for_loop_parses() {
676        let script = indoc! {r#"
677            FOR $f: STRING IN ["x", "y"] {
678                WRITE $f
679            }
680        "#};
681        let steps = parse_script(script, test_lower).expect("parse ok");
682        assert_eq!(steps.len(), 1);
683        match &steps[0].kind {
684            StepKind::For {
685                key_var,
686                var,
687                in_expr,
688                body,
689                ..
690            } => {
691                assert!(key_var.is_none());
692                assert_eq!(var, "f");
693                assert_eq!(
694                    in_expr,
695                    &Expr::List(vec![
696                        Expr::Literal(Value::String("x".to_string())),
697                        Expr::Literal(Value::String("y".to_string()))
698                    ])
699                );
700                assert_eq!(body.len(), 1);
701            }
702            other => panic!("expected For, got {:?}", other),
703        }
704    }
705
706    #[test]
707    fn for_map_iteration_parses() {
708        let script = indoc! {r#"
709            FOR $k: STRING, $v: STRING IN $map {
710                WRITE $k
711            }
712        "#};
713        let steps = parse_script(script, test_lower).expect("parse ok");
714        assert_eq!(steps.len(), 1);
715        match &steps[0].kind {
716            StepKind::For {
717                key_var,
718                var,
719                in_expr,
720                body,
721                ..
722            } => {
723                assert_eq!(key_var.as_deref(), Some("k"));
724                assert_eq!(var, "v");
725                assert_eq!(in_expr, &Expr::Var("map".to_string()));
726                assert_eq!(body.len(), 1);
727            }
728            other => panic!("expected For, got {:?}", other),
729        }
730    }
731
732    #[test]
733    fn if_statement_parses() {
734        let script = "IF true { WRITE yes }\n";
735        let steps = parse_script(script, test_lower).expect("parse ok");
736        assert_eq!(steps.len(), 1);
737        match &steps[0].kind {
738            StepKind::If { .. } => {}
739            other => panic!("expected If, got {:?}", other),
740        }
741    }
742
743    #[test]
744    fn if_keyword_matches_directly() {
745        use crate::lexer::{LanguageParser, Rule};
746        use pest::Parser;
747        let result = LanguageParser::parse(Rule::if_keyword, "IF ");
748        assert!(
749            result.is_ok(),
750            "if_keyword should match 'IF ': {:?}",
751            result.err()
752        );
753    }
754
755    #[test]
756    fn if_statement_pest_matches() {
757        use crate::lexer::{LanguageParser, Rule};
758        use pest::Parser;
759        let result = LanguageParser::parse(Rule::if_statement, "IF true {\n    WRITE yes\n}");
760        assert!(
761            result.is_ok(),
762            "if_statement should match: {:?}",
763            result.err()
764        );
765    }
766
767    #[test]
768    fn not_expression_parses() {
769        let script = r#"LET $x: BOOL = !true"#;
770        let steps = parse_script(script, test_lower).expect("parse ok");
771        match &steps[0].kind {
772            StepKind::Assign {
773                var,
774                decl_type: _,
775                expr,
776            } => {
777                assert_eq!(var, "x");
778                assert_eq!(expr, &Expr::Not(Box::new(Expr::Literal(Value::Bool(true)))));
779            }
780            other => panic!("expected Assign, got {:?}", other),
781        }
782
783        // Double negation nests.
784        let steps = parse_script(r#"LET $x: BOOL = !!false"#, test_lower).expect("parse ok");
785        match &steps[0].kind {
786            StepKind::Assign { expr, .. } => {
787                assert_eq!(
788                    expr,
789                    &Expr::Not(Box::new(Expr::Not(Box::new(Expr::Literal(Value::Bool(
790                        false
791                    ))))))
792                );
793            }
794            other => panic!("expected Assign, got {:?}", other),
795        }
796
797        // `!` binds tighter than `==`: `!true == false` is `(!true) == false`.
798        let steps = parse_script(r#"LET $x: BOOL = !true == false"#, test_lower).expect("parse ok");
799        match &steps[0].kind {
800            StepKind::Assign { expr, .. } => {
801                assert!(matches!(expr, Expr::Compare { .. }), "got {expr:?}");
802                if let Expr::Compare { left, .. } = expr {
803                    assert!(matches!(left.as_ref(), Expr::Not(_)), "got {left:?}");
804                }
805            }
806            other => panic!("expected Assign, got {:?}", other),
807        }
808
809        // Parentheses invert the grouping: `!(true == false)`.
810        let steps =
811            parse_script(r#"LET $x: BOOL = !(true == false)"#, test_lower).expect("parse ok");
812        match &steps[0].kind {
813            StepKind::Assign { expr, .. } => {
814                assert!(matches!(expr, Expr::Not(_)), "got {expr:?}");
815            }
816            other => panic!("expected Assign, got {:?}", other),
817        }
818    }
819
820    #[test]
821    fn not_expression_display_round_trips() {
822        for script in [
823            "LET $x: BOOL = !true",
824            "LET $x: BOOL = !!false",
825            "LET $x: BOOL = !(true == false)",
826            "IF !true {\n    WRITE yes\n}",
827        ] {
828            let steps = parse_script(script, test_lower).expect("parse");
829            let rendered: Vec<String> = steps.iter().map(|s| s.to_string()).collect();
830            let reparsed = parse_script(&rendered.join("\n"), test_lower).expect("reparse");
831            assert_eq!(steps, reparsed, "Display round-trip failed for {script}");
832        }
833    }
834
835    #[test]
836    fn guard_block_with_mock_command() {
837        let script = "CWD\n[env:GATE] {\n    WRITE gated\n}\n[eq(env:A, 1)] WRITE eq\n";
838        let steps = parse_script(script, test_lower).expect("parse should succeed");
839        assert!(
840            steps.len() >= 2,
841            "expected at least 2 steps, got {}",
842            steps.len()
843        );
844    }
845
846    #[test]
847    fn single_line_blocks() {
848        let test_cases = [
849            "IF true { WRITE \"hello\" }",
850            "IF true { WRITE \"cargo test\" }",
851            "IF true { WRITE \"/app\" }",
852        ];
853        for script in test_cases {
854            assert!(
855                parse_script(script, test_lower).is_ok(),
856                "Failed to parse: {}",
857                script
858            );
859        }
860    }
861
862    #[test]
863    fn async_run_parses() {
864        let script = "ASYNC RUN \"echo hello\"";
865        let steps = parse_script(script, test_lower).expect("parse should succeed");
866        assert_eq!(steps.len(), 1);
867        assert!(matches!(&steps[0].kind, StepKind::AsyncBlock { .. }));
868    }
869
870    #[test]
871    fn async_block_parses() {
872        let script = indoc! {r#"
873            ASYNC {
874                RUN "echo one"
875                RUN "echo two"
876            }
877        "#};
878        let steps = parse_script(script, test_lower).expect("parse should succeed");
879        assert_eq!(steps.len(), 1);
880        match &steps[0].kind {
881            StepKind::AsyncBlock { body } => {
882                assert_eq!(body.len(), 2);
883            }
884            other => panic!("expected AsyncBlock, got {:?}", other),
885        }
886    }
887
888    #[test]
889    fn nested_async_parses() {
890        let script = "ASYNC ASYNC RUN \"echo nested\"";
891        let steps = parse_script(script, test_lower).expect("parse should succeed");
892        assert_eq!(steps.len(), 1);
893        match &steps[0].kind {
894            StepKind::AsyncBlock { body } => {
895                assert_eq!(body.len(), 1);
896                match &body[0].kind {
897                    StepKind::AsyncBlock { body } => {
898                        assert_eq!(body.len(), 1);
899                        assert!(matches!(&body[0].kind, StepKind::Run(_)));
900                    }
901                    other => panic!("expected inner AsyncBlock, got {:?}", other),
902                }
903            }
904            other => panic!("expected outer AsyncBlock, got {:?}", other),
905        }
906    }
907
908    #[test]
909    fn nested_async_block_form_parses() {
910        let script = indoc! {r#"
911            ASYNC {
912                ASYNC {
913                    RUN "echo nested"
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::AsyncBlock { body } => {
921                assert_eq!(body.len(), 1);
922                match &body[0].kind {
923                    StepKind::AsyncBlock { body } => {
924                        assert_eq!(body.len(), 1);
925                        assert!(matches!(&body[0].kind, StepKind::Run(_)));
926                    }
927                    other => panic!("expected inner AsyncBlock, got {:?}", other),
928                }
929            }
930            other => panic!("expected outer AsyncBlock, got {:?}", other),
931        }
932    }
933
934    #[test]
935    fn with_io_wrapping_async_parses() {
936        let script = "WITH_IO [stdout] ASYNC RUN \"echo test\"";
937        let steps = parse_script(script, test_lower).expect("parse should succeed");
938        assert_eq!(steps.len(), 1);
939        match &steps[0].kind {
940            StepKind::WithIo { cmd, .. } => {
941                assert!(matches!(cmd.as_ref(), StepKind::AsyncBlock { .. }));
942            }
943            other => panic!("expected WithIo, got {:?}", other),
944        }
945    }
946
947    #[test]
948    fn with_io_async_block_nested_for_parses() {
949        // Regression: structural statements nested inside a WITH_IO-wrapped
950        // ASYNC block must parse. WITH_IO is compound-atomic (implicit
951        // whitespace suppressed), so nested rules carry explicit gaps.
952        let script = indoc! {r#"
953            WITH_IO [stdout=pipe:out] ASYNC {
954                FOR $x: INT IN [0, 1] {
955                    ECHO hi
956                }
957            }
958        "#};
959        let steps = parse_script(script, test_lower).expect("parse should succeed");
960        assert_eq!(steps.len(), 1);
961        match &steps[0].kind {
962            StepKind::WithIo { bindings, cmd } => {
963                assert_eq!(bindings.len(), 1);
964                assert!(matches!(bindings[0].stream, IoStream::Stdout));
965                assert_eq!(bindings[0].pipe, Some(PipeTarget::Name("out".to_string())));
966                match cmd.as_ref() {
967                    StepKind::AsyncBlock { body } => {
968                        assert_eq!(body.len(), 1);
969                        match &body[0].kind {
970                            StepKind::For { var, body, .. } => {
971                                assert_eq!(var, "x");
972                                assert_eq!(body.len(), 1);
973                                assert!(matches!(&body[0].kind, StepKind::Echo(_)));
974                            }
975                            other => panic!("expected For, got {:?}", other),
976                        }
977                    }
978                    other => panic!("expected AsyncBlock, got {:?}", other),
979                }
980            }
981            other => panic!("expected WithIo, got {:?}", other),
982        }
983    }
984
985    #[test]
986    fn for_int_key_parses_for_list_enumeration() {
987        let script = indoc! {r#"
988            FOR $i: INT, $v: STRING IN $items {
989                WRITE $v
990            }
991        "#};
992        let steps = parse_script(script, test_lower).expect("parse ok");
993        match &steps[0].kind {
994            StepKind::For {
995                key_var,
996                key_type,
997                var,
998                var_type,
999                ..
1000            } => {
1001                assert_eq!(key_var.as_deref(), Some("i"));
1002                assert_eq!(*key_type, Some(crate::TypeKind::Int));
1003                assert_eq!(var, "v");
1004                assert_eq!(*var_type, crate::TypeKind::String);
1005            }
1006            other => panic!("expected For, got {:?}", other),
1007        }
1008    }
1009
1010    #[test]
1011    fn for_non_index_key_type_is_rejected() {
1012        let err = parse_script(
1013            "FOR $k: BOOL, $v: STRING IN $map { WRITE $v }\n",
1014            test_lower,
1015        )
1016        .expect_err("BOOL key must fail");
1017        assert!(
1018            err.to_string().contains("must be INT or STRING"),
1019            "unexpected error: {err}"
1020        );
1021    }
1022
1023    #[test]
1024    fn with_io_async_block_nested_if_else_parses() {
1025        // Spaced comparison and ELSE chain inside a WITH_IO-wrapped block.
1026        let script = indoc! {r#"
1027            WITH_IO [stdout=pipe:out] ASYNC {
1028                IF $a == $b {
1029                    ECHO yes
1030                } ELSE {
1031                    ECHO no
1032                }
1033            }
1034        "#};
1035        let steps = parse_script(script, test_lower).expect("parse should succeed");
1036        match &steps[0].kind {
1037            StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1038                StepKind::AsyncBlock { body } => {
1039                    assert!(matches!(&body[0].kind, StepKind::If { .. }));
1040                    match &body[0].kind {
1041                        StepKind::If { else_body, .. } => {
1042                            assert_eq!(else_body.as_ref().map(Vec::len), Some(1));
1043                        }
1044                        other => panic!("expected If, got {:?}", other),
1045                    }
1046                }
1047                other => panic!("expected AsyncBlock, got {:?}", other),
1048            },
1049            other => panic!("expected WithIo, got {:?}", other),
1050        }
1051    }
1052
1053    #[test]
1054    fn timeout_block_nested_for_parses() {
1055        // TIMEOUT is compound-atomic too; nested structural statements must
1056        // parse inside its block form.
1057        let script = indoc! {r#"
1058            TIMEOUT 30s {
1059                FOR $x: INT IN [1] {
1060                    ECHO hi
1061                }
1062            }
1063        "#};
1064        let steps = parse_script(script, test_lower).expect("parse should succeed");
1065        match &steps[0].kind {
1066            StepKind::Timeout { body, .. } => {
1067                assert_eq!(body.len(), 1);
1068                assert!(matches!(&body[0].kind, StepKind::For { .. }));
1069            }
1070            other => panic!("expected Timeout, got {:?}", other),
1071        }
1072    }
1073
1074    #[test]
1075    fn with_io_async_block_nested_let_map_parses() {
1076        // LET with a spaced map literal inside a WITH_IO-wrapped block.
1077        let script = indoc! {r#"
1078            WITH_IO [stdout] ASYNC {
1079                LET $m: MAP = {a: 1, b: 2}
1080            }
1081        "#};
1082        let steps = parse_script(script, test_lower).expect("parse should succeed");
1083        match &steps[0].kind {
1084            StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1085                StepKind::AsyncBlock { body } => {
1086                    assert!(matches!(&body[0].kind, StepKind::Assign { .. }));
1087                }
1088                other => panic!("expected AsyncBlock, got {:?}", other),
1089            },
1090            other => panic!("expected WithIo, got {:?}", other),
1091        }
1092    }
1093
1094    #[test]
1095    fn variable_sigil_binds_tightly() {
1096        // `$` and its identifier are one atomic unit: whitespace between the
1097        // sigil and the name must not parse as a variable reference. (In
1098        // argument position this already failed; in expression position the
1099        // old non-atomic `variable` rule accepted `$   y` via implicit
1100        // whitespace.)
1101        parse_script("ECHO $   x", test_lower).expect_err("spaced sigil must fail");
1102        parse_script("LET $x: STRING = $   y", test_lower).expect_err("spaced sigil must fail");
1103        let steps = parse_script("ECHO $x", test_lower).expect("tight sigil parses");
1104        assert!(matches!(&steps[0].kind, StepKind::Echo(_)));
1105    }
1106
1107    #[test]
1108    fn let_async_block_parses() {
1109        let script = indoc! {r#"
1110            LET $task: HANDLE = ASYNC {
1111                RUN "echo hello"
1112            }
1113        "#};
1114        let steps = parse_script(script, test_lower).expect("parse should succeed");
1115        assert_eq!(steps.len(), 1);
1116        match &steps[0].kind {
1117            StepKind::AssignAsync { var, body, .. } => {
1118                assert_eq!(var, "task");
1119                assert_eq!(body.len(), 1);
1120                assert!(matches!(&body[0].kind, StepKind::Run(_)));
1121            }
1122            other => panic!("expected AssignAsync, got {:?}", other),
1123        }
1124    }
1125
1126    #[test]
1127    fn let_async_inline_parses() {
1128        let script = "LET $t: HANDLE = ASYNC RUN \"echo hi\"";
1129        let steps = parse_script(script, test_lower).expect("parse should succeed");
1130        assert_eq!(steps.len(), 1);
1131        match &steps[0].kind {
1132            StepKind::AssignAsync { var, body, .. } => {
1133                assert_eq!(var, "t");
1134                assert_eq!(body.len(), 1);
1135                assert!(matches!(&body[0].kind, StepKind::Run(_)));
1136            }
1137            other => panic!("expected AssignAsync, got {:?}", other),
1138        }
1139    }
1140
1141    #[test]
1142    fn await_parses() {
1143        let script = "AWAIT $task";
1144        let steps = parse_script(script, test_lower).expect("parse should succeed");
1145        assert_eq!(steps.len(), 1);
1146        match &steps[0].kind {
1147            StepKind::Await { var } => {
1148                assert_eq!(var, "task");
1149            }
1150            other => panic!("expected Await, got {:?}", other),
1151        }
1152    }
1153}