Skip to main content

oxdock_parser/
lib.rs

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