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