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