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