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!(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::AssertFile {
461 hash,
462 path,
463 contents,
464 } => {
465 assert_eq!(hash.as_deref(), Some("aabb"));
466 assert_eq!(path.as_ref(), "path.txt");
467 assert!(contents.is_none());
468 }
469 other => panic!("expected AssertFile, saw {:?}", other),
470 }
471 }
472
473 #[test]
474 fn mock_commands_parse_and_round_trip() {
475 let script = indoc! {r#"
476 WRITE "dist/hello.txt" "Built with OxDock"
477 WRITE "deeply/nested/tree"
478 WRITE "chained.txt"
479 WRITE "visible-after-comments"
480 "#};
481 let steps = parse_script(script, test_lower).expect("parse ok");
482 assert_eq!(steps.len(), 4);
483
484 for step in &steps {
486 assert!(
487 matches!(&step.kind, StepKind::Write { .. }),
488 "expected Write variant"
489 );
490 }
491 }
492
493 #[test]
494 fn quoted_string_content_preserved() {
495 let script = "WRITE 'echo \"a; b\"'";
496 let steps = parse_script(script, test_lower).expect("parse ok");
497 match &steps[0].kind {
498 StepKind::Write { path, .. } => assert_eq!(path, "echo \"a; b\""),
499 other => panic!("expected Write, saw {:?}", other),
500 }
501 }
502
503 #[test]
504 fn templated_argument_with_spaces() {
505 let script = "WRITE {{ env:OXBOOK_RUNNER_DIR }}";
506 let steps = parse_script(script, test_lower).expect("parse ok");
507 assert_eq!(steps.len(), 1);
508 match &steps[0].kind {
509 StepKind::Write { path, .. } => assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}"),
510 other => panic!("expected Write, saw {:?}", other),
511 }
512 }
513
514 #[test]
515 #[cfg(feature = "proc-macro-api")]
516 fn string_and_braced_scripts_produce_identical_ast() {
517 let mut cases = Vec::new();
518
519 cases.push((
520 indoc! {r#"
521 WRITE /tmp
522 WRITE hello
523 "#}
524 .trim()
525 .to_string(),
526 quote! {
527 WRITE /tmp
528 WRITE hello
529 },
530 ));
531
532 cases.push((
533 indoc! {r#"
534 [not(env:SKIP)]
535 [windows] WRITE win
536 [eq(env:MODE, beta), linux] WRITE combo
537 "#}
538 .trim()
539 .to_string(),
540 quote! {
541 [not(env:SKIP)]
542 [windows] WRITE win
543 [eq(env:MODE, beta), linux] WRITE combo
544 },
545 ));
546
547 cases.push((
548 indoc! {r#"
549 [env:OUTER] {
550 WRITE nested
551 [env:INNER] WRITE deep
552 }
553 "#}
554 .trim()
555 .to_string(),
556 quote! {
557 [env:OUTER] {
558 WRITE nested
559 [env:INNER] WRITE deep
560 }
561 },
562 ));
563
564 cases.push((
565 indoc! {r#"
566 [eq(env:TEST, 1)]
567 WITH_IO [stdout=pipe:capture_case] WRITE hi
568 WITH_IO [stdin=pipe:capture_case] WRITE out.txt
569 "#}
570 .trim()
571 .to_string(),
572 quote! {
573 [eq(env:TEST, 1)]
574 WITH_IO [stdout=pipe:capture_case] WRITE hi
575 WITH_IO [stdin=pipe:capture_case] WRITE out.txt
576 },
577 ));
578
579 for (idx, (literal, tokens)) in cases.iter().enumerate() {
580 let text = literal.trim();
581 let string_steps = parse_script(text, test_lower)
582 .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
583 let braced_steps = parse_braced_tokens(tokens, test_lower)
584 .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
585 assert_eq!(
586 string_steps, braced_steps,
587 "AST mismatch for case {idx} literal:\n{text}"
588 );
589 }
590 }
591
592 #[test]
593 fn let_assign_with_bare_word() {
594 let script = r#"LET $x: STRING = hello"#;
595 let steps = parse_script(script, test_lower).expect("parse ok");
596 assert_eq!(steps.len(), 1);
597 match &steps[0].kind {
598 StepKind::Assign {
599 var,
600 decl_type: _,
601 expr,
602 } => {
603 assert_eq!(var, "x");
604 assert_eq!(expr, &Expr::Literal(Value::String("hello".to_string())));
605 }
606 other => panic!("expected Assign, got {:?}", other),
607 }
608 }
609
610 #[test]
611 fn let_assign_with_quoted_string() {
612 let script = r#"LET $x: STRING = "hello world""#;
613 let steps = parse_script(script, test_lower).expect("parse ok");
614 assert_eq!(steps.len(), 1);
615 match &steps[0].kind {
616 StepKind::Assign {
617 var,
618 decl_type: _,
619 expr,
620 } => {
621 assert_eq!(var, "x");
622 assert_eq!(
623 expr,
624 &Expr::Literal(Value::String("hello world".to_string()))
625 );
626 }
627 other => panic!("expected Assign, got {:?}", other),
628 }
629 }
630
631 #[test]
632 fn let_assign_with_list_literal() {
633 let script = r#"LET $x: LIST = ["a", "b", "c"]"#;
634 let steps = parse_script(script, test_lower).expect("parse ok");
635 assert_eq!(steps.len(), 1);
636 match &steps[0].kind {
637 StepKind::Assign {
638 var,
639 decl_type: _,
640 expr,
641 } => {
642 assert_eq!(var, "x");
643 assert_eq!(
644 expr,
645 &Expr::List(vec![
646 Expr::Literal(Value::String("a".to_string())),
647 Expr::Literal(Value::String("b".to_string())),
648 Expr::Literal(Value::String("c".to_string()))
649 ])
650 );
651 }
652 other => panic!("expected Assign, got {:?}", other),
653 }
654 }
655
656 #[test]
657 fn let_assign_with_variable_ref() {
658 let script = r#"LET $x: STRING = $y"#;
659 let steps = parse_script(script, test_lower).expect("parse ok");
660 assert_eq!(steps.len(), 1);
661 match &steps[0].kind {
662 StepKind::Assign {
663 var,
664 decl_type: _,
665 expr,
666 } => {
667 assert_eq!(var, "x");
668 assert_eq!(expr, &Expr::Var("y".to_string()));
669 }
670 other => panic!("expected Assign, got {:?}", other),
671 }
672 }
673
674 #[test]
675 fn for_loop_parses() {
676 let script = indoc! {r#"
677 FOR $f: STRING IN ["x", "y"] {
678 WRITE $f
679 }
680 "#};
681 let steps = parse_script(script, test_lower).expect("parse ok");
682 assert_eq!(steps.len(), 1);
683 match &steps[0].kind {
684 StepKind::For {
685 key_var,
686 var,
687 in_expr,
688 body,
689 ..
690 } => {
691 assert!(key_var.is_none());
692 assert_eq!(var, "f");
693 assert_eq!(
694 in_expr,
695 &Expr::List(vec![
696 Expr::Literal(Value::String("x".to_string())),
697 Expr::Literal(Value::String("y".to_string()))
698 ])
699 );
700 assert_eq!(body.len(), 1);
701 }
702 other => panic!("expected For, got {:?}", other),
703 }
704 }
705
706 #[test]
707 fn for_map_iteration_parses() {
708 let script = indoc! {r#"
709 FOR $k: STRING, $v: STRING IN $map {
710 WRITE $k
711 }
712 "#};
713 let steps = parse_script(script, test_lower).expect("parse ok");
714 assert_eq!(steps.len(), 1);
715 match &steps[0].kind {
716 StepKind::For {
717 key_var,
718 var,
719 in_expr,
720 body,
721 ..
722 } => {
723 assert_eq!(key_var.as_deref(), Some("k"));
724 assert_eq!(var, "v");
725 assert_eq!(in_expr, &Expr::Var("map".to_string()));
726 assert_eq!(body.len(), 1);
727 }
728 other => panic!("expected For, got {:?}", other),
729 }
730 }
731
732 #[test]
733 fn if_statement_parses() {
734 let script = "IF true { WRITE yes }\n";
735 let steps = parse_script(script, test_lower).expect("parse ok");
736 assert_eq!(steps.len(), 1);
737 match &steps[0].kind {
738 StepKind::If { .. } => {}
739 other => panic!("expected If, got {:?}", other),
740 }
741 }
742
743 #[test]
744 fn if_keyword_matches_directly() {
745 use crate::lexer::{LanguageParser, Rule};
746 use pest::Parser;
747 let result = LanguageParser::parse(Rule::if_keyword, "IF ");
748 assert!(
749 result.is_ok(),
750 "if_keyword should match 'IF ': {:?}",
751 result.err()
752 );
753 }
754
755 #[test]
756 fn if_statement_pest_matches() {
757 use crate::lexer::{LanguageParser, Rule};
758 use pest::Parser;
759 let result = LanguageParser::parse(Rule::if_statement, "IF true {\n WRITE yes\n}");
760 assert!(
761 result.is_ok(),
762 "if_statement should match: {:?}",
763 result.err()
764 );
765 }
766
767 #[test]
768 fn not_expression_parses() {
769 let script = r#"LET $x: BOOL = !true"#;
770 let steps = parse_script(script, test_lower).expect("parse ok");
771 match &steps[0].kind {
772 StepKind::Assign {
773 var,
774 decl_type: _,
775 expr,
776 } => {
777 assert_eq!(var, "x");
778 assert_eq!(expr, &Expr::Not(Box::new(Expr::Literal(Value::Bool(true)))));
779 }
780 other => panic!("expected Assign, got {:?}", other),
781 }
782
783 let steps = parse_script(r#"LET $x: BOOL = !!false"#, test_lower).expect("parse ok");
785 match &steps[0].kind {
786 StepKind::Assign { expr, .. } => {
787 assert_eq!(
788 expr,
789 &Expr::Not(Box::new(Expr::Not(Box::new(Expr::Literal(Value::Bool(
790 false
791 ))))))
792 );
793 }
794 other => panic!("expected Assign, got {:?}", other),
795 }
796
797 let steps = parse_script(r#"LET $x: BOOL = !true == false"#, test_lower).expect("parse ok");
799 match &steps[0].kind {
800 StepKind::Assign { expr, .. } => {
801 assert!(matches!(expr, Expr::Compare { .. }), "got {expr:?}");
802 if let Expr::Compare { left, .. } = expr {
803 assert!(matches!(left.as_ref(), Expr::Not(_)), "got {left:?}");
804 }
805 }
806 other => panic!("expected Assign, got {:?}", other),
807 }
808
809 let steps =
811 parse_script(r#"LET $x: BOOL = !(true == false)"#, test_lower).expect("parse ok");
812 match &steps[0].kind {
813 StepKind::Assign { expr, .. } => {
814 assert!(matches!(expr, Expr::Not(_)), "got {expr:?}");
815 }
816 other => panic!("expected Assign, got {:?}", other),
817 }
818 }
819
820 #[test]
821 fn not_expression_display_round_trips() {
822 for script in [
823 "LET $x: BOOL = !true",
824 "LET $x: BOOL = !!false",
825 "LET $x: BOOL = !(true == false)",
826 "IF !true {\n WRITE yes\n}",
827 ] {
828 let steps = parse_script(script, test_lower).expect("parse");
829 let rendered: Vec<String> = steps.iter().map(|s| s.to_string()).collect();
830 let reparsed = parse_script(&rendered.join("\n"), test_lower).expect("reparse");
831 assert_eq!(steps, reparsed, "Display round-trip failed for {script}");
832 }
833 }
834
835 #[test]
836 fn guard_block_with_mock_command() {
837 let script = "CWD\n[env:GATE] {\n WRITE gated\n}\n[eq(env:A, 1)] WRITE eq\n";
838 let steps = parse_script(script, test_lower).expect("parse should succeed");
839 assert!(
840 steps.len() >= 2,
841 "expected at least 2 steps, got {}",
842 steps.len()
843 );
844 }
845
846 #[test]
847 fn single_line_blocks() {
848 let test_cases = [
849 "IF true { WRITE \"hello\" }",
850 "IF true { WRITE \"cargo test\" }",
851 "IF true { WRITE \"/app\" }",
852 ];
853 for script in test_cases {
854 assert!(
855 parse_script(script, test_lower).is_ok(),
856 "Failed to parse: {}",
857 script
858 );
859 }
860 }
861
862 #[test]
863 fn async_run_parses() {
864 let script = "ASYNC RUN \"echo hello\"";
865 let steps = parse_script(script, test_lower).expect("parse should succeed");
866 assert_eq!(steps.len(), 1);
867 assert!(matches!(&steps[0].kind, StepKind::AsyncBlock { .. }));
868 }
869
870 #[test]
871 fn async_block_parses() {
872 let script = indoc! {r#"
873 ASYNC {
874 RUN "echo one"
875 RUN "echo two"
876 }
877 "#};
878 let steps = parse_script(script, test_lower).expect("parse should succeed");
879 assert_eq!(steps.len(), 1);
880 match &steps[0].kind {
881 StepKind::AsyncBlock { body } => {
882 assert_eq!(body.len(), 2);
883 }
884 other => panic!("expected AsyncBlock, got {:?}", other),
885 }
886 }
887
888 #[test]
889 fn nested_async_parses() {
890 let script = "ASYNC ASYNC RUN \"echo nested\"";
891 let steps = parse_script(script, test_lower).expect("parse should succeed");
892 assert_eq!(steps.len(), 1);
893 match &steps[0].kind {
894 StepKind::AsyncBlock { body } => {
895 assert_eq!(body.len(), 1);
896 match &body[0].kind {
897 StepKind::AsyncBlock { body } => {
898 assert_eq!(body.len(), 1);
899 assert!(matches!(&body[0].kind, StepKind::Run(_)));
900 }
901 other => panic!("expected inner AsyncBlock, got {:?}", other),
902 }
903 }
904 other => panic!("expected outer AsyncBlock, got {:?}", other),
905 }
906 }
907
908 #[test]
909 fn nested_async_block_form_parses() {
910 let script = indoc! {r#"
911 ASYNC {
912 ASYNC {
913 RUN "echo nested"
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::AsyncBlock { body } => {
921 assert_eq!(body.len(), 1);
922 match &body[0].kind {
923 StepKind::AsyncBlock { body } => {
924 assert_eq!(body.len(), 1);
925 assert!(matches!(&body[0].kind, StepKind::Run(_)));
926 }
927 other => panic!("expected inner AsyncBlock, got {:?}", other),
928 }
929 }
930 other => panic!("expected outer AsyncBlock, got {:?}", other),
931 }
932 }
933
934 #[test]
935 fn with_io_wrapping_async_parses() {
936 let script = "WITH_IO [stdout] ASYNC RUN \"echo test\"";
937 let steps = parse_script(script, test_lower).expect("parse should succeed");
938 assert_eq!(steps.len(), 1);
939 match &steps[0].kind {
940 StepKind::WithIo { cmd, .. } => {
941 assert!(matches!(cmd.as_ref(), StepKind::AsyncBlock { .. }));
942 }
943 other => panic!("expected WithIo, got {:?}", other),
944 }
945 }
946
947 #[test]
948 fn with_io_async_block_nested_for_parses() {
949 let script = indoc! {r#"
953 WITH_IO [stdout=pipe:out] ASYNC {
954 FOR $x: INT IN [0, 1] {
955 ECHO hi
956 }
957 }
958 "#};
959 let steps = parse_script(script, test_lower).expect("parse should succeed");
960 assert_eq!(steps.len(), 1);
961 match &steps[0].kind {
962 StepKind::WithIo { bindings, cmd } => {
963 assert_eq!(bindings.len(), 1);
964 assert!(matches!(bindings[0].stream, IoStream::Stdout));
965 assert_eq!(bindings[0].pipe, Some(PipeTarget::Name("out".to_string())));
966 match cmd.as_ref() {
967 StepKind::AsyncBlock { body } => {
968 assert_eq!(body.len(), 1);
969 match &body[0].kind {
970 StepKind::For { var, body, .. } => {
971 assert_eq!(var, "x");
972 assert_eq!(body.len(), 1);
973 assert!(matches!(&body[0].kind, StepKind::Echo(_)));
974 }
975 other => panic!("expected For, got {:?}", other),
976 }
977 }
978 other => panic!("expected AsyncBlock, got {:?}", other),
979 }
980 }
981 other => panic!("expected WithIo, got {:?}", other),
982 }
983 }
984
985 #[test]
986 fn for_int_key_parses_for_list_enumeration() {
987 let script = indoc! {r#"
988 FOR $i: INT, $v: STRING IN $items {
989 WRITE $v
990 }
991 "#};
992 let steps = parse_script(script, test_lower).expect("parse ok");
993 match &steps[0].kind {
994 StepKind::For {
995 key_var,
996 key_type,
997 var,
998 var_type,
999 ..
1000 } => {
1001 assert_eq!(key_var.as_deref(), Some("i"));
1002 assert_eq!(*key_type, Some(crate::TypeKind::Int));
1003 assert_eq!(var, "v");
1004 assert_eq!(*var_type, crate::TypeKind::String);
1005 }
1006 other => panic!("expected For, got {:?}", other),
1007 }
1008 }
1009
1010 #[test]
1011 fn for_non_index_key_type_is_rejected() {
1012 let err = parse_script(
1013 "FOR $k: BOOL, $v: STRING IN $map { WRITE $v }\n",
1014 test_lower,
1015 )
1016 .expect_err("BOOL key must fail");
1017 assert!(
1018 err.to_string().contains("must be INT or STRING"),
1019 "unexpected error: {err}"
1020 );
1021 }
1022
1023 #[test]
1024 fn with_io_async_block_nested_if_else_parses() {
1025 let script = indoc! {r#"
1027 WITH_IO [stdout=pipe:out] ASYNC {
1028 IF $a == $b {
1029 ECHO yes
1030 } ELSE {
1031 ECHO no
1032 }
1033 }
1034 "#};
1035 let steps = parse_script(script, test_lower).expect("parse should succeed");
1036 match &steps[0].kind {
1037 StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1038 StepKind::AsyncBlock { body } => {
1039 assert!(matches!(&body[0].kind, StepKind::If { .. }));
1040 match &body[0].kind {
1041 StepKind::If { else_body, .. } => {
1042 assert_eq!(else_body.as_ref().map(Vec::len), Some(1));
1043 }
1044 other => panic!("expected If, got {:?}", other),
1045 }
1046 }
1047 other => panic!("expected AsyncBlock, got {:?}", other),
1048 },
1049 other => panic!("expected WithIo, got {:?}", other),
1050 }
1051 }
1052
1053 #[test]
1054 fn timeout_block_nested_for_parses() {
1055 let script = indoc! {r#"
1058 TIMEOUT 30s {
1059 FOR $x: INT IN [1] {
1060 ECHO hi
1061 }
1062 }
1063 "#};
1064 let steps = parse_script(script, test_lower).expect("parse should succeed");
1065 match &steps[0].kind {
1066 StepKind::Timeout { body, .. } => {
1067 assert_eq!(body.len(), 1);
1068 assert!(matches!(&body[0].kind, StepKind::For { .. }));
1069 }
1070 other => panic!("expected Timeout, got {:?}", other),
1071 }
1072 }
1073
1074 #[test]
1075 fn with_io_async_block_nested_let_map_parses() {
1076 let script = indoc! {r#"
1078 WITH_IO [stdout] ASYNC {
1079 LET $m: MAP = {a: 1, b: 2}
1080 }
1081 "#};
1082 let steps = parse_script(script, test_lower).expect("parse should succeed");
1083 match &steps[0].kind {
1084 StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1085 StepKind::AsyncBlock { body } => {
1086 assert!(matches!(&body[0].kind, StepKind::Assign { .. }));
1087 }
1088 other => panic!("expected AsyncBlock, got {:?}", other),
1089 },
1090 other => panic!("expected WithIo, got {:?}", other),
1091 }
1092 }
1093
1094 #[test]
1095 fn variable_sigil_binds_tightly() {
1096 parse_script("ECHO $ x", test_lower).expect_err("spaced sigil must fail");
1102 parse_script("LET $x: STRING = $ y", test_lower).expect_err("spaced sigil must fail");
1103 let steps = parse_script("ECHO $x", test_lower).expect("tight sigil parses");
1104 assert!(matches!(&steps[0].kind, StepKind::Echo(_)));
1105 }
1106
1107 #[test]
1108 fn let_async_block_parses() {
1109 let script = indoc! {r#"
1110 LET $task: HANDLE = ASYNC {
1111 RUN "echo hello"
1112 }
1113 "#};
1114 let steps = parse_script(script, test_lower).expect("parse should succeed");
1115 assert_eq!(steps.len(), 1);
1116 match &steps[0].kind {
1117 StepKind::AssignAsync { var, body, .. } => {
1118 assert_eq!(var, "task");
1119 assert_eq!(body.len(), 1);
1120 assert!(matches!(&body[0].kind, StepKind::Run(_)));
1121 }
1122 other => panic!("expected AssignAsync, got {:?}", other),
1123 }
1124 }
1125
1126 #[test]
1127 fn let_async_inline_parses() {
1128 let script = "LET $t: HANDLE = ASYNC RUN \"echo hi\"";
1129 let steps = parse_script(script, test_lower).expect("parse should succeed");
1130 assert_eq!(steps.len(), 1);
1131 match &steps[0].kind {
1132 StepKind::AssignAsync { var, body, .. } => {
1133 assert_eq!(var, "t");
1134 assert_eq!(body.len(), 1);
1135 assert!(matches!(&body[0].kind, StepKind::Run(_)));
1136 }
1137 other => panic!("expected AssignAsync, got {:?}", other),
1138 }
1139 }
1140
1141 #[test]
1142 fn await_parses() {
1143 let script = "AWAIT $task";
1144 let steps = parse_script(script, test_lower).expect("parse should succeed");
1145 assert_eq!(steps.len(), 1);
1146 match &steps[0].kind {
1147 StepKind::Await { var } => {
1148 assert_eq!(var, "task");
1149 }
1150 other => panic!("expected Await, got {:?}", other),
1151 }
1152 }
1153}