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