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