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