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