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, AssertTarget, 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::AssertEq {
78                        hash: Some(hash),
79                        actual: AssertTarget::Value(path),
80                        expected: 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::AssertEq {
89                        hash: None,
90                        actual: AssertTarget::Value(path),
91                        expected: 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::AssertEq {
461                hash,
462                actual,
463                expected,
464            } => {
465                assert_eq!(hash.as_deref(), Some("aabb"));
466                assert_eq!(
467                    actual,
468                    &AssertTarget::Value(Arg::String("path.txt".to_string(), false))
469                );
470                assert_eq!(expected, &None);
471            }
472            other => panic!("expected AssertEq, saw {:?}", other),
473        }
474    }
475
476    #[test]
477    fn mock_commands_parse_and_round_trip() {
478        let script = indoc! {r#"
479            WRITE "dist/hello.txt" "Built with OxDock"
480            WRITE "deeply/nested/tree"
481            WRITE "chained.txt"
482            WRITE "visible-after-comments"
483        "#};
484        let steps = parse_script(script, test_lower).expect("parse ok");
485        assert_eq!(steps.len(), 4);
486
487        // Verify each step's kind matches what we expect
488        for step in &steps {
489            assert!(
490                matches!(&step.kind, StepKind::Write { .. }),
491                "expected Write variant"
492            );
493        }
494    }
495
496    #[test]
497    fn quoted_string_content_preserved() {
498        let script = "WRITE 'echo \"a; b\"'";
499        let steps = parse_script(script, test_lower).expect("parse ok");
500        match &steps[0].kind {
501            StepKind::Write { path, .. } => assert_eq!(path, "echo \"a; b\""),
502            other => panic!("expected Write, saw {:?}", other),
503        }
504    }
505
506    #[test]
507    fn templated_argument_with_spaces() {
508        let script = "WRITE {{ env:OXBOOK_RUNNER_DIR }}";
509        let steps = parse_script(script, test_lower).expect("parse ok");
510        assert_eq!(steps.len(), 1);
511        match &steps[0].kind {
512            StepKind::Write { path, .. } => assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}"),
513            other => panic!("expected Write, saw {:?}", other),
514        }
515    }
516
517    #[test]
518    #[cfg(feature = "proc-macro-api")]
519    fn string_and_braced_scripts_produce_identical_ast() {
520        let mut cases = Vec::new();
521
522        cases.push((
523            indoc! {r#"
524                WRITE /tmp
525                WRITE hello
526            "#}
527            .trim()
528            .to_string(),
529            quote! {
530                WRITE /tmp
531                WRITE hello
532            },
533        ));
534
535        cases.push((
536            indoc! {r#"
537                [not(env:SKIP)]
538                [windows] WRITE win
539                [eq(env:MODE, beta), linux] WRITE combo
540            "#}
541            .trim()
542            .to_string(),
543            quote! {
544                [not(env:SKIP)]
545                [windows] WRITE win
546                [eq(env:MODE, beta), linux] WRITE combo
547            },
548        ));
549
550        cases.push((
551            indoc! {r#"
552                [env:OUTER] {
553                    WRITE nested
554                    [env:INNER] WRITE deep
555                }
556            "#}
557            .trim()
558            .to_string(),
559            quote! {
560                [env:OUTER] {
561                    WRITE nested
562                    [env:INNER] WRITE deep
563                }
564            },
565        ));
566
567        cases.push((
568            indoc! {r#"
569                [eq(env:TEST, 1)]
570                WITH_IO [stdout=pipe:capture_case] WRITE hi
571                WITH_IO [stdin=pipe:capture_case] WRITE out.txt
572            "#}
573            .trim()
574            .to_string(),
575            quote! {
576                [eq(env:TEST, 1)]
577                WITH_IO [stdout=pipe:capture_case] WRITE hi
578                WITH_IO [stdin=pipe:capture_case] WRITE out.txt
579            },
580        ));
581
582        for (idx, (literal, tokens)) in cases.iter().enumerate() {
583            let text = literal.trim();
584            let string_steps = parse_script(text, test_lower)
585                .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
586            let braced_steps = parse_braced_tokens(tokens, test_lower)
587                .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
588            assert_eq!(
589                string_steps, braced_steps,
590                "AST mismatch for case {idx} literal:\n{text}"
591            );
592        }
593    }
594
595    #[test]
596    fn let_assign_with_bare_word() {
597        let script = r#"LET $x: STRING = hello"#;
598        let steps = parse_script(script, test_lower).expect("parse ok");
599        assert_eq!(steps.len(), 1);
600        match &steps[0].kind {
601            StepKind::Assign {
602                var,
603                decl_type: _,
604                expr,
605            } => {
606                assert_eq!(var, "x");
607                assert_eq!(expr, &Expr::Literal(Value::String("hello".to_string())));
608            }
609            other => panic!("expected Assign, got {:?}", other),
610        }
611    }
612
613    #[test]
614    fn let_assign_with_quoted_string() {
615        let script = r#"LET $x: STRING = "hello world""#;
616        let steps = parse_script(script, test_lower).expect("parse ok");
617        assert_eq!(steps.len(), 1);
618        match &steps[0].kind {
619            StepKind::Assign {
620                var,
621                decl_type: _,
622                expr,
623            } => {
624                assert_eq!(var, "x");
625                assert_eq!(
626                    expr,
627                    &Expr::Literal(Value::String("hello world".to_string()))
628                );
629            }
630            other => panic!("expected Assign, got {:?}", other),
631        }
632    }
633
634    #[test]
635    fn let_assign_with_list_literal() {
636        let script = r#"LET $x: LIST = ["a", "b", "c"]"#;
637        let steps = parse_script(script, test_lower).expect("parse ok");
638        assert_eq!(steps.len(), 1);
639        match &steps[0].kind {
640            StepKind::Assign {
641                var,
642                decl_type: _,
643                expr,
644            } => {
645                assert_eq!(var, "x");
646                assert_eq!(
647                    expr,
648                    &Expr::List(vec![
649                        Expr::Literal(Value::String("a".to_string())),
650                        Expr::Literal(Value::String("b".to_string())),
651                        Expr::Literal(Value::String("c".to_string()))
652                    ])
653                );
654            }
655            other => panic!("expected Assign, got {:?}", other),
656        }
657    }
658
659    #[test]
660    fn let_assign_with_variable_ref() {
661        let script = r#"LET $x: STRING = $y"#;
662        let steps = parse_script(script, test_lower).expect("parse ok");
663        assert_eq!(steps.len(), 1);
664        match &steps[0].kind {
665            StepKind::Assign {
666                var,
667                decl_type: _,
668                expr,
669            } => {
670                assert_eq!(var, "x");
671                assert_eq!(expr, &Expr::Var("y".to_string()));
672            }
673            other => panic!("expected Assign, got {:?}", other),
674        }
675    }
676
677    #[test]
678    fn for_loop_parses() {
679        let script = indoc! {r#"
680            FOR $f: STRING IN ["x", "y"] {
681                WRITE $f
682            }
683        "#};
684        let steps = parse_script(script, test_lower).expect("parse ok");
685        assert_eq!(steps.len(), 1);
686        match &steps[0].kind {
687            StepKind::For {
688                key_var,
689                var,
690                in_expr,
691                body,
692                ..
693            } => {
694                assert!(key_var.is_none());
695                assert_eq!(var, "f");
696                assert_eq!(
697                    in_expr,
698                    &Expr::List(vec![
699                        Expr::Literal(Value::String("x".to_string())),
700                        Expr::Literal(Value::String("y".to_string()))
701                    ])
702                );
703                assert_eq!(body.len(), 1);
704            }
705            other => panic!("expected For, got {:?}", other),
706        }
707    }
708
709    #[test]
710    fn for_map_iteration_parses() {
711        let script = indoc! {r#"
712            FOR $k: STRING, $v: STRING IN $map {
713                WRITE $k
714            }
715        "#};
716        let steps = parse_script(script, test_lower).expect("parse ok");
717        assert_eq!(steps.len(), 1);
718        match &steps[0].kind {
719            StepKind::For {
720                key_var,
721                var,
722                in_expr,
723                body,
724                ..
725            } => {
726                assert_eq!(key_var.as_deref(), Some("k"));
727                assert_eq!(var, "v");
728                assert_eq!(in_expr, &Expr::Var("map".to_string()));
729                assert_eq!(body.len(), 1);
730            }
731            other => panic!("expected For, got {:?}", other),
732        }
733    }
734
735    #[test]
736    fn if_statement_parses() {
737        let script = "IF true { WRITE yes }\n";
738        let steps = parse_script(script, test_lower).expect("parse ok");
739        assert_eq!(steps.len(), 1);
740        match &steps[0].kind {
741            StepKind::If { .. } => {}
742            other => panic!("expected If, got {:?}", other),
743        }
744    }
745
746    #[test]
747    fn if_keyword_matches_directly() {
748        use crate::lexer::{LanguageParser, Rule};
749        use pest::Parser;
750        let result = LanguageParser::parse(Rule::if_keyword, "IF ");
751        assert!(
752            result.is_ok(),
753            "if_keyword should match 'IF ': {:?}",
754            result.err()
755        );
756    }
757
758    #[test]
759    fn if_statement_pest_matches() {
760        use crate::lexer::{LanguageParser, Rule};
761        use pest::Parser;
762        let result = LanguageParser::parse(Rule::if_statement, "IF true {\n    WRITE yes\n}");
763        assert!(
764            result.is_ok(),
765            "if_statement should match: {:?}",
766            result.err()
767        );
768    }
769
770    #[test]
771    fn not_expression_parses() {
772        let script = r#"LET $x: BOOL = !true"#;
773        let steps = parse_script(script, test_lower).expect("parse ok");
774        match &steps[0].kind {
775            StepKind::Assign {
776                var,
777                decl_type: _,
778                expr,
779            } => {
780                assert_eq!(var, "x");
781                assert_eq!(expr, &Expr::Not(Box::new(Expr::Literal(Value::Bool(true)))));
782            }
783            other => panic!("expected Assign, got {:?}", other),
784        }
785
786        // Double negation nests.
787        let steps = parse_script(r#"LET $x: BOOL = !!false"#, test_lower).expect("parse ok");
788        match &steps[0].kind {
789            StepKind::Assign { expr, .. } => {
790                assert_eq!(
791                    expr,
792                    &Expr::Not(Box::new(Expr::Not(Box::new(Expr::Literal(Value::Bool(
793                        false
794                    ))))))
795                );
796            }
797            other => panic!("expected Assign, got {:?}", other),
798        }
799
800        // `!` binds tighter than `==`: `!true == false` is `(!true) == false`.
801        let steps = parse_script(r#"LET $x: BOOL = !true == false"#, test_lower).expect("parse ok");
802        match &steps[0].kind {
803            StepKind::Assign { expr, .. } => {
804                assert!(matches!(expr, Expr::Compare { .. }), "got {expr:?}");
805                if let Expr::Compare { left, .. } = expr {
806                    assert!(matches!(left.as_ref(), Expr::Not(_)), "got {left:?}");
807                }
808            }
809            other => panic!("expected Assign, got {:?}", other),
810        }
811
812        // Parentheses invert the grouping: `!(true == false)`.
813        let steps =
814            parse_script(r#"LET $x: BOOL = !(true == false)"#, test_lower).expect("parse ok");
815        match &steps[0].kind {
816            StepKind::Assign { expr, .. } => {
817                assert!(matches!(expr, Expr::Not(_)), "got {expr:?}");
818            }
819            other => panic!("expected Assign, got {:?}", other),
820        }
821    }
822
823    #[test]
824    fn not_expression_display_round_trips() {
825        for script in [
826            "LET $x: BOOL = !true",
827            "LET $x: BOOL = !!false",
828            "LET $x: BOOL = !(true == false)",
829            "IF !true {\n    WRITE yes\n}",
830        ] {
831            let steps = parse_script(script, test_lower).expect("parse");
832            let rendered: Vec<String> = steps.iter().map(|s| s.to_string()).collect();
833            let reparsed = parse_script(&rendered.join("\n"), test_lower).expect("reparse");
834            assert_eq!(steps, reparsed, "Display round-trip failed for {script}");
835        }
836    }
837
838    #[test]
839    fn guard_block_with_mock_command() {
840        let script = "CWD\n[env:GATE] {\n    WRITE gated\n}\n[eq(env:A, 1)] WRITE eq\n";
841        let steps = parse_script(script, test_lower).expect("parse should succeed");
842        assert!(
843            steps.len() >= 2,
844            "expected at least 2 steps, got {}",
845            steps.len()
846        );
847    }
848
849    #[test]
850    fn single_line_blocks() {
851        let test_cases = [
852            "IF true { WRITE \"hello\" }",
853            "IF true { WRITE \"cargo test\" }",
854            "IF true { WRITE \"/app\" }",
855        ];
856        for script in test_cases {
857            assert!(
858                parse_script(script, test_lower).is_ok(),
859                "Failed to parse: {}",
860                script
861            );
862        }
863    }
864
865    #[test]
866    fn async_run_parses() {
867        let script = "ASYNC RUN \"echo hello\"";
868        let steps = parse_script(script, test_lower).expect("parse should succeed");
869        assert_eq!(steps.len(), 1);
870        assert!(matches!(&steps[0].kind, StepKind::AsyncBlock { .. }));
871    }
872
873    #[test]
874    fn async_block_parses() {
875        let script = indoc! {r#"
876            ASYNC {
877                RUN "echo one"
878                RUN "echo two"
879            }
880        "#};
881        let steps = parse_script(script, test_lower).expect("parse should succeed");
882        assert_eq!(steps.len(), 1);
883        match &steps[0].kind {
884            StepKind::AsyncBlock { body } => {
885                assert_eq!(body.len(), 2);
886            }
887            other => panic!("expected AsyncBlock, got {:?}", other),
888        }
889    }
890
891    #[test]
892    fn nested_async_parses() {
893        let script = "ASYNC ASYNC RUN \"echo nested\"";
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::AsyncBlock { body } => {
898                assert_eq!(body.len(), 1);
899                match &body[0].kind {
900                    StepKind::AsyncBlock { body } => {
901                        assert_eq!(body.len(), 1);
902                        assert!(matches!(&body[0].kind, StepKind::Run(_)));
903                    }
904                    other => panic!("expected inner AsyncBlock, got {:?}", other),
905                }
906            }
907            other => panic!("expected outer AsyncBlock, got {:?}", other),
908        }
909    }
910
911    #[test]
912    fn nested_async_block_form_parses() {
913        let script = indoc! {r#"
914            ASYNC {
915                ASYNC {
916                    RUN "echo nested"
917                }
918            }
919        "#};
920        let steps = parse_script(script, test_lower).expect("parse should succeed");
921        assert_eq!(steps.len(), 1);
922        match &steps[0].kind {
923            StepKind::AsyncBlock { body } => {
924                assert_eq!(body.len(), 1);
925                match &body[0].kind {
926                    StepKind::AsyncBlock { body } => {
927                        assert_eq!(body.len(), 1);
928                        assert!(matches!(&body[0].kind, StepKind::Run(_)));
929                    }
930                    other => panic!("expected inner AsyncBlock, got {:?}", other),
931                }
932            }
933            other => panic!("expected outer AsyncBlock, got {:?}", other),
934        }
935    }
936
937    #[test]
938    fn with_io_wrapping_async_parses() {
939        let script = "WITH_IO [stdout] ASYNC RUN \"echo test\"";
940        let steps = parse_script(script, test_lower).expect("parse should succeed");
941        assert_eq!(steps.len(), 1);
942        match &steps[0].kind {
943            StepKind::WithIo { cmd, .. } => {
944                assert!(matches!(cmd.as_ref(), StepKind::AsyncBlock { .. }));
945            }
946            other => panic!("expected WithIo, got {:?}", other),
947        }
948    }
949
950    #[test]
951    fn with_io_async_block_nested_for_parses() {
952        // Regression: structural statements nested inside a WITH_IO-wrapped
953        // ASYNC block must parse. WITH_IO is compound-atomic (implicit
954        // whitespace suppressed), so nested rules carry explicit gaps.
955        let script = indoc! {r#"
956            WITH_IO [stdout=pipe:out] ASYNC {
957                FOR $x: INT IN [0, 1] {
958                    ECHO hi
959                }
960            }
961        "#};
962        let steps = parse_script(script, test_lower).expect("parse should succeed");
963        assert_eq!(steps.len(), 1);
964        match &steps[0].kind {
965            StepKind::WithIo { bindings, cmd } => {
966                assert_eq!(bindings.len(), 1);
967                assert!(matches!(bindings[0].stream, IoStream::Stdout));
968                assert_eq!(bindings[0].pipe, Some(PipeTarget::Name("out".to_string())));
969                match cmd.as_ref() {
970                    StepKind::AsyncBlock { body } => {
971                        assert_eq!(body.len(), 1);
972                        match &body[0].kind {
973                            StepKind::For { var, body, .. } => {
974                                assert_eq!(var, "x");
975                                assert_eq!(body.len(), 1);
976                                assert!(matches!(&body[0].kind, StepKind::Echo(_)));
977                            }
978                            other => panic!("expected For, got {:?}", other),
979                        }
980                    }
981                    other => panic!("expected AsyncBlock, got {:?}", other),
982                }
983            }
984            other => panic!("expected WithIo, got {:?}", other),
985        }
986    }
987
988    #[test]
989    fn for_int_key_parses_for_list_enumeration() {
990        let script = indoc! {r#"
991            FOR $i: INT, $v: STRING IN $items {
992                WRITE $v
993            }
994        "#};
995        let steps = parse_script(script, test_lower).expect("parse ok");
996        match &steps[0].kind {
997            StepKind::For {
998                key_var,
999                key_type,
1000                var,
1001                var_type,
1002                ..
1003            } => {
1004                assert_eq!(key_var.as_deref(), Some("i"));
1005                assert_eq!(*key_type, Some(crate::TypeKind::Int));
1006                assert_eq!(var, "v");
1007                assert_eq!(*var_type, crate::TypeKind::String);
1008            }
1009            other => panic!("expected For, got {:?}", other),
1010        }
1011    }
1012
1013    #[test]
1014    fn for_non_index_key_type_is_rejected() {
1015        let err = parse_script(
1016            "FOR $k: BOOL, $v: STRING IN $map { WRITE $v }\n",
1017            test_lower,
1018        )
1019        .expect_err("BOOL key must fail");
1020        assert!(
1021            err.to_string().contains("must be INT or STRING"),
1022            "unexpected error: {err}"
1023        );
1024    }
1025
1026    #[test]
1027    fn with_io_async_block_nested_if_else_parses() {
1028        // Spaced comparison and ELSE chain inside a WITH_IO-wrapped block.
1029        let script = indoc! {r#"
1030            WITH_IO [stdout=pipe:out] ASYNC {
1031                IF $a == $b {
1032                    ECHO yes
1033                } ELSE {
1034                    ECHO no
1035                }
1036            }
1037        "#};
1038        let steps = parse_script(script, test_lower).expect("parse should succeed");
1039        match &steps[0].kind {
1040            StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1041                StepKind::AsyncBlock { body } => {
1042                    assert!(matches!(&body[0].kind, StepKind::If { .. }));
1043                    match &body[0].kind {
1044                        StepKind::If { else_body, .. } => {
1045                            assert_eq!(else_body.as_ref().map(Vec::len), Some(1));
1046                        }
1047                        other => panic!("expected If, got {:?}", other),
1048                    }
1049                }
1050                other => panic!("expected AsyncBlock, got {:?}", other),
1051            },
1052            other => panic!("expected WithIo, got {:?}", other),
1053        }
1054    }
1055
1056    #[test]
1057    fn timeout_block_nested_for_parses() {
1058        // TIMEOUT is compound-atomic too; nested structural statements must
1059        // parse inside its block form.
1060        let script = indoc! {r#"
1061            TIMEOUT 30s {
1062                FOR $x: INT IN [1] {
1063                    ECHO hi
1064                }
1065            }
1066        "#};
1067        let steps = parse_script(script, test_lower).expect("parse should succeed");
1068        match &steps[0].kind {
1069            StepKind::Timeout { body, .. } => {
1070                assert_eq!(body.len(), 1);
1071                assert!(matches!(&body[0].kind, StepKind::For { .. }));
1072            }
1073            other => panic!("expected Timeout, got {:?}", other),
1074        }
1075    }
1076
1077    #[test]
1078    fn with_io_async_block_nested_let_map_parses() {
1079        // LET with a spaced map literal inside a WITH_IO-wrapped block.
1080        let script = indoc! {r#"
1081            WITH_IO [stdout] ASYNC {
1082                LET $m: MAP = {a: 1, b: 2}
1083            }
1084        "#};
1085        let steps = parse_script(script, test_lower).expect("parse should succeed");
1086        match &steps[0].kind {
1087            StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1088                StepKind::AsyncBlock { body } => {
1089                    assert!(matches!(&body[0].kind, StepKind::Assign { .. }));
1090                }
1091                other => panic!("expected AsyncBlock, got {:?}", other),
1092            },
1093            other => panic!("expected WithIo, got {:?}", other),
1094        }
1095    }
1096
1097    #[test]
1098    fn variable_sigil_binds_tightly() {
1099        // `$` and its identifier are one atomic unit: whitespace between the
1100        // sigil and the name must not parse as a variable reference. (In
1101        // argument position this already failed; in expression position the
1102        // old non-atomic `variable` rule accepted `$   y` via implicit
1103        // whitespace.)
1104        parse_script("ECHO $   x", test_lower).expect_err("spaced sigil must fail");
1105        parse_script("LET $x: STRING = $   y", test_lower).expect_err("spaced sigil must fail");
1106        let steps = parse_script("ECHO $x", test_lower).expect("tight sigil parses");
1107        assert!(matches!(&steps[0].kind, StepKind::Echo(_)));
1108    }
1109
1110    #[test]
1111    fn let_async_block_parses() {
1112        let script = indoc! {r#"
1113            LET $task: HANDLE = ASYNC {
1114                RUN "echo hello"
1115            }
1116        "#};
1117        let steps = parse_script(script, test_lower).expect("parse should succeed");
1118        assert_eq!(steps.len(), 1);
1119        match &steps[0].kind {
1120            StepKind::AssignAsync { var, body, .. } => {
1121                assert_eq!(var, "task");
1122                assert_eq!(body.len(), 1);
1123                assert!(matches!(&body[0].kind, StepKind::Run(_)));
1124            }
1125            other => panic!("expected AssignAsync, got {:?}", other),
1126        }
1127    }
1128
1129    #[test]
1130    fn let_async_inline_parses() {
1131        let script = "LET $t: HANDLE = ASYNC RUN \"echo hi\"";
1132        let steps = parse_script(script, test_lower).expect("parse should succeed");
1133        assert_eq!(steps.len(), 1);
1134        match &steps[0].kind {
1135            StepKind::AssignAsync { var, body, .. } => {
1136                assert_eq!(var, "t");
1137                assert_eq!(body.len(), 1);
1138                assert!(matches!(&body[0].kind, StepKind::Run(_)));
1139            }
1140            other => panic!("expected AssignAsync, got {:?}", other),
1141        }
1142    }
1143
1144    #[test]
1145    fn await_parses() {
1146        let script = "AWAIT $task";
1147        let steps = parse_script(script, test_lower).expect("parse should succeed");
1148        assert_eq!(steps.len(), 1);
1149        match &steps[0].kind {
1150            StepKind::Await { var } => {
1151                assert_eq!(var, "task");
1152            }
1153            other => panic!("expected Await, got {:?}", other),
1154        }
1155    }
1156}