1#![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
51pub 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 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 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 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 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 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 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 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 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 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 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 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}