1#![allow(rustdoc::invalid_codeblock_attributes)]
14#![doc = include_str!("../docs/command_reference.md")]
15
16extern crate self as oxdock_parser;
17
18pub mod ast;
19pub mod command;
20pub mod commands;
21pub mod constants;
22pub mod error;
23mod lexer;
24pub mod literal;
25#[cfg(feature = "proc-macro-api")]
26mod macro_input;
27pub mod markdown;
28pub mod parser;
29pub mod strip_flags;
30pub mod tag;
31pub mod value;
32
33pub use ast::*;
34pub use command::{
35 ArgSpec, ArgType, CommandMeta, CommandSpec, Example, FlagSpec, FlagValueType, IoDirection,
36 Stream,
37};
38pub use commands::{all_metadata, all_structural_metadata, lower_command};
39pub use constants::*;
40pub use error::{ParseError, ParseErrorKind, ParseResult, SpanContext};
41pub use lexer::LANGUAGE_SPEC;
42pub use literal::{escape_dsl_string, render_dsl_literal, render_quoted};
43#[cfg(feature = "proc-macro-api")]
44pub use macro_input::{
45 DslMacroInput, ScriptSource, parse_braced_tokens, script_from_braced_tokens,
46 split_modules_prefix,
47};
48pub use markdown::{BlockMetadata, FencedBlock, expect_error_from_info, extract_fenced_blocks};
49pub use parser::{
50 parse_expression_str, parse_guard_expr_str, parse_script, parse_script_with_modules,
51 parse_script_with_preseed,
52};
53pub use strip_flags::strip_flags;
54pub use tag::{
55 ANY_TAG, Field, LIST_TAG, MAP_TAG, MAX_GENERIC_DEPTH, Spelled, SpelledField, TypeTag,
56 canonicalize_spelling, check_value, check_value_at, intern_composed, lookup_composed,
57 parse_spelling, render_structural,
58};
59
60pub mod test_lower_mock {
64 use crate::error::{ParseError, SpanContext};
65 use crate::{Arg, AssertTarget, ParseResult, StepKind, WorkspaceTarget};
66
67 fn validation(cmd: &str, msg: &str) -> ParseError {
68 ParseError::validation(cmd, msg.to_string(), &SpanContext::line_only(0))
69 }
70
71 pub fn lower(name: &str, args: Vec<Arg>) -> ParseResult<StepKind> {
72 match name {
73 "CWD" => Ok(StepKind::Cwd),
74 "WRITE" => {
75 let mut it = args.into_iter();
76 let path = it
77 .next()
78 .ok_or_else(|| validation("WRITE", "WRITE requires path"))?;
79 let remaining: Vec<_> = it.collect();
80 let contents = if remaining.is_empty() {
81 None
82 } else {
83 let joined = remaining
84 .iter()
85 .map(|a| a.as_str())
86 .collect::<Vec<_>>()
87 .join(" ");
88 Some(Arg::String(joined, false))
89 };
90 Ok(StepKind::Write { path, contents })
91 }
92 "HASH_SHA256" => {
93 let mut a = args;
94 if a.first().map(|a| a.as_str()) == Some("--hash") {
95 a.remove(0);
96 let hash = a
97 .first()
98 .ok_or_else(|| validation("HASH_SHA256", "--hash requires value"))?
99 .as_str()
100 .to_string();
101 a.remove(0);
102 let path = a
103 .first()
104 .ok_or_else(|| validation("HASH_SHA256", "HASH_SHA256 requires path"))?
105 .clone();
106 Ok(StepKind::AssertEq {
107 hash: Some(hash),
108 actual: AssertTarget::Value(path),
109 expected: None,
110 })
111 } else {
112 let path = a
113 .first()
114 .ok_or_else(|| validation("HASH_SHA256", "HASH_SHA256 requires path"))?
115 .clone();
116 let contents = a.get(1).cloned();
117 Ok(StepKind::AssertEq {
118 hash: None,
119 actual: AssertTarget::Value(path),
120 expected: contents,
121 })
122 }
123 }
124 "ENV" => crate::commands::lower_env_assignment(args)
125 .map_err(|e| validation("ENV", &e.to_string())),
126 "WORKSPACE" => {
127 let mut it = args.into_iter();
128 let target = it
129 .next()
130 .ok_or_else(|| validation("WORKSPACE", "requires target"))?;
131 let local = it.any(|a| a.as_str() == "--local");
132 match target.as_str() {
133 "SNAPSHOT" | "LOCAL" | "SYSTEM" if local => {
134 Err(validation("WORKSPACE", "--local requires CACHE"))
135 }
136 "SNAPSHOT" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
137 "LOCAL" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
138 "CACHE" => Ok(StepKind::Workspace(WorkspaceTarget::Cache { local })),
139 "SYSTEM" => Ok(StepKind::Workspace(WorkspaceTarget::System)),
140 _ => Err(validation("WORKSPACE", "unknown workspace target")),
141 }
142 }
143 "INHERIT_ENV" => {
144 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
145 Ok(StepKind::InheritEnv { keys })
146 }
147 "ECHO" => {
148 let msg = args
149 .into_iter()
150 .next()
151 .ok_or_else(|| validation("ECHO", "ECHO requires arg"))?;
152 Ok(StepKind::Echo(msg))
153 }
154 "RUN" => {
155 let cmd = args
156 .into_iter()
157 .next()
158 .ok_or_else(|| validation("RUN", "RUN requires arg"))?;
159 Ok(StepKind::Run(cmd))
160 }
161 "WORKDIR" => {
162 let path = args
163 .into_iter()
164 .next()
165 .ok_or_else(|| validation("WORKDIR", "requires path"))?;
166 Ok(StepKind::Workdir(path))
167 }
168 "COPY" => {
169 let mut from_host = false;
173 let mut to_host = false;
174 let mut positional = Vec::new();
175 for arg in args {
176 match arg.as_str() {
177 "--from-host" => from_host = true,
178 "--to-host" => to_host = true,
179 _ => positional.push(arg),
180 }
181 }
182 if from_host && to_host {
183 return Err(validation(
184 "COPY",
185 "COPY --from-host and --to-host are mutually exclusive",
186 ));
187 }
188 let mut it = positional.into_iter();
189 let from = it
190 .next()
191 .ok_or_else(|| validation("COPY", "COPY requires a source"))?;
192 let to = it
193 .next()
194 .ok_or_else(|| validation("COPY", "COPY requires a destination"))?;
195 Ok(StepKind::Copy {
196 from_workspace: None,
197 from_host,
198 to_host,
199 from,
200 to,
201 })
202 }
203 "ARCHIVE" => {
204 let raw = args.iter().any(|a| a.as_str() == "--raw");
205 let positional: Vec<_> =
206 args.into_iter().filter(|a| a.as_str() != "--raw").collect();
207 let mut it = positional.into_iter();
208 let src = it
209 .next()
210 .ok_or_else(|| validation("ARCHIVE", "ARCHIVE requires a source"))?;
211 let dst = it
212 .next()
213 .ok_or_else(|| validation("ARCHIVE", "ARCHIVE requires a destination"))?;
214 Ok(StepKind::Archive { src, dst, raw })
215 }
216 "UNARCHIVE" => {
217 let mut max_bytes = None;
218 let mut positional = Vec::new();
219 let mut iter = args.into_iter().peekable();
220 while let Some(arg) = iter.next() {
221 if arg.as_str() == "--max-bytes" {
222 max_bytes = iter.next();
223 } else {
224 positional.push(arg);
225 }
226 }
227 let mut it = positional.into_iter();
228 let src = it
229 .next()
230 .ok_or_else(|| validation("UNARCHIVE", "UNARCHIVE requires an archive"))?;
231 let dst = it
232 .next()
233 .ok_or_else(|| validation("UNARCHIVE", "UNARCHIVE requires a destination"))?;
234 Ok(StepKind::Unarchive {
235 src,
236 dst,
237 max_bytes,
238 })
239 }
240 _ => Err(ParseError::unknown_command(
241 name,
242 format!("unknown command: {name}"),
243 None,
244 &SpanContext::line_only(0),
245 )),
246 }
247 }
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use indoc::indoc;
254 #[cfg(feature = "proc-macro-api")]
255 use quote::quote;
256 use std::collections::HashMap;
257
258 fn test_lower(name: &str, args: Vec<Arg>) -> ParseResult<StepKind> {
260 crate::test_lower_mock::lower(name, args)
261 }
262
263 fn guard_text(step: &Step) -> Option<String> {
264 step.guard.as_ref().map(|g| g.to_string())
265 }
266
267 #[test]
268 fn commands_are_case_sensitive() {
269 for bad in ["cwd hi", "Cwd hi", "cwd foo"] {
270 parse_script(bad, test_lower).expect_err("mixed/lowercase commands must fail");
271 }
272 }
273
274 #[test]
275 fn string_dsl_supports_rust_style_comments() {
276 let script = indoc! {r#"
277 // leading comment line
278 CWD // inline comment
279 WRITE 'echo "keep // literal"'
280 /* block comment
281 CWD ignored
282 /* nested inner */
283 WRITE ignored as well
284 */
285 WRITE "echo final"
286 WRITE "echo 'literal /* stay */ value'"
287 "#};
288 let steps = parse_script(script, test_lower).expect("parse ok");
289 assert_eq!(steps.len(), 4, "expected 4 executable steps");
290 assert!(matches!(&steps[0].kind, StepKind::Cwd));
291 assert!(matches!(&steps[1].kind, StepKind::Write { .. }));
292 assert!(matches!(&steps[2].kind, StepKind::Write { .. }));
293 assert!(matches!(&steps[3].kind, StepKind::Write { .. }));
294 }
295
296 #[test]
297 fn string_dsl_errors_on_unclosed_block_comment() {
298 let script = indoc! {r#"
299 WRITE echo hi
300 /* unclosed
301 "#};
302 parse_script(script, test_lower).expect_err("should fail");
303 }
304
305 #[test]
306 fn semicolon_splits_instructions() {
307 let script = "WRITE \"echo hi\"; WRITE \"echo bye\"";
308 let steps = parse_script(script, test_lower).expect("parse ok");
309 assert_eq!(steps.len(), 2);
310 }
311
312 #[test]
313 fn guard_supports_colon_separator() {
314 let script = "[env:FOO] WRITE \"echo hi\"";
315 let steps = parse_script(script, test_lower).expect("parse ok");
316 assert_eq!(steps.len(), 1);
317 assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:FOO"));
318 }
319
320 #[test]
321 fn guard_lines_chain_before_block() {
322 let script = indoc! {r#"
323 [env:A]
324 [env:B]
325 {
326 WRITE ok.txt hi
327 }
328 "#};
329 let steps = parse_script(script, test_lower).expect("parse ok");
330 assert_eq!(steps.len(), 1);
331 assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
332 }
333
334 #[test]
335 fn guard_block_must_contain_command() {
336 let script = indoc! {r#"
337 [env.A] {
338 }
339 "#};
340 parse_script(script, test_lower).expect_err("empty block should fail");
341 }
342
343 #[test]
344 fn with_io_rejects_non_variable_bindings() {
345 let err = parse_script(
348 "WITH_IO [stdin, stdout=pipe:setup, stderr=pipe:errors] WRITE \"echo hi\"",
349 test_lower,
350 )
351 .expect_err("non-variable bindings must fail");
352 let msg = err.to_string();
353 assert!(
354 msg.contains("pipe:setup"),
355 "error must name the bad binding: {msg}"
356 );
357 assert_eq!(err.line(), 1, "error must name the failing line");
358 }
359
360 #[test]
361 fn with_io_supports_variable_pipes() {
362 let script = "WITH_IO [stdout=$p, stdin=$q] WRITE \"echo hi\"";
363 let steps = parse_script(script, test_lower).expect("parse ok");
364 assert_eq!(steps.len(), 1);
365 match &steps[0].kind {
366 StepKind::WithIo { bindings, cmd } => {
367 assert_eq!(bindings.len(), 2);
368 assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdout)
369 && b.pipe == Some(PipeTarget::Var("p".to_string()))));
370 assert!(bindings.iter().any(|b| matches!(b.stream, IoStream::Stdin)
371 && b.pipe == Some(PipeTarget::Var("q".to_string()))));
372 assert!(matches!(cmd.as_ref(), StepKind::Write { .. }));
373 }
374 other => panic!("expected WITH_IO, saw {:?}", other),
375 }
376 assert_eq!(
378 steps[0].kind.to_string(),
379 "WITH_IO [stdout=$p, stdin=$q] WRITE \"echo hi\""
380 );
381 }
382
383 #[test]
384 fn colon_text_in_expression_fails() {
385 let err = parse_script("LET $p: PIPE = pipe:ch", test_lower)
388 .expect_err("colon text in expression must fail");
389 assert_eq!(err.line(), 1, "error must name the failing line");
390 }
391
392 #[test]
393 fn colon_text_in_assert_is_a_plain_value() {
394 let steps = parse_script("ASSERT_EQ pipe:ch \"x\"", crate::commands::lower_command)
399 .expect("colon text parses as a literal");
400 assert_eq!(steps.len(), 1);
401 match &steps[0].kind {
402 StepKind::AssertEq { actual, .. } => {
403 assert_eq!(
404 actual,
405 &AssertTarget::Value(Arg::String("pipe:ch".to_string(), false)),
406 "colon text must stay a literal value, got {actual:?}"
407 );
408 }
409 other => panic!("expected AssertEq, got {other:?}"),
410 }
411 }
412
413 #[test]
414 fn grouped_int_assert_operand_types_as_int() {
415 let steps = parse_script("ASSERT_EQ $x 1_000_000", crate::commands::lower_command)
418 .expect("grouped int parses");
419 match &steps[0].kind {
420 StepKind::AssertEq { expected, .. } => {
421 assert_eq!(
422 expected,
423 &Some(Arg::Expr(Expr::Literal(Value::int(1_000_000)))),
424 "grouped digits must type as Int, got {expected:?}"
425 );
426 }
427 other => panic!("expected AssertEq, got {other:?}"),
428 }
429 }
430
431 #[test]
432 fn bare_let_pipe_declares_fresh_backend() {
433 let steps = parse_script("LET $p: PIPE", test_lower).expect("bare LET $p: PIPE parses");
434 assert_eq!(steps.len(), 1);
435 match &steps[0].kind {
436 StepKind::Assign {
437 var,
438 decl_type,
439 expr,
440 } => {
441 assert_eq!(var, "p");
442 assert_eq!(decl_type, "PIPE");
443 assert!(
444 matches!(expr, Expr::FreshPipe),
445 "expected FreshPipe, got {expr:?}"
446 );
447 }
448 other => panic!("expected Assign, got {other:?}"),
449 }
450 assert_eq!(steps[0].kind.to_string(), "LET $p: PIPE");
452 let again =
453 parse_script(&steps[0].kind.to_string(), test_lower).expect("Display round-trips");
454 assert_eq!(again, steps);
455 let err = parse_script("LET $x: STRING", test_lower).expect_err("bare STRING must fail");
457 assert!(
458 err.to_string().contains("requires an expression"),
459 "unexpected error: {err}"
460 );
461 }
462
463 #[test]
464 fn brace_blocks_require_guard() {
465 let script = indoc! {r#"
466 {
467 WRITE nope.txt hi
468 }
469 "#};
470 parse_script(script, test_lower).expect_err("unguarded block should fail");
471 }
472
473 #[test]
474 fn multi_line_guard_blocks_apply_to_next_command() {
475 let script = indoc! {r#"
476 [
477 env:A,
478 env:B
479 ]
480 WRITE "echo guarded"
481 "#};
482 let steps = parse_script(script, test_lower).expect("parse ok");
483 assert_eq!(steps.len(), 1);
484 assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
485 }
486
487 #[test]
488 fn guarded_brace_blocks_apply_to_all_inner_steps() {
489 let script = indoc! {r#"
490 [env:A] {
491 WRITE one.txt 1
492 WRITE two.txt 2
493 }
494 "#};
495 let steps = parse_script(script, test_lower).expect("parse ok");
496 assert_eq!(steps.len(), 2);
497 assert!(steps.iter().all(|s| s.guard.is_some()));
498 }
499
500 #[test]
501 fn nested_guard_blocks_stack() {
502 let script = indoc! {r#"
503 [env:A] {
504 WRITE outer.txt no
505 [env:B] {
506 WRITE nested.txt yes
507 }
508 }
509 "#};
510 let steps = parse_script(script, test_lower).expect("parse ok");
511 assert_eq!(steps.len(), 2);
512 assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A"));
513 assert_eq!(guard_text(&steps[1]).as_deref(), Some("env:A, env:B"));
514 }
515
516 #[test]
517 fn nested_guard_block_scopes_stack_counts() {
518 let script = indoc! {r#"
519 [env:A] {
520 WRITE outer.txt ok
521 [env:B] {
522 WRITE deep.txt ok
523 }
524 WRITE outer_again.txt ok
525 }
526 "#};
527 let steps = parse_script(script, test_lower).expect("parse ok");
528 assert_eq!(steps.len(), 3);
529 assert_eq!(steps[0].scope_enter, 1);
530 assert_eq!(steps[0].scope_exit, 0);
531 assert_eq!(steps[1].scope_enter, 1);
532 assert_eq!(steps[1].scope_exit, 1);
533 assert_eq!(steps[2].scope_enter, 0);
534 assert_eq!(steps[2].scope_exit, 1);
535 }
536
537 #[test]
538 fn guard_or_and_and_compose_as_expected() {
539 let script = indoc! {r#"
540 [env:A]
541 [any(env:B, env:C)]
542 WRITE "echo complex"
543 "#};
544 let steps = parse_script(script, test_lower).expect("parse ok");
545 assert_eq!(steps.len(), 1);
546 let guard = steps[0].guard.as_ref().expect("missing guard");
547 assert_eq!(guard.to_string(), "env:A, any(env:B, env:C)");
548
549 let mut env = HashMap::new();
550 env.insert("A".into(), "1".into());
551 env.insert("B".into(), "1".into());
552 assert!(guard_expr_allows(guard, &env), "A && B should pass");
553
554 env.remove("B");
555 env.insert("C".into(), "1".into());
556 assert!(guard_expr_allows(guard, &env), "A && C should pass");
557
558 env.remove("C");
559 assert!(!guard_expr_allows(guard, &env), "A without B/C should fail");
560 }
561
562 #[test]
563 fn guard_or_requires_at_least_one_branch() {
564 let expr = GuardExpr::or(vec![
565 Guard::Attr {
566 ns: Ns::Env,
567 key: Some("MISSING".into()),
568 val: None,
569 }
570 .into(),
571 Guard::Attr {
572 ns: Ns::Env,
573 key: Some("ALSO_MISSING".into()),
574 val: None,
575 }
576 .into(),
577 ]);
578 assert!(!guard_expr_allows(&expr, &HashMap::new()));
579 let mut env = HashMap::new();
580 env.insert("MISSING".into(), "1".into());
581 assert!(guard_expr_allows(&expr, &env));
582 }
583
584 #[test]
585 fn guard_or_can_chain_with_additional_predicates() {
586 let script = "[any(env:A, os:linux), os:macos] WRITE \"echo hi\"";
587 let steps = parse_script(script, test_lower).expect("parse ok");
588 assert_eq!(steps.len(), 1);
589 let guard = steps[0].guard.as_ref().expect("missing guard");
590 assert_eq!(guard.to_string(), "any(env:A, os:linux), os:macos");
591 let GuardExpr::All(children) = guard else {
592 panic!("expected ALL guard");
593 };
594 assert!(matches!(children[0], GuardExpr::Or(_)));
595 match &children[1] {
596 GuardExpr::Predicate(Guard::Attr {
597 ns: Ns::Os,
598 key: None,
599 val: Some(value),
600 }) if value == "macos" => {}
601 other => panic!("unexpected trailing guard: {other:?}"),
602 }
603 }
604
605 #[test]
606 fn guard_or_guard_line_parses() {
607 use crate::lexer::{LanguageParser, Rule};
608 use pest::Parser;
609 LanguageParser::parse(Rule::guard_line, "[any(os:linux, env:FOO)]")
610 .expect("guard guard line should parse");
611 }
612
613 #[test]
614 fn guard_bare_platform_word_fails_to_parse() {
615 let err = parse_script("[unix] WRITE \"echo hi\"", test_lower)
618 .expect_err("bare platform must fail");
619 let text = format!("{err:#}");
620 assert!(!text.contains("family:unix"), "{text}");
621 }
622
623 #[test]
624 fn env_equals_guard_with_not_wrapper() {
625 let g = GuardExpr::Not(Box::new(GuardExpr::Predicate(Guard::Attr {
626 ns: Ns::Env,
627 key: Some("A".into()),
628 val: Some("1".into()),
629 })));
630 let mut env = HashMap::new();
631 env.insert("A".into(), "1".into());
632 assert!(!guard_expr_allows(&g, &env));
633 env.insert("A".into(), "2".into());
634 assert!(guard_expr_allows(&g, &env));
635 }
636
637 #[test]
638 fn guard_block_emits_scope_markers() {
639 let script = indoc! {r#"
640 ENV RUN=1
641 [env:RUN] {
642 WRITE one.txt 1
643 WRITE two.txt 2
644 }
645 WRITE three.txt 3
646 "#};
647 let steps = parse_script(script, test_lower).expect("parse ok");
648 assert_eq!(steps.len(), 4);
649 assert_eq!(steps[1].scope_enter, 1);
650 assert_eq!(steps[1].scope_exit, 0);
651 assert_eq!(steps[2].scope_enter, 0);
652 assert_eq!(steps[2].scope_exit, 1);
653 assert_eq!(steps[3].scope_enter, 0);
654 assert_eq!(steps[3].scope_exit, 0);
655 }
656
657 #[test]
658 fn mock_hash_form_parses() {
659 let script = "HASH_SHA256 --hash aabb path.txt";
660 let steps = parse_script(script, test_lower).expect("parse ok");
661 match &steps[0].kind {
662 StepKind::AssertEq {
663 hash,
664 actual,
665 expected,
666 } => {
667 assert_eq!(hash.as_deref(), Some("aabb"));
668 assert_eq!(
669 actual,
670 &AssertTarget::Value(Arg::String("path.txt".to_string(), false))
671 );
672 assert_eq!(expected, &None);
673 }
674 other => panic!("expected AssertEq, saw {:?}", other),
675 }
676 }
677
678 #[test]
679 fn mock_commands_parse_and_round_trip() {
680 let script = indoc! {r#"
681 WRITE "dist/hello.txt" "Built with OxDock"
682 WRITE "deeply/nested/tree"
683 WRITE "chained.txt"
684 WRITE "visible-after-comments"
685 "#};
686 let steps = parse_script(script, test_lower).expect("parse ok");
687 assert_eq!(steps.len(), 4);
688
689 for step in &steps {
691 assert!(
692 matches!(&step.kind, StepKind::Write { .. }),
693 "expected Write variant"
694 );
695 }
696 }
697
698 #[test]
699 fn quoted_string_content_preserved() {
700 let script = "WRITE 'echo \"a; b\"'";
701 let steps = parse_script(script, test_lower).expect("parse ok");
702 match &steps[0].kind {
703 StepKind::Write { path, .. } => assert_eq!(path, "echo \"a; b\""),
704 other => panic!("expected Write, saw {:?}", other),
705 }
706 }
707
708 #[test]
709 fn templated_argument_with_spaces() {
710 let script = "WRITE {{ env:OXBOOK_RUNNER_DIR }}";
711 let steps = parse_script(script, test_lower).expect("parse ok");
712 assert_eq!(steps.len(), 1);
713 match &steps[0].kind {
714 StepKind::Write { path, .. } => assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}"),
715 other => panic!("expected Write, saw {:?}", other),
716 }
717 }
718
719 #[test]
720 #[cfg(feature = "proc-macro-api")]
721 fn string_and_braced_scripts_produce_identical_ast() {
722 let mut cases = Vec::new();
723
724 cases.push((
725 indoc! {r#"
726 WRITE /tmp
727 WRITE hello
728 "#}
729 .trim()
730 .to_string(),
731 quote! {
732 WRITE /tmp
733 WRITE hello
734 },
735 ));
736
737 cases.push((
738 indoc! {r#"
739 [not(env:SKIP)]
740 [family:windows] WRITE win
741 [eq(env:MODE, beta), os:linux] WRITE combo
742 "#}
743 .trim()
744 .to_string(),
745 quote! {
746 [not(env:SKIP)]
747 [family:windows] WRITE win
748 [eq(env:MODE, beta), os:linux] WRITE combo
749 },
750 ));
751
752 cases.push((
753 indoc! {r#"
754 [env:OUTER] {
755 WRITE nested
756 [env:INNER] WRITE deep
757 }
758 "#}
759 .trim()
760 .to_string(),
761 quote! {
762 [env:OUTER] {
763 WRITE nested
764 [env:INNER] WRITE deep
765 }
766 },
767 ));
768
769 cases.push((
770 indoc! {r#"
771 [eq(env:TEST, 1)]
772 WITH_IO [stdout=$capture_case] WRITE hi
773 WITH_IO [stdin=$capture_case] WRITE out.txt
774 "#}
775 .trim()
776 .to_string(),
777 quote! {
778 [eq(env:TEST, 1)]
779 WITH_IO [stdout=$capture_case] WRITE hi
780 WITH_IO [stdin=$capture_case] WRITE out.txt
781 },
782 ));
783
784 for (idx, (literal, tokens)) in cases.iter().enumerate() {
785 let text = literal.trim();
786 let string_steps = parse_script(text, test_lower)
787 .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
788 let braced_steps = parse_braced_tokens(tokens, test_lower)
789 .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
790 assert_eq!(
791 string_steps, braced_steps,
792 "AST mismatch for case {idx} literal:\n{text}"
793 );
794 }
795 }
796
797 #[test]
798 fn let_assign_with_bare_word() {
799 let script = r#"LET $x: STRING = hello"#;
800 let steps = parse_script(script, test_lower).expect("parse ok");
801 assert_eq!(steps.len(), 1);
802 match &steps[0].kind {
803 StepKind::Assign {
804 var,
805 decl_type: _,
806 expr,
807 } => {
808 assert_eq!(var, "x");
809 assert_eq!(expr, &Expr::Literal(Value::string("hello".to_string())));
810 }
811 other => panic!("expected Assign, got {:?}", other),
812 }
813 }
814
815 #[test]
816 fn let_assign_grouped_int_literal() {
817 let steps =
818 parse_script("LET $x: INT = 1_000_000", test_lower).expect("grouped int parses");
819 match &steps[0].kind {
820 StepKind::Assign { expr, .. } => {
821 assert_eq!(expr, &Expr::Literal(Value::int(1_000_000)));
822 }
823 other => panic!("expected Assign, got {other:?}"),
824 }
825 let steps =
827 parse_script("LET $f: FLOAT = 1_024.768", test_lower).expect("grouped float parses");
828 match &steps[0].kind {
829 StepKind::Assign { expr, .. } => {
830 assert_eq!(expr, &Expr::Literal(Value::float(1024.768)));
831 }
832 other => panic!("expected Assign, got {other:?}"),
833 }
834 let steps = parse_script("ECHO 1__0", test_lower).expect("parses as text");
837 match &steps[0].kind {
838 StepKind::Echo(arg) => assert_eq!(arg.as_str(), "1__0"),
839 other => panic!("expected Echo, got {other:?}"),
840 }
841 }
842
843 #[test]
844 fn grouped_numeric_boundaries() {
845 for (spelling, expected) in [
849 ("0", Value::int(0)),
850 ("1_000", Value::int(1_000)),
851 ("1_000_000", Value::int(1_000_000)),
852 ("9_223_372_036_854_775_807", Value::int(i64::MAX)),
853 ("1_024.768", Value::float(1024.768)),
854 ("0.5", Value::float(0.5)),
855 ] {
856 let script = format!("LET $x: ANY = {spelling}");
857 let steps = parse_script(&script, test_lower).expect("parses");
858 match &steps[0].kind {
859 StepKind::Assign { expr, .. } => {
860 assert_eq!(expr, &Expr::Literal(expected.clone()), "{spelling:?}");
861 }
862 other => panic!("expected Assign, got {other:?}"),
863 }
864 let plain = spelling.replace('_', "");
866 let script = format!("LET $x: ANY = {plain}");
867 let again = parse_script(&script, test_lower).expect("parses");
868 assert_eq!(again, steps, "{spelling:?} must stage like {plain:?}");
869 }
870 for spelling in ["9_223_372_036_854_775_808", "9223372036854775808"] {
874 let err = parse_script(&format!("LET $x: ANY = {spelling}"), test_lower)
875 .expect_err("boundary outside negation must fail");
876 assert!(err.to_string().contains("exceeds i64::MAX"), "{err}");
877 }
878 let steps = parse_script("LET $x: INT = -9_223_372_036_854_775_808", test_lower)
879 .expect("negated boundary parses");
880 match &steps[0].kind {
881 StepKind::Assign { expr, .. } => {
882 assert_eq!(expr, &Expr::Literal(Value::int(i64::MIN)));
883 }
884 other => panic!("expected Assign, got {other:?}"),
885 }
886 let err = parse_script("LET $x: ANY = 9_223_372_036_854_775_809", test_lower)
887 .expect_err("overflow must fail");
888 assert!(err.to_string().contains("exceeds i64::MAX"), "{err}");
889 }
890
891 #[test]
892 fn let_assign_with_quoted_string() {
893 let script = r#"LET $x: STRING = "hello world""#;
894 let steps = parse_script(script, test_lower).expect("parse ok");
895 assert_eq!(steps.len(), 1);
896 match &steps[0].kind {
897 StepKind::Assign {
898 var,
899 decl_type: _,
900 expr,
901 } => {
902 assert_eq!(var, "x");
903 assert_eq!(
904 expr,
905 &Expr::Literal(Value::string("hello world".to_string()))
906 );
907 }
908 other => panic!("expected Assign, got {:?}", other),
909 }
910 }
911
912 #[test]
913 fn let_assign_with_list_literal() {
914 let script = r#"LET $x: LIST = ["a", "b", "c"]"#;
915 let steps = parse_script(script, test_lower).expect("parse ok");
916 assert_eq!(steps.len(), 1);
917 match &steps[0].kind {
918 StepKind::Assign {
919 var,
920 decl_type: _,
921 expr,
922 } => {
923 assert_eq!(var, "x");
924 assert_eq!(
925 expr,
926 &Expr::List(vec![
927 Expr::Literal(Value::string("a".to_string())),
928 Expr::Literal(Value::string("b".to_string())),
929 Expr::Literal(Value::string("c".to_string()))
930 ])
931 );
932 }
933 other => panic!("expected Assign, got {:?}", other),
934 }
935 }
936
937 #[test]
938 fn let_assign_with_variable_ref() {
939 let script = r#"LET $x: STRING = $y"#;
940 let steps = parse_script(script, test_lower).expect("parse ok");
941 assert_eq!(steps.len(), 1);
942 match &steps[0].kind {
943 StepKind::Assign {
944 var,
945 decl_type: _,
946 expr,
947 } => {
948 assert_eq!(var, "x");
949 assert_eq!(expr, &Expr::Var("y".to_string()));
950 }
951 other => panic!("expected Assign, got {:?}", other),
952 }
953 }
954
955 #[test]
956 fn let_assign_with_block() {
957 let script = r#"LET $a: STRING = { RETURN "hello" }"#;
958 let steps = parse_script(script, test_lower).expect("parse ok");
959 assert_eq!(steps.len(), 1);
960 match &steps[0].kind {
961 StepKind::Assign { var, expr, .. } => {
962 assert_eq!(var, "a");
963 match expr {
964 Expr::Block(body) => {
965 assert_eq!(body.len(), 1);
966 assert!(matches!(body[0].kind, StepKind::Return { .. }));
967 }
968 other => panic!("expected Block, got {:?}", other),
969 }
970 }
971 other => panic!("expected Assign, got {:?}", other),
972 }
973 }
974
975 #[test]
976 fn let_assign_multiline_block_with_nesting() {
977 let script = indoc! {r#"
978 LET $a: STRING = {
979 LET $b: STRING = { RETURN "hi" }
980 RETURN $b
981 }
982 "#};
983 let steps = parse_script(script, test_lower).expect("parse ok");
984 assert_eq!(steps.len(), 1);
985 match &steps[0].kind {
986 StepKind::Assign { expr, .. } => match expr {
987 Expr::Block(body) => {
988 assert_eq!(body.len(), 2);
989 assert!(matches!(body[0].kind, StepKind::Assign { .. }));
990 assert!(matches!(body[1].kind, StepKind::Return { .. }));
991 let StepKind::Assign { expr: inner, .. } = &body[0].kind else {
992 panic!("expected inner Assign");
993 };
994 assert!(matches!(inner, Expr::Block(_)));
995 }
996 other => panic!("expected Block, got {:?}", other),
997 },
998 other => panic!("expected Assign, got {:?}", other),
999 }
1000 }
1001
1002 #[test]
1003 fn let_assign_map_literal_stays_map() {
1004 let script = r#"LET $m: MAP = {a: 1, b: 2}"#;
1005 let steps = parse_script(script, test_lower).expect("parse ok");
1006 match &steps[0].kind {
1007 StepKind::Assign { expr, .. } => {
1008 assert!(matches!(expr, Expr::Map(entries) if entries.len() == 2));
1009 }
1010 other => panic!("expected Assign, got {:?}", other),
1011 }
1012 }
1013
1014 #[test]
1015 fn map_literal_spans_lines_with_comments() {
1016 let script = indoc! {r#"
1017 LET $m: MAP = {
1018 // leading comment
1019 "a": 1, /* trailing */
1020 // own line
1021 b: 2
1022 }
1023 "#};
1024 let steps = parse_script(script, test_lower).expect("parse ok");
1025 match &steps[0].kind {
1026 StepKind::Assign { expr, .. } => match expr {
1027 Expr::Map(entries) => {
1028 assert_eq!(entries.len(), 2);
1029 assert_eq!(entries[0].0, "a");
1030 assert_eq!(entries[1].0, "b");
1031 }
1032 other => panic!("expected Map, got {:?}", other),
1033 },
1034 other => panic!("expected Assign, got {:?}", other),
1035 }
1036 }
1037
1038 #[test]
1039 fn call_args_accept_comments_between_lines() {
1040 let script = indoc! {r#"
1041 FUNC SERVE($h: STRING, $u: STRING, $p: STRING, $o: MAP) {
1042 RETURN $h
1043 }
1044 LET $s: STRING = SERVE(
1045 // host port
1046 "127.0.0.1:2251",
1047 "test",
1048 "test123", {
1049 // workspace-relative key
1050 key_path: "/temp/test_key"
1051 }
1052 )
1053 "#};
1054 let steps = parse_script(script, test_lower).expect("parse ok");
1055 assert_eq!(steps.len(), 2);
1056 match &steps[1].kind {
1057 StepKind::Assign { expr, .. } => match expr {
1058 Expr::Call { name, args } => {
1059 assert_eq!(name, "SCRIPT::SERVE");
1060 assert_eq!(args.len(), 4);
1061 assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
1062 }
1063 other => panic!("expected Call, got {:?}", other),
1064 },
1065 other => panic!("expected Assign, got {:?}", other),
1066 }
1067 }
1068
1069 #[test]
1070 fn hash_comments_trail_map_entries() {
1071 let script = indoc! {r#"
1072 # 1. Ephemeral server setup
1073 LET $m: MAP = {
1074 key_path: "temp/test_key" # Fixed: Relative workspace pathing
1075 }
1076 "#};
1077 let steps = parse_script(script, test_lower).expect("parse ok");
1078 match &steps[0].kind {
1079 StepKind::Assign { expr, .. } => match expr {
1080 Expr::Map(entries) => {
1081 assert_eq!(entries.len(), 1);
1082 assert_eq!(entries[0].0, "key_path");
1083 }
1084 other => panic!("expected Map, got {:?}", other),
1085 },
1086 other => panic!("expected Assign, got {:?}", other),
1087 }
1088 }
1089
1090 #[test]
1091 fn hash_comments_span_call_args_like_slash_comments() {
1092 let script = indoc! {r#"
1093 FUNC SERVE($h: STRING, $o: MAP) {
1094 RETURN $h
1095 }
1096 # leading hash comment
1097 LET $s: STRING = SERVE(
1098 # host port
1099 "127.0.0.1:2251", {
1100 # workspace-relative key
1101 key_path: "temp/test_key" # trailing hash comment
1102 }
1103 )
1104 "#};
1105 let steps = parse_script(script, test_lower).expect("parse ok");
1106 assert_eq!(steps.len(), 2);
1107 match &steps[1].kind {
1108 StepKind::Assign { expr, .. } => match expr {
1109 Expr::Call { name, args } => {
1110 assert_eq!(name, "SCRIPT::SERVE");
1111 assert_eq!(args.len(), 2);
1112 assert!(matches!(&args[1], Expr::Map(entries) if entries.len() == 1));
1113 }
1114 other => panic!("expected Call, got {:?}", other),
1115 },
1116 other => panic!("expected Assign, got {:?}", other),
1117 }
1118 }
1119
1120 #[test]
1121 fn for_loop_parses() {
1122 let script = indoc! {r#"
1123 FOR $f: STRING IN ["x", "y"] {
1124 WRITE $f
1125 }
1126 "#};
1127 let steps = parse_script(script, test_lower).expect("parse ok");
1128 assert_eq!(steps.len(), 1);
1129 match &steps[0].kind {
1130 StepKind::For {
1131 key_var,
1132 var,
1133 in_expr,
1134 body,
1135 ..
1136 } => {
1137 assert!(key_var.is_none());
1138 assert_eq!(var, "f");
1139 assert_eq!(
1140 in_expr,
1141 &Expr::List(vec![
1142 Expr::Literal(Value::string("x".to_string())),
1143 Expr::Literal(Value::string("y".to_string()))
1144 ])
1145 );
1146 assert_eq!(body.len(), 1);
1147 }
1148 other => panic!("expected For, got {:?}", other),
1149 }
1150 }
1151
1152 #[test]
1153 fn for_map_iteration_parses() {
1154 let script = indoc! {r#"
1155 FOR $k: STRING, $v: STRING IN $map {
1156 WRITE $k
1157 }
1158 "#};
1159 let steps = parse_script(script, test_lower).expect("parse ok");
1160 assert_eq!(steps.len(), 1);
1161 match &steps[0].kind {
1162 StepKind::For {
1163 key_var,
1164 var,
1165 in_expr,
1166 body,
1167 ..
1168 } => {
1169 assert_eq!(key_var.as_deref(), Some("k"));
1170 assert_eq!(var, "v");
1171 assert_eq!(in_expr, &Expr::Var("map".to_string()));
1172 assert_eq!(body.len(), 1);
1173 }
1174 other => panic!("expected For, got {:?}", other),
1175 }
1176 }
1177
1178 #[test]
1179 fn if_statement_parses() {
1180 let script = "IF true { WRITE yes }\n";
1181 let steps = parse_script(script, test_lower).expect("parse ok");
1182 assert_eq!(steps.len(), 1);
1183 match &steps[0].kind {
1184 StepKind::If { .. } => {}
1185 other => panic!("expected If, got {:?}", other),
1186 }
1187 }
1188
1189 #[test]
1190 fn if_keyword_matches_directly() {
1191 use crate::lexer::{LanguageParser, Rule};
1192 use pest::Parser;
1193 let result = LanguageParser::parse(Rule::if_keyword, "IF ");
1194 assert!(
1195 result.is_ok(),
1196 "if_keyword should match 'IF ': {:?}",
1197 result.err()
1198 );
1199 }
1200
1201 #[test]
1202 fn if_statement_pest_matches() {
1203 use crate::lexer::{LanguageParser, Rule};
1204 use pest::Parser;
1205 let result = LanguageParser::parse(Rule::if_statement, "IF true {\n WRITE yes\n}");
1206 assert!(
1207 result.is_ok(),
1208 "if_statement should match: {:?}",
1209 result.err()
1210 );
1211 }
1212
1213 #[test]
1214 fn not_expression_parses() {
1215 let script = r#"LET $x: BOOL = !true"#;
1216 let steps = parse_script(script, test_lower).expect("parse ok");
1217 match &steps[0].kind {
1218 StepKind::Assign {
1219 var,
1220 decl_type: _,
1221 expr,
1222 } => {
1223 assert_eq!(var, "x");
1224 assert_eq!(expr, &Expr::Not(Box::new(Expr::Literal(Value::bool(true)))));
1225 }
1226 other => panic!("expected Assign, got {:?}", other),
1227 }
1228
1229 let steps = parse_script(r#"LET $x: BOOL = !!false"#, test_lower).expect("parse ok");
1231 match &steps[0].kind {
1232 StepKind::Assign { expr, .. } => {
1233 assert_eq!(
1234 expr,
1235 &Expr::Not(Box::new(Expr::Not(Box::new(Expr::Literal(Value::bool(
1236 false
1237 ))))))
1238 );
1239 }
1240 other => panic!("expected Assign, got {:?}", other),
1241 }
1242
1243 let steps = parse_script(r#"LET $x: BOOL = !true == false"#, test_lower).expect("parse ok");
1245 match &steps[0].kind {
1246 StepKind::Assign { expr, .. } => {
1247 assert!(matches!(expr, Expr::Compare { .. }), "got {expr:?}");
1248 if let Expr::Compare { left, .. } = expr {
1249 assert!(matches!(left.as_ref(), Expr::Not(_)), "got {left:?}");
1250 }
1251 }
1252 other => panic!("expected Assign, got {:?}", other),
1253 }
1254
1255 let steps =
1257 parse_script(r#"LET $x: BOOL = !(true == false)"#, test_lower).expect("parse ok");
1258 match &steps[0].kind {
1259 StepKind::Assign { expr, .. } => {
1260 assert!(matches!(expr, Expr::Not(_)), "got {expr:?}");
1261 }
1262 other => panic!("expected Assign, got {:?}", other),
1263 }
1264 }
1265
1266 #[test]
1267 fn not_expression_display_round_trips() {
1268 for script in [
1269 "LET $x: BOOL = !true",
1270 "LET $x: BOOL = !!false",
1271 "LET $x: BOOL = !(true == false)",
1272 "IF !true {\n WRITE yes\n}",
1273 ] {
1274 let steps = parse_script(script, test_lower).expect("parse");
1275 let rendered: Vec<String> = steps.iter().map(|s| s.to_string()).collect();
1276 let reparsed = parse_script(&rendered.join("\n"), test_lower).expect("reparse");
1277 assert_eq!(steps, reparsed, "Display round-trip failed for {script}");
1278 }
1279 }
1280
1281 #[test]
1282 fn guard_block_with_mock_command() {
1283 let script = "CWD\n[env:GATE] {\n WRITE gated\n}\n[eq(env:A, 1)] WRITE eq\n";
1284 let steps = parse_script(script, test_lower).expect("parse should succeed");
1285 assert!(
1286 steps.len() >= 2,
1287 "expected at least 2 steps, got {}",
1288 steps.len()
1289 );
1290 }
1291
1292 #[test]
1293 fn single_line_blocks() {
1294 let test_cases = [
1295 "IF true { WRITE \"hello\" }",
1296 "IF true { WRITE \"cargo test\" }",
1297 "IF true { WRITE \"/app\" }",
1298 ];
1299 for script in test_cases {
1300 assert!(
1301 parse_script(script, test_lower).is_ok(),
1302 "Failed to parse: {}",
1303 script
1304 );
1305 }
1306 }
1307
1308 #[test]
1309 fn async_run_parses() {
1310 let script = "ASYNC RUN \"echo hello\"";
1311 let steps = parse_script(script, test_lower).expect("parse should succeed");
1312 assert_eq!(steps.len(), 1);
1313 assert!(matches!(&steps[0].kind, StepKind::AsyncBlock { .. }));
1314 }
1315
1316 #[test]
1317 fn async_block_parses() {
1318 let script = indoc! {r#"
1319 ASYNC {
1320 RUN "echo one"
1321 RUN "echo two"
1322 }
1323 "#};
1324 let steps = parse_script(script, test_lower).expect("parse should succeed");
1325 assert_eq!(steps.len(), 1);
1326 match &steps[0].kind {
1327 StepKind::AsyncBlock { body } => {
1328 assert_eq!(body.len(), 2);
1329 }
1330 other => panic!("expected AsyncBlock, got {:?}", other),
1331 }
1332 }
1333
1334 #[test]
1335 fn nested_async_parses() {
1336 let script = "ASYNC ASYNC RUN \"echo nested\"";
1337 let steps = parse_script(script, test_lower).expect("parse should succeed");
1338 assert_eq!(steps.len(), 1);
1339 match &steps[0].kind {
1340 StepKind::AsyncBlock { body } => {
1341 assert_eq!(body.len(), 1);
1342 match &body[0].kind {
1343 StepKind::AsyncBlock { body } => {
1344 assert_eq!(body.len(), 1);
1345 assert!(matches!(&body[0].kind, StepKind::Run(_)));
1346 }
1347 other => panic!("expected inner AsyncBlock, got {:?}", other),
1348 }
1349 }
1350 other => panic!("expected outer AsyncBlock, got {:?}", other),
1351 }
1352 }
1353
1354 #[test]
1355 fn nested_async_block_form_parses() {
1356 let script = indoc! {r#"
1357 ASYNC {
1358 ASYNC {
1359 RUN "echo nested"
1360 }
1361 }
1362 "#};
1363 let steps = parse_script(script, test_lower).expect("parse should succeed");
1364 assert_eq!(steps.len(), 1);
1365 match &steps[0].kind {
1366 StepKind::AsyncBlock { body } => {
1367 assert_eq!(body.len(), 1);
1368 match &body[0].kind {
1369 StepKind::AsyncBlock { body } => {
1370 assert_eq!(body.len(), 1);
1371 assert!(matches!(&body[0].kind, StepKind::Run(_)));
1372 }
1373 other => panic!("expected inner AsyncBlock, got {:?}", other),
1374 }
1375 }
1376 other => panic!("expected outer AsyncBlock, got {:?}", other),
1377 }
1378 }
1379
1380 #[test]
1381 fn with_io_wrapping_async_parses() {
1382 let script = "WITH_IO [stdout] ASYNC RUN \"echo test\"";
1383 let steps = parse_script(script, test_lower).expect("parse should succeed");
1384 assert_eq!(steps.len(), 1);
1385 match &steps[0].kind {
1386 StepKind::WithIo { cmd, .. } => {
1387 assert!(matches!(cmd.as_ref(), StepKind::AsyncBlock { .. }));
1388 }
1389 other => panic!("expected WithIo, got {:?}", other),
1390 }
1391 }
1392
1393 #[test]
1394 fn with_io_async_block_nested_for_parses() {
1395 let script = indoc! {r#"
1399 WITH_IO [stdout=$out] ASYNC {
1400 FOR $x: INT IN [0, 1] {
1401 ECHO hi
1402 }
1403 }
1404 "#};
1405 let steps = parse_script(script, test_lower).expect("parse should succeed");
1406 assert_eq!(steps.len(), 1);
1407 match &steps[0].kind {
1408 StepKind::WithIo { bindings, cmd } => {
1409 assert_eq!(bindings.len(), 1);
1410 assert!(matches!(bindings[0].stream, IoStream::Stdout));
1411 assert_eq!(bindings[0].pipe, Some(PipeTarget::Var("out".to_string())));
1412 match cmd.as_ref() {
1413 StepKind::AsyncBlock { body } => {
1414 assert_eq!(body.len(), 1);
1415 match &body[0].kind {
1416 StepKind::For { var, body, .. } => {
1417 assert_eq!(var, "x");
1418 assert_eq!(body.len(), 1);
1419 assert!(matches!(&body[0].kind, StepKind::Echo(_)));
1420 }
1421 other => panic!("expected For, got {:?}", other),
1422 }
1423 }
1424 other => panic!("expected AsyncBlock, got {:?}", other),
1425 }
1426 }
1427 other => panic!("expected WithIo, got {:?}", other),
1428 }
1429 }
1430
1431 #[test]
1432 fn for_int_key_parses_for_list_enumeration() {
1433 let script = indoc! {r#"
1434 FOR $i: INT, $v: STRING IN $items {
1435 WRITE $v
1436 }
1437 "#};
1438 let steps = parse_script(script, test_lower).expect("parse ok");
1439 match &steps[0].kind {
1440 StepKind::For {
1441 key_var,
1442 key_type,
1443 var,
1444 var_type,
1445 ..
1446 } => {
1447 assert_eq!(key_var.as_deref(), Some("i"));
1448 assert_eq!(*key_type, Some("INT".to_string()));
1449 assert_eq!(var, "v");
1450 assert_eq!(*var_type, "STRING".to_string());
1451 }
1452 other => panic!("expected For, got {:?}", other),
1453 }
1454 }
1455
1456 #[test]
1457 fn for_non_index_key_type_is_rejected() {
1458 let err = parse_script(
1459 "FOR $k: BOOL, $v: STRING IN $map { WRITE $v }\n",
1460 test_lower,
1461 )
1462 .expect_err("BOOL key must fail");
1463 assert!(
1464 err.to_string().contains("must be INT or STRING"),
1465 "unexpected error: {err}"
1466 );
1467 }
1468
1469 #[test]
1470 fn with_io_async_block_nested_if_else_parses() {
1471 let script = indoc! {r#"
1473 WITH_IO [stdout=$out] ASYNC {
1474 IF $a == $b {
1475 ECHO yes
1476 } ELSE {
1477 ECHO no
1478 }
1479 }
1480 "#};
1481 let steps = parse_script(script, test_lower).expect("parse should succeed");
1482 match &steps[0].kind {
1483 StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1484 StepKind::AsyncBlock { body } => {
1485 assert!(matches!(&body[0].kind, StepKind::If { .. }));
1486 match &body[0].kind {
1487 StepKind::If { else_body, .. } => {
1488 assert_eq!(else_body.as_ref().map(Vec::len), Some(1));
1489 }
1490 other => panic!("expected If, got {:?}", other),
1491 }
1492 }
1493 other => panic!("expected AsyncBlock, got {:?}", other),
1494 },
1495 other => panic!("expected WithIo, got {:?}", other),
1496 }
1497 }
1498
1499 #[test]
1500 fn timeout_block_nested_for_parses() {
1501 let script = indoc! {r#"
1504 TIMEOUT 30s {
1505 FOR $x: INT IN [1] {
1506 ECHO hi
1507 }
1508 }
1509 "#};
1510 let steps = parse_script(script, test_lower).expect("parse should succeed");
1511 match &steps[0].kind {
1512 StepKind::Timeout { body, .. } => {
1513 assert_eq!(body.len(), 1);
1514 assert!(matches!(&body[0].kind, StepKind::For { .. }));
1515 }
1516 other => panic!("expected Timeout, got {:?}", other),
1517 }
1518 }
1519
1520 #[test]
1521 fn with_io_async_block_nested_let_map_parses() {
1522 let script = indoc! {r#"
1524 WITH_IO [stdout] ASYNC {
1525 LET $m: MAP = {a: 1, b: 2}
1526 }
1527 "#};
1528 let steps = parse_script(script, test_lower).expect("parse should succeed");
1529 match &steps[0].kind {
1530 StepKind::WithIo { cmd, .. } => match cmd.as_ref() {
1531 StepKind::AsyncBlock { body } => {
1532 assert!(matches!(&body[0].kind, StepKind::Assign { .. }));
1533 }
1534 other => panic!("expected AsyncBlock, got {:?}", other),
1535 },
1536 other => panic!("expected WithIo, got {:?}", other),
1537 }
1538 }
1539
1540 #[test]
1541 fn variable_sigil_binds_tightly() {
1542 parse_script("ECHO $ x", test_lower).expect_err("spaced sigil must fail");
1548 parse_script("LET $x: STRING = $ y", test_lower).expect_err("spaced sigil must fail");
1549 let steps = parse_script("ECHO $x", test_lower).expect("tight sigil parses");
1550 assert!(matches!(&steps[0].kind, StepKind::Echo(_)));
1551 }
1552
1553 #[test]
1554 fn let_async_block_parses() {
1555 let script = indoc! {r#"
1556 LET $task: HANDLE = ASYNC {
1557 RUN "echo hello"
1558 }
1559 "#};
1560 let steps = parse_script(script, test_lower).expect("parse should succeed");
1561 assert_eq!(steps.len(), 1);
1562 match &steps[0].kind {
1563 StepKind::AssignAsync { var, body, .. } => {
1564 assert_eq!(var, "task");
1565 assert_eq!(body.len(), 1);
1566 assert!(matches!(&body[0].kind, StepKind::Run(_)));
1567 }
1568 other => panic!("expected AssignAsync, got {:?}", other),
1569 }
1570 }
1571
1572 #[test]
1573 fn let_async_inline_parses() {
1574 let script = "LET $t: HANDLE = ASYNC RUN \"echo hi\"";
1575 let steps = parse_script(script, test_lower).expect("parse should succeed");
1576 assert_eq!(steps.len(), 1);
1577 match &steps[0].kind {
1578 StepKind::AssignAsync { var, body, .. } => {
1579 assert_eq!(var, "t");
1580 assert_eq!(body.len(), 1);
1581 assert!(matches!(&body[0].kind, StepKind::Run(_)));
1582 }
1583 other => panic!("expected AssignAsync, got {:?}", other),
1584 }
1585 }
1586
1587 #[test]
1588 fn await_parses() {
1589 let script = "AWAIT $task";
1590 let steps = parse_script(script, test_lower).expect("parse should succeed");
1591 assert_eq!(steps.len(), 1);
1592 match &steps[0].kind {
1593 StepKind::Await { var } => {
1594 assert_eq!(var, "task");
1595 }
1596 other => panic!("expected Await, got {:?}", other),
1597 }
1598 }
1599}