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