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, 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::AssertFile {
78 hash: Some(hash),
79 path,
80 contents: 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::AssertFile {
89 hash: None,
90 path,
91 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!(
246 bindings.iter().any(|b| matches!(b.stream, IoStream::Stdout)
247 && b.pipe.as_deref() == Some("setup"))
248 );
249 assert!(
250 bindings.iter().any(|b| matches!(b.stream, IoStream::Stderr)
251 && b.pipe.as_deref() == Some("errors"))
252 );
253 assert!(matches!(cmd.as_ref(), StepKind::Write { .. }));
254 }
255 other => panic!("expected WITH_IO, saw {:?}", other),
256 }
257 }
258
259 #[test]
260 fn brace_blocks_require_guard() {
261 let script = indoc! {r#"
262 {
263 WRITE nope.txt hi
264 }
265 "#};
266 parse_script(script, test_lower).expect_err("unguarded block should fail");
267 }
268
269 #[test]
270 fn multi_line_guard_blocks_apply_to_next_command() {
271 let script = indoc! {r#"
272 [
273 env:A,
274 env:B
275 ]
276 WRITE "echo guarded"
277 "#};
278 let steps = parse_script(script, test_lower).expect("parse ok");
279 assert_eq!(steps.len(), 1);
280 assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
281 }
282
283 #[test]
284 fn guarded_brace_blocks_apply_to_all_inner_steps() {
285 let script = indoc! {r#"
286 [env:A] {
287 WRITE one.txt 1
288 WRITE two.txt 2
289 }
290 "#};
291 let steps = parse_script(script, test_lower).expect("parse ok");
292 assert_eq!(steps.len(), 2);
293 assert!(steps.iter().all(|s| s.guard.is_some()));
294 }
295
296 #[test]
297 fn nested_guard_blocks_stack() {
298 let script = indoc! {r#"
299 [env:A] {
300 WRITE outer.txt no
301 [env:B] {
302 WRITE nested.txt yes
303 }
304 }
305 "#};
306 let steps = parse_script(script, test_lower).expect("parse ok");
307 assert_eq!(steps.len(), 2);
308 assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A"));
309 assert_eq!(guard_text(&steps[1]).as_deref(), Some("env:A, env:B"));
310 }
311
312 #[test]
313 fn nested_guard_block_scopes_stack_counts() {
314 let script = indoc! {r#"
315 [env:A] {
316 WRITE outer.txt ok
317 [env:B] {
318 WRITE deep.txt ok
319 }
320 WRITE outer_again.txt ok
321 }
322 "#};
323 let steps = parse_script(script, test_lower).expect("parse ok");
324 assert_eq!(steps.len(), 3);
325 assert_eq!(steps[0].scope_enter, 1);
326 assert_eq!(steps[0].scope_exit, 0);
327 assert_eq!(steps[1].scope_enter, 1);
328 assert_eq!(steps[1].scope_exit, 1);
329 assert_eq!(steps[2].scope_enter, 0);
330 assert_eq!(steps[2].scope_exit, 1);
331 }
332
333 #[test]
334 fn guard_or_and_and_compose_as_expected() {
335 let script = indoc! {r#"
336 [env:A]
337 [any(env:B, env:C)]
338 WRITE "echo complex"
339 "#};
340 let steps = parse_script(script, test_lower).expect("parse ok");
341 assert_eq!(steps.len(), 1);
342 let guard = steps[0].guard.as_ref().expect("missing guard");
343 assert_eq!(guard.to_string(), "env:A, any(env:B, env:C)");
344
345 let mut env = HashMap::new();
346 env.insert("A".into(), "1".into());
347 env.insert("B".into(), "1".into());
348 assert!(guard_expr_allows(guard, &env), "A && B should pass");
349
350 env.remove("B");
351 env.insert("C".into(), "1".into());
352 assert!(guard_expr_allows(guard, &env), "A && C should pass");
353
354 env.remove("C");
355 assert!(!guard_expr_allows(guard, &env), "A without B/C should fail");
356 }
357
358 #[test]
359 fn guard_or_requires_at_least_one_branch() {
360 let expr = GuardExpr::or(vec![
361 Guard::EnvExists {
362 key: "MISSING".into(),
363 }
364 .into(),
365 Guard::EnvExists {
366 key: "ALSO_MISSING".into(),
367 }
368 .into(),
369 ]);
370 assert!(!guard_expr_allows(&expr, &HashMap::new()));
371 let mut env = HashMap::new();
372 env.insert("MISSING".into(), "1".into());
373 assert!(guard_expr_allows(&expr, &env));
374 }
375
376 #[test]
377 fn guard_or_can_chain_with_additional_predicates() {
378 let script = "[any(env:A, linux), mac] WRITE \"echo hi\"";
379 let steps = parse_script(script, test_lower).expect("parse ok");
380 assert_eq!(steps.len(), 1);
381 let guard = steps[0].guard.as_ref().expect("missing guard");
382 assert_eq!(guard.to_string(), "any(env:A, linux), macos");
383 let GuardExpr::All(children) = guard else {
384 panic!("expected ALL guard");
385 };
386 assert!(matches!(children[0], GuardExpr::Or(_)));
387 match &children[1] {
388 GuardExpr::Predicate(Guard::Platform {
389 target: PlatformGuard::Macos,
390 }) => {}
391 other => panic!("unexpected trailing guard: {other:?}"),
392 }
393 }
394
395 #[test]
396 fn guard_or_guard_line_parses() {
397 use crate::lexer::{LanguageParser, Rule};
398 use pest::Parser;
399 LanguageParser::parse(Rule::guard_line, "[any(linux, env:FOO)]")
400 .expect("guard guard line should parse");
401 }
402
403 #[test]
404 fn env_equals_guard_with_not_wrapper() {
405 let g = GuardExpr::Not(Box::new(GuardExpr::Predicate(Guard::EnvEquals {
406 key: "A".into(),
407 value: "1".into(),
408 })));
409 let mut env = HashMap::new();
410 env.insert("A".into(), "1".into());
411 assert!(!guard_expr_allows(&g, &env));
412 env.insert("A".into(), "2".into());
413 assert!(guard_expr_allows(&g, &env));
414 }
415
416 #[test]
417 fn guard_block_emits_scope_markers() {
418 let script = indoc! {r#"
419 ENV RUN=1
420 [env:RUN] {
421 WRITE one.txt 1
422 WRITE two.txt 2
423 }
424 WRITE three.txt 3
425 "#};
426 let steps = parse_script(script, test_lower).expect("parse ok");
427 assert_eq!(steps.len(), 4);
428 assert_eq!(steps[1].scope_enter, 1);
429 assert_eq!(steps[1].scope_exit, 0);
430 assert_eq!(steps[2].scope_enter, 0);
431 assert_eq!(steps[2].scope_exit, 1);
432 assert_eq!(steps[3].scope_enter, 0);
433 assert_eq!(steps[3].scope_exit, 0);
434 }
435
436 #[test]
437 fn mock_hash_form_parses() {
438 let script = "HASH_SHA256 --hash aabb path.txt";
439 let steps = parse_script(script, test_lower).expect("parse ok");
440 match &steps[0].kind {
441 StepKind::AssertFile {
442 hash,
443 path,
444 contents,
445 } => {
446 assert_eq!(hash.as_deref(), Some("aabb"));
447 assert_eq!(path.as_ref(), "path.txt");
448 assert!(contents.is_none());
449 }
450 other => panic!("expected AssertFile, saw {:?}", other),
451 }
452 }
453
454 #[test]
455 fn mock_commands_parse_and_round_trip() {
456 let script = indoc! {r#"
457 WRITE "dist/hello.txt" "Built with OxDock"
458 WRITE "deeply/nested/tree"
459 WRITE "chained.txt"
460 WRITE "visible-after-comments"
461 "#};
462 let steps = parse_script(script, test_lower).expect("parse ok");
463 assert_eq!(steps.len(), 4);
464
465 for step in &steps {
467 assert!(
468 matches!(&step.kind, StepKind::Write { .. }),
469 "expected Write variant"
470 );
471 }
472 }
473
474 #[test]
475 fn quoted_string_content_preserved() {
476 let script = "WRITE 'echo \"a; b\"'";
477 let steps = parse_script(script, test_lower).expect("parse ok");
478 match &steps[0].kind {
479 StepKind::Write { path, .. } => assert_eq!(path, "echo \"a; b\""),
480 other => panic!("expected Write, saw {:?}", other),
481 }
482 }
483
484 #[test]
485 fn templated_argument_with_spaces() {
486 let script = "WRITE {{ env:OXBOOK_RUNNER_DIR }}";
487 let steps = parse_script(script, test_lower).expect("parse ok");
488 assert_eq!(steps.len(), 1);
489 match &steps[0].kind {
490 StepKind::Write { path, .. } => assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}"),
491 other => panic!("expected Write, saw {:?}", other),
492 }
493 }
494
495 #[test]
496 #[cfg(feature = "proc-macro-api")]
497 fn string_and_braced_scripts_produce_identical_ast() {
498 let mut cases = Vec::new();
499
500 cases.push((
501 indoc! {r#"
502 WRITE /tmp
503 WRITE hello
504 "#}
505 .trim()
506 .to_string(),
507 quote! {
508 WRITE /tmp
509 WRITE hello
510 },
511 ));
512
513 cases.push((
514 indoc! {r#"
515 [not(env:SKIP)]
516 [windows] WRITE win
517 [eq(env:MODE, beta), linux] WRITE combo
518 "#}
519 .trim()
520 .to_string(),
521 quote! {
522 [not(env:SKIP)]
523 [windows] WRITE win
524 [eq(env:MODE, beta), linux] WRITE combo
525 },
526 ));
527
528 cases.push((
529 indoc! {r#"
530 [env:OUTER] {
531 WRITE nested
532 [env:INNER] WRITE deep
533 }
534 "#}
535 .trim()
536 .to_string(),
537 quote! {
538 [env:OUTER] {
539 WRITE nested
540 [env:INNER] WRITE deep
541 }
542 },
543 ));
544
545 cases.push((
546 indoc! {r#"
547 [eq(env:TEST, 1)]
548 WITH_IO [stdout=pipe:capture_case] WRITE hi
549 WITH_IO [stdin=pipe:capture_case] WRITE out.txt
550 "#}
551 .trim()
552 .to_string(),
553 quote! {
554 [eq(env:TEST, 1)]
555 WITH_IO [stdout=pipe:capture_case] WRITE hi
556 WITH_IO [stdin=pipe:capture_case] WRITE out.txt
557 },
558 ));
559
560 for (idx, (literal, tokens)) in cases.iter().enumerate() {
561 let text = literal.trim();
562 let string_steps = parse_script(text, test_lower)
563 .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
564 let braced_steps = parse_braced_tokens(tokens, test_lower)
565 .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
566 assert_eq!(
567 string_steps, braced_steps,
568 "AST mismatch for case {idx} literal:\n{text}"
569 );
570 }
571 }
572
573 #[test]
574 fn let_assign_with_bare_word() {
575 let script = r#"LET $x = hello"#;
576 let steps = parse_script(script, test_lower).expect("parse ok");
577 assert_eq!(steps.len(), 1);
578 match &steps[0].kind {
579 StepKind::Assign { var, expr } => {
580 assert_eq!(var, "x");
581 assert_eq!(expr, &Expr::Literal(Value::String("hello".to_string())));
582 }
583 other => panic!("expected Assign, got {:?}", other),
584 }
585 }
586
587 #[test]
588 fn let_assign_with_quoted_string() {
589 let script = r#"LET $x = "hello world""#;
590 let steps = parse_script(script, test_lower).expect("parse ok");
591 assert_eq!(steps.len(), 1);
592 match &steps[0].kind {
593 StepKind::Assign { var, expr } => {
594 assert_eq!(var, "x");
595 assert_eq!(
596 expr,
597 &Expr::Literal(Value::String("hello world".to_string()))
598 );
599 }
600 other => panic!("expected Assign, got {:?}", other),
601 }
602 }
603
604 #[test]
605 fn let_assign_with_list_literal() {
606 let script = r#"LET $x = ["a", "b", "c"]"#;
607 let steps = parse_script(script, test_lower).expect("parse ok");
608 assert_eq!(steps.len(), 1);
609 match &steps[0].kind {
610 StepKind::Assign { var, expr } => {
611 assert_eq!(var, "x");
612 assert_eq!(
613 expr,
614 &Expr::List(vec![
615 Expr::Literal(Value::String("a".to_string())),
616 Expr::Literal(Value::String("b".to_string())),
617 Expr::Literal(Value::String("c".to_string()))
618 ])
619 );
620 }
621 other => panic!("expected Assign, got {:?}", other),
622 }
623 }
624
625 #[test]
626 fn let_assign_with_variable_ref() {
627 let script = r#"LET $x = $y"#;
628 let steps = parse_script(script, test_lower).expect("parse ok");
629 assert_eq!(steps.len(), 1);
630 match &steps[0].kind {
631 StepKind::Assign { var, expr } => {
632 assert_eq!(var, "x");
633 assert_eq!(expr, &Expr::Var("y".to_string()));
634 }
635 other => panic!("expected Assign, got {:?}", other),
636 }
637 }
638
639 #[test]
640 fn for_loop_parses() {
641 let script = indoc! {r#"
642 FOR $f IN ["x", "y"] {
643 WRITE $f
644 }
645 "#};
646 let steps = parse_script(script, test_lower).expect("parse ok");
647 assert_eq!(steps.len(), 1);
648 match &steps[0].kind {
649 StepKind::For {
650 key_var,
651 var,
652 in_expr,
653 body,
654 } => {
655 assert!(key_var.is_none());
656 assert_eq!(var, "f");
657 assert_eq!(
658 in_expr,
659 &Expr::List(vec![
660 Expr::Literal(Value::String("x".to_string())),
661 Expr::Literal(Value::String("y".to_string()))
662 ])
663 );
664 assert_eq!(body.len(), 1);
665 }
666 other => panic!("expected For, got {:?}", other),
667 }
668 }
669
670 #[test]
671 fn for_map_iteration_parses() {
672 let script = indoc! {r#"
673 FOR $k, $v IN $map {
674 WRITE $k
675 }
676 "#};
677 let steps = parse_script(script, test_lower).expect("parse ok");
678 assert_eq!(steps.len(), 1);
679 match &steps[0].kind {
680 StepKind::For {
681 key_var,
682 var,
683 in_expr,
684 body,
685 } => {
686 assert_eq!(key_var.as_deref(), Some("k"));
687 assert_eq!(var, "v");
688 assert_eq!(in_expr, &Expr::Var("map".to_string()));
689 assert_eq!(body.len(), 1);
690 }
691 other => panic!("expected For, got {:?}", other),
692 }
693 }
694
695 #[test]
696 fn if_statement_parses() {
697 let script = "IF true { WRITE yes }\n";
698 let steps = parse_script(script, test_lower).expect("parse ok");
699 assert_eq!(steps.len(), 1);
700 match &steps[0].kind {
701 StepKind::If { .. } => {}
702 other => panic!("expected If, got {:?}", other),
703 }
704 }
705
706 #[test]
707 fn if_keyword_matches_directly() {
708 use crate::lexer::{LanguageParser, Rule};
709 use pest::Parser;
710 let result = LanguageParser::parse(Rule::if_keyword, "IF ");
711 assert!(
712 result.is_ok(),
713 "if_keyword should match 'IF ': {:?}",
714 result.err()
715 );
716 }
717
718 #[test]
719 fn if_statement_pest_matches() {
720 use crate::lexer::{LanguageParser, Rule};
721 use pest::Parser;
722 let result = LanguageParser::parse(Rule::if_statement, "IF true {\n WRITE yes\n}");
723 assert!(
724 result.is_ok(),
725 "if_statement should match: {:?}",
726 result.err()
727 );
728 }
729
730 #[test]
731 fn not_expression_parses() {
732 let script = r#"LET $x = !true"#;
733 let steps = parse_script(script, test_lower).expect("parse ok");
734 match &steps[0].kind {
735 StepKind::Assign { var, expr } => {
736 assert_eq!(var, "x");
737 assert_eq!(expr, &Expr::Not(Box::new(Expr::Literal(Value::Bool(true)))));
738 }
739 other => panic!("expected Assign, got {:?}", other),
740 }
741
742 let steps = parse_script(r#"LET $x = !!false"#, test_lower).expect("parse ok");
744 match &steps[0].kind {
745 StepKind::Assign { expr, .. } => {
746 assert_eq!(
747 expr,
748 &Expr::Not(Box::new(Expr::Not(Box::new(Expr::Literal(Value::Bool(
749 false
750 ))))))
751 );
752 }
753 other => panic!("expected Assign, got {:?}", other),
754 }
755
756 let steps = parse_script(r#"LET $x = !true == false"#, test_lower).expect("parse ok");
758 match &steps[0].kind {
759 StepKind::Assign { expr, .. } => {
760 assert!(matches!(expr, Expr::Compare { .. }), "got {expr:?}");
761 if let Expr::Compare { left, .. } = expr {
762 assert!(matches!(left.as_ref(), Expr::Not(_)), "got {left:?}");
763 }
764 }
765 other => panic!("expected Assign, got {:?}", other),
766 }
767
768 let steps = parse_script(r#"LET $x = !(true == false)"#, test_lower).expect("parse ok");
770 match &steps[0].kind {
771 StepKind::Assign { expr, .. } => {
772 assert!(matches!(expr, Expr::Not(_)), "got {expr:?}");
773 }
774 other => panic!("expected Assign, got {:?}", other),
775 }
776 }
777
778 #[test]
779 fn not_expression_display_round_trips() {
780 for script in [
781 "LET $x = !true",
782 "LET $x = !!false",
783 "LET $x = !(true == false)",
784 "IF !true {\n WRITE yes\n}",
785 ] {
786 let steps = parse_script(script, test_lower).expect("parse");
787 let rendered: Vec<String> = steps.iter().map(|s| s.to_string()).collect();
788 let reparsed = parse_script(&rendered.join("\n"), test_lower).expect("reparse");
789 assert_eq!(steps, reparsed, "Display round-trip failed for {script}");
790 }
791 }
792
793 #[test]
794 fn guard_block_with_mock_command() {
795 let script = "CWD\n[env:GATE] {\n WRITE gated\n}\n[eq(env:A, 1)] WRITE eq\n";
796 let steps = parse_script(script, test_lower).expect("parse should succeed");
797 assert!(
798 steps.len() >= 2,
799 "expected at least 2 steps, got {}",
800 steps.len()
801 );
802 }
803
804 #[test]
805 fn single_line_blocks() {
806 let test_cases = [
807 "IF true { WRITE \"hello\" }",
808 "IF true { WRITE \"cargo test\" }",
809 "IF true { WRITE \"/app\" }",
810 ];
811 for script in test_cases {
812 assert!(
813 parse_script(script, test_lower).is_ok(),
814 "Failed to parse: {}",
815 script
816 );
817 }
818 }
819
820 #[test]
821 fn async_run_parses() {
822 let script = "ASYNC RUN \"echo hello\"";
823 let steps = parse_script(script, test_lower).expect("parse should succeed");
824 assert_eq!(steps.len(), 1);
825 assert!(matches!(&steps[0].kind, StepKind::AsyncBlock { .. }));
826 }
827
828 #[test]
829 fn async_block_parses() {
830 let script = indoc! {r#"
831 ASYNC {
832 RUN "echo one"
833 RUN "echo two"
834 }
835 "#};
836 let steps = parse_script(script, test_lower).expect("parse should succeed");
837 assert_eq!(steps.len(), 1);
838 match &steps[0].kind {
839 StepKind::AsyncBlock { body } => {
840 assert_eq!(body.len(), 2);
841 }
842 other => panic!("expected AsyncBlock, got {:?}", other),
843 }
844 }
845
846 #[test]
847 fn nested_async_parses() {
848 let script = "ASYNC ASYNC RUN \"echo nested\"";
849 let steps = parse_script(script, test_lower).expect("parse should succeed");
850 assert_eq!(steps.len(), 1);
851 match &steps[0].kind {
852 StepKind::AsyncBlock { body } => {
853 assert_eq!(body.len(), 1);
854 match &body[0].kind {
855 StepKind::AsyncBlock { body } => {
856 assert_eq!(body.len(), 1);
857 assert!(matches!(&body[0].kind, StepKind::Run(_)));
858 }
859 other => panic!("expected inner AsyncBlock, got {:?}", other),
860 }
861 }
862 other => panic!("expected outer AsyncBlock, got {:?}", other),
863 }
864 }
865
866 #[test]
867 fn nested_async_block_form_parses() {
868 let script = indoc! {r#"
869 ASYNC {
870 ASYNC {
871 RUN "echo nested"
872 }
873 }
874 "#};
875 let steps = parse_script(script, test_lower).expect("parse should succeed");
876 assert_eq!(steps.len(), 1);
877 match &steps[0].kind {
878 StepKind::AsyncBlock { body } => {
879 assert_eq!(body.len(), 1);
880 match &body[0].kind {
881 StepKind::AsyncBlock { body } => {
882 assert_eq!(body.len(), 1);
883 assert!(matches!(&body[0].kind, StepKind::Run(_)));
884 }
885 other => panic!("expected inner AsyncBlock, got {:?}", other),
886 }
887 }
888 other => panic!("expected outer AsyncBlock, got {:?}", other),
889 }
890 }
891
892 #[test]
893 fn with_io_wrapping_async_parses() {
894 let script = "WITH_IO [stdout] ASYNC RUN \"echo test\"";
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::WithIo { cmd, .. } => {
899 assert!(matches!(cmd.as_ref(), StepKind::AsyncBlock { .. }));
900 }
901 other => panic!("expected WithIo, got {:?}", other),
902 }
903 }
904
905 #[test]
906 fn with_io_async_block_nested_for_parses() {
907 let script = indoc! {r#"
911 WITH_IO [stdout=pipe:out] ASYNC {
912 FOR $x IN [0, 1] {
913 ECHO hi
914 }
915 }
916 "#};
917 let steps = parse_script(script, test_lower).expect("parse should succeed");
918 assert_eq!(steps.len(), 1);
919 match &steps[0].kind {
920 StepKind::WithIo { bindings, cmd } => {
921 assert_eq!(bindings.len(), 1);
922 assert!(matches!(bindings[0].stream, IoStream::Stdout));
923 assert_eq!(bindings[0].pipe.as_deref(), Some("out"));
924 match cmd.as_ref() {
925 StepKind::AsyncBlock { body } => {
926 assert_eq!(body.len(), 1);
927 match &body[0].kind {
928 StepKind::For { var, body, .. } => {
929 assert_eq!(var, "x");
930 assert_eq!(body.len(), 1);
931 assert!(matches!(&body[0].kind, StepKind::Echo(_)));
932 }
933 other => panic!("expected For, got {:?}", other),
934 }
935 }
936 other => panic!("expected AsyncBlock, got {:?}", other),
937 }
938 }
939 other => panic!("expected WithIo, got {:?}", other),
940 }
941 }
942
943 #[test]
944 fn with_io_async_block_nested_if_else_parses() {
945 let script = indoc! {r#"
947 WITH_IO [stdout=pipe:out] ASYNC {
948 IF $a == $b {
949 ECHO yes
950 } ELSE {
951 ECHO no
952 }
953 }
954 "#};
955 let steps = parse_script(script, test_lower).expect("parse should succeed");
956 match &steps[0].kind {
957 StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
958 StepKind::AsyncBlock { body } => {
959 assert!(matches!(&body[0].kind, StepKind::If { .. }));
960 match &body[0].kind {
961 StepKind::If { else_body, .. } => {
962 assert_eq!(else_body.as_ref().map(Vec::len), Some(1));
963 }
964 other => panic!("expected If, got {:?}", other),
965 }
966 }
967 other => panic!("expected AsyncBlock, got {:?}", other),
968 },
969 other => panic!("expected WithIo, got {:?}", other),
970 }
971 }
972
973 #[test]
974 fn timeout_block_nested_for_parses() {
975 let script = indoc! {r#"
978 TIMEOUT 30s {
979 FOR $x IN [1] {
980 ECHO hi
981 }
982 }
983 "#};
984 let steps = parse_script(script, test_lower).expect("parse should succeed");
985 match &steps[0].kind {
986 StepKind::Timeout { body, .. } => {
987 assert_eq!(body.len(), 1);
988 assert!(matches!(&body[0].kind, StepKind::For { .. }));
989 }
990 other => panic!("expected Timeout, got {:?}", other),
991 }
992 }
993
994 #[test]
995 fn with_io_async_block_nested_let_map_parses() {
996 let script = indoc! {r#"
998 WITH_IO [stdout] ASYNC {
999 LET $m = {a: 1, b: 2}
1000 }
1001 "#};
1002 let steps = parse_script(script, test_lower).expect("parse should succeed");
1003 match &steps[0].kind {
1004 StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1005 StepKind::AsyncBlock { body } => {
1006 assert!(matches!(&body[0].kind, StepKind::Assign { .. }));
1007 }
1008 other => panic!("expected AsyncBlock, got {:?}", other),
1009 },
1010 other => panic!("expected WithIo, got {:?}", other),
1011 }
1012 }
1013
1014 #[test]
1015 fn variable_sigil_binds_tightly() {
1016 parse_script("ECHO $ x", test_lower).expect_err("spaced sigil must fail");
1022 parse_script("LET $x = $ y", test_lower).expect_err("spaced sigil must fail");
1023 let steps = parse_script("ECHO $x", test_lower).expect("tight sigil parses");
1024 assert!(matches!(&steps[0].kind, StepKind::Echo(_)));
1025 }
1026
1027 #[test]
1028 fn let_async_block_parses() {
1029 let script = indoc! {r#"
1030 LET $task = ASYNC {
1031 RUN "echo hello"
1032 }
1033 "#};
1034 let steps = parse_script(script, test_lower).expect("parse should succeed");
1035 assert_eq!(steps.len(), 1);
1036 match &steps[0].kind {
1037 StepKind::AssignAsync { var, body } => {
1038 assert_eq!(var, "task");
1039 assert_eq!(body.len(), 1);
1040 assert!(matches!(&body[0].kind, StepKind::Run(_)));
1041 }
1042 other => panic!("expected AssignAsync, got {:?}", other),
1043 }
1044 }
1045
1046 #[test]
1047 fn let_async_inline_parses() {
1048 let script = "LET $t = ASYNC RUN \"echo hi\"";
1049 let steps = parse_script(script, test_lower).expect("parse should succeed");
1050 assert_eq!(steps.len(), 1);
1051 match &steps[0].kind {
1052 StepKind::AssignAsync { var, body } => {
1053 assert_eq!(var, "t");
1054 assert_eq!(body.len(), 1);
1055 assert!(matches!(&body[0].kind, StepKind::Run(_)));
1056 }
1057 other => panic!("expected AssignAsync, got {:?}", other),
1058 }
1059 }
1060
1061 #[test]
1062 fn await_parses() {
1063 let script = "AWAIT $task";
1064 let steps = parse_script(script, test_lower).expect("parse should succeed");
1065 assert_eq!(steps.len(), 1);
1066 match &steps[0].kind {
1067 StepKind::Await { var } => {
1068 assert_eq!(var, "task");
1069 }
1070 other => panic!("expected Await, got {:?}", other),
1071 }
1072 }
1073}