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