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