1use std::fmt;
16
17use crate::ast::{
18 Arg, ArgPart, Expr, IoBinding, IoStream, PipeTarget, Step, Value, WorkspaceTarget,
19};
20use crate::command::{
21 ArgSpec, ArgType, CommandMeta, Example, FlagSpec, FlagValueType, IoDirection, Stream,
22 split_assignment,
23};
24use crate::constants::{KEYWORD_EXPORT, KEYWORD_IMPORT};
25use crate::error::{ParseError, ParseResult, SpanContext};
26use indoc::indoc;
27
28pub const REMOTE_RESERVED_WORDS: &[&str] = &["SNAPSHOT", "LOCAL", "CACHE", "SYSTEM", "REMOTE"];
38
39pub fn is_valid_remote_target(target: &str) -> bool {
43 if target.is_empty() || target.len() > 64 {
44 return false;
45 }
46 if REMOTE_RESERVED_WORDS.contains(&target) {
47 return false;
48 }
49 let mut chars = target.chars();
50 match chars.next() {
51 Some(first) if first.is_ascii_alphanumeric() => {}
52 _ => return false,
53 }
54 chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
55}
56
57fn join_value(args: Vec<Arg>, cmd_name: &str) -> ParseResult<Arg> {
62 if args.is_empty() {
63 return Err(ParseError::validation(
64 cmd_name,
65 format!("{cmd_name} requires at least one argument"),
66 &SpanContext::line_only(0),
67 ));
68 }
69 if args.len() == 1 {
70 return Ok(args.into_iter().next().unwrap());
71 }
72 if args.iter().all(|a| matches!(a, Arg::String(..))) {
73 return Ok(Arg::String(
74 args.iter()
75 .map(|a| a.as_str())
76 .collect::<Vec<_>>()
77 .join(" "),
78 false,
79 ));
80 }
81 let mut parts = Vec::new();
82 for (index, arg) in args.into_iter().enumerate() {
83 if index > 0 {
84 parts.push(ArgPart::Text(" ".to_string(), false));
85 }
86 match arg {
87 Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
88 Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
89 Arg::Parts(inner) => parts.extend(inner),
90 }
91 }
92 Ok(Arg::Parts(parts))
93}
94
95pub fn lower_env_assignment(args: Vec<Arg>) -> ParseResult<StepKind> {
99 let arg = args.into_iter().next().ok_or_else(|| {
100 ParseError::validation(
101 "ENV",
102 "ENV requires KEY=value".to_string(),
103 &SpanContext::line_only(0),
104 )
105 })?;
106 let Some((key, value)) = split_assignment(arg.as_str())
107 .map_err(|e| ParseError::validation("ENV", e.to_string(), &SpanContext::line_only(0)))?
108 else {
109 return Err(ParseError::validation(
110 "ENV",
111 "ENV requires KEY=value format".to_string(),
112 &SpanContext::line_only(0),
113 ));
114 };
115 Ok(StepKind::Env { key, value })
116}
117
118pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
123 Arg::String(format!("{key}={}", value.render()), false)
124}
125
126fn fmt_assert_target(target: &AssertTarget) -> String {
129 match target {
130 AssertTarget::Value(arg) => fmt_value(arg, quote_msg),
131 _ => target.render(),
132 }
133}
134
135fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
140 match arg {
141 Arg::Expr(_) => arg.render(),
142 Arg::String(text, _) => quote(text),
143 Arg::Parts(_) => {
144 let rendered = arg.render();
145 if rendered.contains(';')
146 || rendered.contains('}')
147 || rendered.contains('\n')
148 || rendered.contains('\r')
149 {
150 quote(&rendered)
151 } else {
152 rendered
153 }
154 }
155 }
156}
157
158fn quote_dsl_string(s: &str) -> String {
159 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
167}
168
169fn quote_arg(s: &str) -> String {
170 let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
171 && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
172 && !crate::Command::is_statement_keyword(s);
173 if is_safe && !s.is_empty() {
174 s.to_string()
175 } else {
176 quote_dsl_string(s)
177 }
178}
179
180fn quote_msg(s: &str) -> String {
181 let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
182 && !s.starts_with(|c: char| c.is_ascii_digit())
183 && !crate::Command::is_statement_keyword(s);
184 if safe && !s.is_empty() {
185 s.to_string()
186 } else {
187 quote_dsl_string(s)
188 }
189}
190
191fn quote_run(s: &str) -> String {
192 if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
193 return quote_dsl_string(s);
194 }
195 s.split(' ')
196 .map(|w| {
197 if w.starts_with(|c: char| c.is_ascii_digit())
198 || w.starts_with(['/', '.', '-', ':', '='])
199 {
200 quote_dsl_string(w)
201 } else {
202 w.to_string()
203 }
204 })
205 .collect::<Vec<_>>()
206 .join(" ")
207}
208
209fn fmt_exec_arg(arg: &Arg) -> String {
215 match arg {
216 Arg::String(text, _) => quote_dsl_string(text),
217 Arg::Expr(_) => arg.render(),
218 Arg::Parts(_) => {
219 let rendered = arg.render();
220 if rendered.contains(';')
221 || rendered.contains('}')
222 || rendered.contains('\n')
223 || rendered.contains('\r')
224 {
225 quote_dsl_string(&rendered)
226 } else {
227 rendered
228 }
229 }
230 }
231}
232
233fn fmt_raw_arg(arg: &Arg) -> String {
237 match arg {
238 Arg::String(s, true) => quote_dsl_string(s),
239 _ => arg.render(),
240 }
241}
242
243fn fmt_io(b: &IoBinding) -> String {
244 let s = match b.stream {
245 IoStream::Stdin => "stdin",
246 IoStream::Stdout => "stdout",
247 IoStream::Stderr => "stderr",
248 };
249 match &b.pipe {
250 Some(PipeTarget::Var(v)) => format!("{}=${}", s, v),
251 None => s.to_string(),
252 }
253}
254
255pub(crate) fn is_known_command(name: &str) -> bool {
262 if name == "ELSE" {
263 return true;
264 }
265 all_metadata().iter().any(|meta| meta.name == name)
266}
267
268pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> ParseError {
269 let received = raw_args
270 .iter()
271 .map(Arg::render)
272 .collect::<Vec<_>>()
273 .join(" ");
274 let got = if received.is_empty() {
275 "nothing".to_string()
276 } else {
277 format!("`{received}`")
278 };
279 let found = if received.is_empty() {
280 None
281 } else {
282 Some(received.clone())
283 };
284 let expected = all_metadata()
285 .iter()
286 .find(|meta| meta.name == name)
287 .map(|meta| vec![meta.syntax.to_string()])
288 .unwrap_or_default();
289 let ctx = SpanContext::line_only(0);
290 match structural_hint(name, &received) {
291 Some(hint) => ParseError::invalid_syntax(
292 name,
293 format!("invalid syntax for command {name}: {hint}"),
294 found,
295 expected,
296 Some(hint),
297 &ctx,
298 ),
299 None => ParseError::invalid_syntax(
300 name,
301 format!("invalid syntax for command {name}: got {got}."),
302 found,
303 expected,
304 None,
305 &ctx,
306 ),
307 }
308}
309
310fn unknown_command_error(name: &str, raw_args: &[Arg]) -> ParseError {
311 let received = raw_args
312 .iter()
313 .map(Arg::render)
314 .collect::<Vec<_>>()
315 .join(" ");
316 let hint = structural_hint(name, &received).or_else(|| case_hint(name));
317 let ctx = SpanContext::line_only(0);
318 match hint {
319 Some(hint) => ParseError::unknown_command(
320 name,
321 format!("unknown command: {name}\n{hint}"),
322 Some(hint),
323 &ctx,
324 ),
325 None => ParseError::unknown_command(name, format!("unknown command: {name}"), None, &ctx),
326 }
327}
328
329pub(crate) fn classify(name: &str, raw_args: &[Arg]) -> ParseError {
335 if is_known_command(name) {
336 invalid_syntax_error(name, raw_args)
337 } else {
338 unknown_command_error(name, raw_args)
339 }
340}
341
342fn structural_hint(name: &str, received: &str) -> Option<String> {
343 let got = if received.is_empty() {
344 "nothing".to_string()
345 } else {
346 format!("`{received}`")
347 };
348 match name {
349 "WITH_IO" => Some(with_io_hint(&got, received)),
350 "AWAIT" => Some(format!(
351 "AWAIT waits for a background task variable, e.g. `LET $t: HANDLE = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
352 )),
353 "CANCEL" => Some(format!(
354 "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t: HANDLE = ASYNC ...`); got {got}."
355 )),
356 "ASYNC" => Some(format!(
357 "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t: HANDLE = ASYNC ...`; got {got}."
358 )),
359 "FOR" => Some(format!(
360 "FOR loops need `FOR $item: TYPE IN <expr> {{ ... }}` (or `FOR $key: STRING, $value: TYPE IN <expr> {{ ... }}`); got {got}."
361 )),
362 "IF" => Some(format!(
363 "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
364 )),
365 "ELSE" => Some(format!(
366 "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
367 )),
368 "LET" => Some(format!(
369 "LET assigns a variable, e.g. `LET $name: STRING = <expr>`, `LET $t: HANDLE = ASYNC ...`, `LET $out: STRING = <command>` (capture), `LET $out: STRING = AWAIT $t`, or `LET $var: TYPE = {{ ... }}` (inline block); got {got}."
370 )),
371 "SET" => Some(
372 "`SET` is not a keyword; mutate a declared variable with `$var = <expr>`, e.g. `$count = 2`.".to_string(),
373 ),
374 "TIMEOUT" => Some(format!(
375 "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
376 )),
377 "REMOTE" => Some(format!(
378 "REMOTE needs a target and a braced block, e.g. `REMOTE prod [$version, env:DEPLOY_ENV] {{ ... }}`; got {got}."
379 )),
380 "FUNC" => Some(format!(
381 "FUNC defines a function, e.g. `FUNC GREET($name: STRING) {{ RETURN $name }}`; got {got}."
382 )),
383 "RETURN" => Some(format!(
384 "RETURN ends the nearest function, ASYNC task, or inline LET block with a value, e.g. `RETURN $x`; got {got}."
385 )),
386 "WHILE" => Some(format!(
387 "WHILE needs a Bool condition and a block, e.g. `WHILE !$done {{ ... }}`; got {got}."
388 )),
389 "BREAK" => Some(
390 "`BREAK` exits the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
391 ),
392 "CONTINUE" => Some(
393 "`CONTINUE` skips to the next iteration of the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
394 ),
395 "INHERIT_ENV" => Some(format!(
396 "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME, PATH]`; got {got}."
397 )),
398 name if name == KEYWORD_IMPORT => Some(format!(
399 "IMPORT brings module functions into bare-call scope, e.g. `IMPORT [STD]` or `IMPORT [STD, MOCK]`; got {got}."
400 )),
401 name if name == KEYWORD_EXPORT => Some(
402 "`EXPORT` is reserved for future script-module support and cannot be used yet."
403 .to_string(),
404 ),
405 _ => None,
406 }
407}
408
409fn with_io_hint(got: &str, received: &str) -> String {
412 const SYNTAX: &str =
413 "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
414 const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=$p]`, `[stdin=$p]`)";
415 if let Some(after_open) = received.strip_prefix('[') {
416 match after_open.split_once(']') {
417 None => {
418 return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
419 }
420 Some((bindings, _)) => {
421 for part in bindings.split(',') {
422 let part = part.trim();
423 if part.is_empty() {
424 continue;
425 }
426 let (stream, binding) = match part.split_once('=') {
427 Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
428 None => (part, None),
429 };
430 if !matches!(stream, "stdin" | "stdout" | "stderr") {
431 return format!(
432 "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
433 );
434 }
435 let valid = match binding {
436 None => true,
437 Some(value) => value.strip_prefix('$').is_some_and(|var| {
438 !var.trim().is_empty()
439 && var.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
440 }),
441 };
442 if !valid {
443 return format!(
444 "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
445 );
446 }
447 }
448 }
449 }
450 }
451 format!("{SYNTAX}; got {got}. {BINDINGS}.")
452}
453
454fn case_hint(name: &str) -> Option<String> {
456 let upper = name.to_ascii_uppercase();
457 if upper != name
458 && all_metadata()
459 .iter()
460 .any(|meta| meta.name == upper.as_str())
461 {
462 return Some(format!("did you mean `{upper}`? commands are uppercase."));
463 }
464 None
465}
466
467macro_rules! declare_commands {
468 (
469 structural [
470 $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
471 ]
472
473 $(
474 $cmd_ident:ident => [
475 name: $name:expr,
476 variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
477 syntax: $syntax:expr,
478 summary: $summary:expr,
479 description: $desc:expr,
480 args: $args:expr,
481 flags: $flags:expr,
482 default_output: $out:expr,
483 examples: $examples:expr,
484 lower: $lower:expr,
485 ]
486 ),* $(,)?
487 ) => {
488 #[derive(Debug, Clone, PartialEq)]
489 pub enum StepKind {
490 $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
491 $( $sname $( { $( $sfname : $sftype ),* } )?, )*
492 }
493
494 pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> ParseResult<StepKind> {
495 match name {
496 $(
497 s if s == $name => {
498 let meta = CommandMeta {
499 name: $name, syntax: $syntax, summary: $summary,
500 description: $desc, args: $args, flags: $flags,
501 default_output: $out, examples: $examples,
502 };
503 let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
504 crate::command::validate_positionals_against_meta(
505 s,
506 &meta.args,
507 &positional,
508 )?;
509 let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> ParseResult<StepKind> = $lower;
510 lower_fn(flags, positional)
511 }
512 )*
513 _ => {
514 Err(classify(name, &raw_args))
515 }
516 }
517 }
518
519 pub fn all_metadata() -> Vec<CommandMeta> {
520 let mut out = vec![
521 $( CommandMeta {
522 name: $name, syntax: $syntax, summary: $summary,
523 description: $desc, args: $args, flags: $flags,
524 default_output: $out, examples: $examples,
525 }, )*
526 ];
527 out.extend(all_structural_metadata());
531 out
532 }
533 };
534}
535
536impl StepKind {
537 pub fn walk_exprs(&self, f: &mut impl FnMut(&Expr)) {
544 fn walk_arg(arg: &Arg, f: &mut impl FnMut(&Expr)) {
545 match arg {
546 Arg::Expr(expr) => f(expr),
547 Arg::Parts(parts) => {
548 for part in parts {
549 if let ArgPart::Expr(expr) = part {
550 f(expr);
551 }
552 }
553 }
554 Arg::String(..) => {}
555 }
556 }
557 fn walk_opt(arg: &Option<Arg>, f: &mut impl FnMut(&Expr)) {
558 if let Some(arg) = arg {
559 walk_arg(arg, f);
560 }
561 }
562 fn walk_target(target: &AssertTarget, f: &mut impl FnMut(&Expr)) {
563 if let AssertTarget::Value(arg) = target {
564 walk_arg(arg, f);
565 }
566 }
567 match self {
568 StepKind::Assign { expr, .. } | StepKind::Set { expr, .. } => f(expr),
569 StepKind::Return { expr } => f(expr),
570 StepKind::Call { args, .. } => {
571 for arg in args {
572 f(arg);
573 }
574 }
575 StepKind::For { in_expr, .. } => f(in_expr),
576 StepKind::If { cond, else_ifs, .. } => {
577 f(cond);
578 for (cond, _) in else_ifs {
579 f(cond);
580 }
581 }
582 StepKind::While { cond, .. } => f(cond),
583 StepKind::Timeout { duration, .. } | StepKind::Sleep { duration } => {
584 walk_arg(duration, f);
585 }
586 StepKind::Workdir(arg)
587 | StepKind::Echo(arg)
588 | StepKind::Run(arg)
589 | StepKind::Mkdir(arg)
590 | StepKind::HashSha256 { path: arg }
591 | StepKind::Exit(arg) => walk_arg(arg, f),
592 StepKind::Env { value, .. } => walk_arg(value, f),
593 StepKind::Copy { from, to, .. }
594 | StepKind::Symlink { from, to, .. }
595 | StepKind::Install { from, to } => {
596 walk_arg(from, f);
597 walk_arg(to, f);
598 }
599 StepKind::CopyGit { rev, from, to, .. } => {
600 walk_arg(rev, f);
601 walk_arg(from, f);
602 walk_arg(to, f);
603 }
604 StepKind::Ls(arg) | StepKind::Read(arg) => walk_opt(arg, f),
605 StepKind::Remove { path, .. } => walk_arg(path, f),
606 StepKind::Prompt {
607 message, default, ..
608 } => {
609 walk_arg(message, f);
610 walk_opt(default, f);
611 }
612 StepKind::Write { path, contents } | StepKind::Append { path, contents } => {
613 walk_arg(path, f);
614 walk_opt(contents, f);
615 }
616 StepKind::Expand { path, overrides } => {
617 walk_opt(path, f);
618 for (_, value) in overrides {
619 walk_arg(value, f);
620 }
621 }
622 StepKind::AssertEq {
623 actual, expected, ..
624 } => {
625 walk_target(actual, f);
626 walk_opt(expected, f);
627 }
628 StepKind::AssertContains { haystack, needle } => {
629 walk_target(haystack, f);
630 walk_arg(needle, f);
631 }
632 StepKind::ListAppend { item, .. } => walk_arg(item, f),
633 StepKind::RunExec { argv } => {
634 for arg in argv {
635 walk_arg(arg, f);
636 }
637 }
638 StepKind::WithIo { .. }
639 | StepKind::WithIoBlock { .. }
640 | StepKind::TypeAlias { .. }
641 | StepKind::Workspace(_)
642 | StepKind::InheritEnv { .. }
643 | StepKind::Cwd
644 | StepKind::ReadLine { .. }
645 | StepKind::AssignCapture { .. }
646 | StepKind::AwaitCapture { .. }
647 | StepKind::AsyncBlock { .. }
648 | StepKind::AssignAsync { .. }
649 | StepKind::Await { .. }
650 | StepKind::Cancel { .. }
651 | StepKind::RemoteBlock { .. }
652 | StepKind::FuncDef { .. }
653 | StepKind::Break
654 | StepKind::Continue => {}
655 }
656 }
657
658 pub fn walk_child_kinds(&self, f: &mut impl FnMut(&StepKind)) {
662 fn walk_body(body: &[Step], f: &mut impl FnMut(&StepKind)) {
663 for step in body {
664 f(&step.kind);
665 }
666 }
667 match self {
668 StepKind::WithIo { cmd, .. } => f(cmd),
669 StepKind::AssignCapture { cmd, .. } => f(cmd),
670 StepKind::For { body, .. }
671 | StepKind::While { body, .. }
672 | StepKind::FuncDef { body, .. }
673 | StepKind::Timeout { body, .. }
674 | StepKind::AssignAsync { body, .. }
675 | StepKind::AsyncBlock { body }
676 | StepKind::RemoteBlock { body, .. } => walk_body(body, f),
677 StepKind::If {
678 then_body,
679 else_ifs,
680 else_body,
681 ..
682 } => {
683 walk_body(then_body, f);
684 for (_, body) in else_ifs {
685 walk_body(body, f);
686 }
687 if let Some(body) = else_body {
688 walk_body(body, f);
689 }
690 }
691 StepKind::InheritEnv { .. }
692 | StepKind::TypeAlias { .. }
693 | StepKind::Workdir(_)
694 | StepKind::Workspace(_)
695 | StepKind::Env { .. }
696 | StepKind::Echo(_)
697 | StepKind::Run(_)
698 | StepKind::RunExec { .. }
699 | StepKind::Copy { .. }
700 | StepKind::CopyGit { .. }
701 | StepKind::Symlink { .. }
702 | StepKind::Install { .. }
703 | StepKind::Mkdir(_)
704 | StepKind::Ls(_)
705 | StepKind::Cwd
706 | StepKind::Read(_)
707 | StepKind::ReadLine { .. }
708 | StepKind::Prompt { .. }
709 | StepKind::Write { .. }
710 | StepKind::Append { .. }
711 | StepKind::Remove { .. }
712 | StepKind::Expand { .. }
713 | StepKind::AssertEq { .. }
714 | StepKind::AssertContains { .. }
715 | StepKind::HashSha256 { .. }
716 | StepKind::Exit(_)
717 | StepKind::WithIoBlock { .. }
718 | StepKind::Assign { .. }
719 | StepKind::Set { .. }
720 | StepKind::AwaitCapture { .. }
721 | StepKind::Await { .. }
722 | StepKind::Cancel { .. }
723 | StepKind::Sleep { .. }
724 | StepKind::ListAppend { .. }
725 | StepKind::Call { .. }
726 | StepKind::Return { .. }
727 | StepKind::Break
728 | StepKind::Continue => {}
729 }
730 }
731
732 pub fn walk_step_exprs(&self, f: &mut impl FnMut(&Expr)) {
737 self.walk_exprs(&mut |expr| expr.walk(f));
738 self.walk_child_kinds(&mut |kind| kind.walk_step_exprs(f));
739 }
740}
741
742#[derive(Debug, Clone, PartialEq)]
751pub enum AssertTarget {
752 Value(Arg),
753 Stdout,
754 Stderr,
755}
756
757impl AssertTarget {
758 pub fn render(&self) -> String {
759 match self {
760 AssertTarget::Value(arg) => arg.render(),
761 AssertTarget::Stdout => "stdout".to_string(),
762 AssertTarget::Stderr => "stderr".to_string(),
763 }
764 }
765}
766
767fn lower_assert_target(arg: Arg) -> ParseResult<AssertTarget> {
776 match arg {
777 Arg::Expr(_) => Ok(AssertTarget::Value(arg)),
778 Arg::String(text, quoted) if !quoted => match text.as_str() {
779 "stdout" => Ok(AssertTarget::Stdout),
780 "stderr" => Ok(AssertTarget::Stderr),
781 _ => Ok(AssertTarget::Value(lower_assert_operand(Arg::String(
782 text, false,
783 )))),
784 },
785 other => Ok(AssertTarget::Value(lower_assert_operand(other))),
786 }
787}
788
789fn lower_assert_operand(arg: Arg) -> Arg {
798 match arg {
799 Arg::String(text, false) => {
800 if let Ok(i) = text.parse::<i64>() {
801 Arg::Expr(Expr::Literal(Value::int(i)))
802 } else if text.contains('.') && text.parse::<f64>().is_ok() {
803 Arg::Expr(Expr::Literal(Value::float(
804 text.parse::<f64>().unwrap_or(f64::NAN),
805 )))
806 } else if text == "true" {
807 Arg::Expr(Expr::Literal(Value::bool(true)))
808 } else if text == "false" {
809 Arg::Expr(Expr::Literal(Value::bool(false)))
810 } else {
811 Arg::String(text, false)
812 }
813 }
814 other => other,
815 }
816}
817
818declare_commands! {
819 structural [
820 WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
821 WithIoBlock { bindings: Vec<IoBinding> },
822 TypeAlias { name: String, target: String },
823 For { key_var: Option<String>, key_type: Option<String>, var: String, var_type: String, in_expr: Expr, body: Vec<Step> },
824 If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
825 Assign { var: String, decl_type: String, expr: Expr },
826 Set { var: String, expr: Expr },
827 AssignCapture { var: String, decl_type: String, cmd: Box<StepKind> },
828 AwaitCapture { out_var: String, out_type: String, task_var: String },
829 AsyncBlock { body: Vec<Step> },
830 AssignAsync { var: String, decl_type: String, body: Vec<Step> },
831 Await { var: String },
832 Cancel { var: String },
833 Timeout { duration: Arg, body: Vec<Step> },
834 RemoteBlock { target: String, vars: Vec<String>, env: Vec<String>, body: Vec<Step> },
835 RunExec { argv: Vec<Arg> },
836 FuncDef { name: String, params: Vec<(String, String)>, body: Vec<Step> },
837 Call { name: String, args: Vec<Expr> },
838 Return { expr: Box<Expr> },
839 While { cond: Box<Expr>, body: Vec<Step> },
840 Break,
841 Continue,
842 ]
843
844 Workdir => [
845 name: "WORKDIR",
846 variant: Workdir(Arg),
847 syntax: "WORKDIR <path>",
848 summary: "Change the working directory.",
849 description: indoc! {r#"
850 Sets the current working directory.
851
852 Relative paths resolve against the current directory; `/` resets to
853 the workspace root. Paths cannot escape the workspace.
854 "#},
855 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
856 flags: &[],
857 default_output: None,
858 examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
859 # Later relative paths resolve under the new directory.
860 WORKDIR project/src
861 WRITE generated.txt generated-under-workdir
862
863 LET $body: STRING = READ generated.txt
864 ASSERT_EQ $body "generated-under-workdir"
865 "#} }, Example { name: "workdir in a scoped block", fence_meta: None, code: indoc! {r#"
866 # The block reverts to the starting directory on exit.
867 LET $outside: STRING = CWD
868 MKDIR project
869
870 [bool:true] {
871 WORKDIR project
872 WRITE inner.txt inner
873 }
874
875 LET $back: STRING = CWD
876 ASSERT_EQ $back $outside
877 LET $body: STRING = READ project/inner.txt
878 ASSERT_EQ $body "inner"
879 "#} } ],
880 lower: |_flags, args| {
881 let path = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKDIR", "WORKDIR requires a path".to_string(), &SpanContext::line_only(0)))?;
882 Ok(StepKind::Workdir(path))
883 },
884 ],
885
886 Workspace => [
887 name: "WORKSPACE",
888 variant: Workspace(WorkspaceTarget),
889 syntax: "WORKSPACE (SNAPSHOT|LOCAL|CACHE|SYSTEM) [--local]",
890 summary: "Switch workspace roots.",
891 description: indoc! {r#"
892 Switches the workspace root. The selection reverts at scope
893 exit like `WORKDIR`.
894
895 - `SNAPSHOT`: the materialized build snapshot (the default).
896 - `LOCAL`: the local workspace directory.
897 - `CACHE`: a persistent per-project directory shared across
898 runs, never evicted. It lives under the OS user cache
899 (`OXDOCK_CACHE_DIR` pins an exact directory);
900 `WORKSPACE CACHE --local` keeps it in
901 `<project>/.cache/workspace` instead.
902 - `SYSTEM`: full filesystem access. Scripts using it are not
903 hermetic.
904 "#},
905 args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL", "CACHE", "SYSTEM"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
906 flags: &[ FlagSpec { name: "local", long: "--local", value_type: FlagValueType::Flag, required: false, description: "Use the project-local cache directory instead of the OS user cache (CACHE only)" } ],
907 default_output: None,
908 examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"
909 IMPORT [STD]
910 WORKSPACE LOCAL
911
912 LET $t: STRING = PATH_TYPE(".")
913 ASSERT_EQ $t "dir"
914 "#} }, Example { name: "workspace cache in a scoped block", fence_meta: None, code: indoc! {r#"
915 [bool:true] {
916 WORKSPACE CACHE
917 WRITE cached.txt cached-content
918 }
919
920 COPY --from-workspace CACHE cached.txt restored.txt
921 LET $body: STRING = READ restored.txt
922 ASSERT_EQ $body "cached-content"
923 "#} } ],
924 lower: |flags, args| {
925 let local = flags.iter().any(|(k, _)| k == "local");
926 let target = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKSPACE", "WORKSPACE requires a target".to_string(), &SpanContext::line_only(0)))?;
927 match target.as_str() {
928 "SNAPSHOT" | "LOCAL" | "SYSTEM" if local => Err(ParseError::validation("WORKSPACE", "WORKSPACE --local requires CACHE".to_string(), &SpanContext::line_only(0))),
929 "SNAPSHOT" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
930 "LOCAL" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
931 "CACHE" => Ok(StepKind::Workspace(WorkspaceTarget::Cache { local })),
932 "SYSTEM" => Ok(StepKind::Workspace(WorkspaceTarget::System)),
933 other => Err(ParseError::validation("WORKSPACE", format!("unknown workspace target: {other}"), &SpanContext::line_only(0))),
934 }
935 },
936 ],
937
938 Env => [
939 name: "ENV",
940 variant: Env { key: String, value: Arg },
941 syntax: "ENV KEY=value",
942 summary: "Set an environment variable.",
943 description: indoc! {r#"
944 Inserts or updates an env var.
945
946 The value uses the unified string-value rules shared by every command:
947 `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
948 evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
949 words join with single spaces, and the first `=` splits key from value
950 (`KEY=a=b` stores `a=b`).
951
952 A `$var` inside larger text stays literal — write `{{ $var }}` to
953 interpolate there.
954 "#},
955 args: &[ ArgSpec { name: "assignment", arg_type: ArgType::String, description: "KEY=value pair; the value resolves as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
956 flags: &[],
957 default_output: None,
958 examples: &[
959 Example { name: "set env", fence_meta: None, code: indoc! {r#"
960 ENV APP_MODE=production
961 LET $mode: STRING = env:APP_MODE
962 ASSERT_EQ $mode "production"
963 "#} },
964 Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
965 # Quotes keep the space: SET_FORTH stores `outer scope`.
966 ENV SET_FORTH="outer scope"
967 WRITE out.txt "{{ env:SET_FORTH }}"
968
969 LET $body: STRING = READ out.txt
970 ASSERT_EQ $body "outer scope"
971 "#} },
972 Example { name: "variable value", fence_meta: None, code: indoc! {r#"
973 # A lone $var evaluates, like ECHO $var.
974 LET $who: STRING = "Alice"
975 ENV GREETING=$who
976 WRITE out.txt "{{ env:GREETING }}"
977
978 LET $body: STRING = READ out.txt
979 ASSERT_EQ $body "Alice"
980 "#} },
981 Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
982 # A bare variable, a quoted literal, and a template all
983 # store plain strings through the same value rules.
984 LET $x: STRING = "Ada"
985 ENV A=$x
986 ENV B="hello world"
987 ENV C="{{ $x }} concatenated"
988 WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
989
990 LET $body: STRING = READ check.txt
991 ASSERT_EQ $body "Ada|hello world|Ada concatenated"
992 "#} },
993 Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
994 # ENV inside a braced block reverts when the block exits
995 ENV MODE=production
996
997 [bool:true] {
998 ENV MODE=staging
999 WRITE inner.txt "{{ env:MODE }}"
1000 }
1001
1002 WRITE outer.txt "{{ env:MODE }}"
1003
1004 LET $inner_body: STRING = READ inner.txt
1005 ASSERT_EQ $inner_body "staging"
1006
1007 LET $outer_body: STRING = READ outer.txt
1008 ASSERT_EQ $outer_body "production"
1009 "#} },
1010 Example { name: "shell reads env per platform", fence_meta: None, code: indoc! {r#"
1011 # A shell command reads its own environment, with
1012 # per-platform spelling: quoted "$VAR" passes the parser
1013 # through untouched on Unix ...
1014 ENV PROXY_PORT=23791
1015
1016 [family:unix] LET $o: STRING = RUN echo serving on "$PROXY_PORT"
1017
1018 # ... while cmd expands %VAR% on Windows.
1019 [family:windows] LET $o: STRING = RUN echo serving on %PROXY_PORT%
1020
1021 ASSERT_CONTAINS $o "23791"
1022 "#} },
1023 ],
1024 lower: |_flags, args| lower_env_assignment(args),
1025 ],
1026
1027 InheritEnv => [
1028 name: "INHERIT_ENV",
1029 variant: InheritEnv { keys: Vec<String> },
1030 syntax: "INHERIT_ENV [<key>, ...]",
1031 summary: "Inherit env vars from host.",
1032 description: indoc! {r#"
1033 Declares which host environment variables to inherit into the script.
1034
1035 Must appear before any other commands and at most once. Without this
1036 directive, the script starts with an empty environment.
1037 "#},
1038 args: &[ ArgSpec { name: "keys", arg_type: ArgType::Rest(&ArgType::String), description: "Host variables to inherit", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1039 flags: &[],
1040 default_output: None,
1041 examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"
1042 INHERIT_ENV [PATH, HOME]
1043 LET $path: STRING = env:PATH
1044 ASSERT_CONTAINS $path ":"
1045 "#} } ],
1046 lower: |_flags, args| {
1047 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
1048 Ok(StepKind::InheritEnv { keys })
1049 },
1050 ],
1051
1052 Echo => [
1053 name: "ECHO",
1054 variant: Echo(Arg),
1055 syntax: "ECHO <message>",
1056 summary: "Print to stdout.",
1057 description: "Outputs message to stdout.",
1058 args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1059 flags: &[],
1060 default_output: Some(Stream::Stdout),
1061 examples: &[
1062 Example { name: "echo", fence_meta: None, code: indoc! {r#"
1063 ECHO build-complete
1064 ASSERT_CONTAINS stdout "build-complete"
1065 "#} },
1066 Example { name: "variables", fence_meta: None, code: indoc! {r#"
1067 # {{ }} interpolates inside text; a lone $var evaluates on its own.
1068 LET $x: STRING = "World"
1069 ECHO "braced:{{ $x }}"
1070 ECHO $x
1071 ASSERT_EQ stdout "braced:World\nWorld\n"
1072 "#} },
1073 Example { name: "interpolation", fence_meta: None, code: indoc! {r#"
1074 # `{{ env:KEY }}` reads the script environment; a bare name
1075 # resolves a DSL variable instead. Unknown keys expand to
1076 # empty with no host fallback.
1077 ENV USER=OxDock
1078 LET $WHO: STRING = "Ada"
1079 ECHO "Hello {{ env:USER }}! Hi {{ WHO }}!{{ env:OXDOCK_DOC_NO_SUCH_KEY }}"
1080 ASSERT_CONTAINS stdout "Hello OxDock! Hi Ada!"
1081 "#} },
1082 ],
1083 lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
1084 ],
1085
1086 Run => [
1087 name: "RUN",
1088 variant: Run(Arg),
1089 syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
1090 summary: "Execute shell command or direct executable.",
1091 description: indoc! {r#"
1092 Shell form (`RUN <command...>`) runs the joined command string in the
1093 system shell (`$SHELL -c` / `COMSPEC /C`).
1094
1095 Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
1096 with no shell, so there is no shell expansion, globbing, redirection,
1097 or pipes; use it for portable commands.
1098
1099 Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
1100 both forms.
1101 "#},
1102 args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1103 flags: &[],
1104 default_output: None,
1105 examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"
1106 RUN echo hello
1107
1108 # Captured runs prove the output, not just the exit status.
1109 LET $o: STRING = RUN echo hello
1110 ASSERT_CONTAINS $o "hello"
1111 "#} }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"
1112 # No shell: `>` stays a literal argument, so no file is created.
1113 IMPORT [STD]
1114 RUN ["cargo", "--version", ">", "x.txt"]
1115 ASSERT_CONTAINS stdout "cargo"
1116
1117 LET $t: STRING = PATH_TYPE("x.txt")
1118 ASSERT_EQ $t "absent"
1119 "#} } ],
1120 lower: |_flags, args| match args.as_slice() {
1121 [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
1122 Err(ParseError::validation("RUN", "RUN requires at least one argument".to_string(), &SpanContext::line_only(0)))
1123 }
1124 [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
1125 argv: elems.iter().cloned().map(Arg::Expr).collect(),
1126 }),
1127 _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
1128 },
1129 ],
1130
1131 Copy => [
1132 name: "COPY",
1133 variant: Copy { from_workspace: Option<WorkspaceTarget>, from_host: bool, to_host: bool, from: Arg, to: Arg },
1134 syntax: "COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] [--from-host | --to-host] <from> <to>",
1135 summary: "Copy file into workspace.",
1136 description: "Copies from host (the source is never moved or modified). Docker destination semantics: a file copied onto a directory (an existing one, or a trailing-slash spell like `out/`) is duplicated inside it under its own basename; a directory source duplicates its contents into the destination; any other destination path is created holding the copied bytes.",
1137 args: &[
1138 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1139 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1140 ],
1141 flags: &[ FlagSpec { name: "from_workspace", long: "--from-workspace", value_type: FlagValueType::String, required: false, description: "Copy from the given workspace root instead of the build context" }, FlagSpec { name: "from_host", long: "--from-host", value_type: FlagValueType::Flag, required: false, description: "Fetch from the host workspace into the guest (REMOTE bodies only)" }, FlagSpec { name: "to_host", long: "--to-host", value_type: FlagValueType::Flag, required: false, description: "Push from the guest workspace back to the host (REMOTE bodies only)" } ],
1142 default_output: None,
1143 examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
1144 # Copy to a new name, then read back.
1145 WRITE src.txt content
1146 COPY src.txt dst.txt
1147
1148 LET $body: STRING = READ dst.txt
1149 ASSERT_EQ $body "content"
1150 "#} }, Example { name: "copy from workspace", fence_meta: None, code: indoc! {r#"
1151 # Same name, different contents per root: only LOCAL has ws-content.
1152 WRITE shared.txt from-snapshot
1153 WORKSPACE LOCAL
1154 WRITE shared.txt ws-content
1155
1156 WORKSPACE SNAPSHOT
1157 COPY --from-workspace LOCAL shared.txt ws-copy.txt
1158
1159 LET $body: STRING = READ ws-copy.txt
1160 ASSERT_EQ $body "ws-content"
1161 "#} } ],
1162 lower: |flags, args| {
1163 let from_workspace = flags
1164 .iter()
1165 .find(|(k, _)| k == "from_workspace")
1166 .map(|(_, v)| match v.as_str() {
1167 "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
1168 "LOCAL" => Ok(WorkspaceTarget::Local),
1169 "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
1170 "SYSTEM" => Ok(WorkspaceTarget::System),
1171 other => Err(ParseError::validation("COPY", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
1172 })
1173 .transpose()?;
1174 let mut it = args.into_iter();
1175 let from = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a source".to_string(), &SpanContext::line_only(0)))?;
1176 let to = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a destination".to_string(), &SpanContext::line_only(0)))?;
1177 let from_host = flags.iter().any(|(k, _)| k == "from_host");
1178 let to_host = flags.iter().any(|(k, _)| k == "to_host");
1179 if from_host && to_host {
1180 return Err(ParseError::validation("COPY", "COPY --from-host and --to-host are mutually exclusive".to_string(), &SpanContext::line_only(0)));
1181 }
1182 Ok(StepKind::Copy { from_workspace, from_host, to_host, from, to })
1183 },
1184 ],
1185
1186 CopyGit => [
1187 name: "COPY_GIT",
1188 variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
1189 syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
1190 summary: "Copy from git revision.",
1191 description: "Checkout and copy.",
1192 args: &[
1193 ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1194 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1195 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
1196 ],
1197 flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
1198 default_output: None,
1199 examples: &[ Example { name: "git copy missing source errors", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
1200 lower: |flags, args| {
1201 let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
1202 let mut it = args.into_iter();
1203 let rev = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a revision".to_string(), &SpanContext::line_only(0)))?;
1204 let from = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a source".to_string(), &SpanContext::line_only(0)))?;
1205 let to = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a destination".to_string(), &SpanContext::line_only(0)))?;
1206 Ok(StepKind::CopyGit { rev, from, to, include_dirty })
1207 },
1208 ],
1209
1210 Symlink => [
1211 name: "SYMLINK",
1212 variant: Symlink { from_workspace: Option<WorkspaceTarget>, from: Arg, to: Arg },
1213 syntax: "SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
1214 summary: "Create symlink.",
1215 description: "Creates symlink. A directory destination (existing, or a trailing-slash spell) receives the link under the source basename.",
1216 args: &[
1217 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1218 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1219 ],
1220 flags: &[ FlagSpec { name: "from_workspace", long: "--from-workspace", value_type: FlagValueType::String, required: false, description: "Symlink from the given workspace root instead of the build context" } ],
1221 default_output: None,
1222 examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
1223 # A symlink reads like its target.
1224 WRITE original.txt content
1225 SYMLINK original.txt link.txt
1226
1227 LET $body: STRING = READ link.txt
1228 ASSERT_EQ $body "content"
1229 "#} }, Example { name: "symlink from workspace", fence_meta: None, code: indoc! {r#"
1230 # Same name, different contents per root: only LOCAL has ws-content.
1231 WRITE shared.txt from-snapshot
1232 WORKSPACE LOCAL
1233 WRITE shared.txt ws-content
1234
1235 WORKSPACE SNAPSHOT
1236 SYMLINK --from-workspace LOCAL shared.txt ws-link.txt
1237
1238 LET $body: STRING = READ ws-link.txt
1239 ASSERT_EQ $body "ws-content"
1240 "#} } ],
1241 lower: |flags, args| {
1242 let from_workspace = flags
1243 .iter()
1244 .find(|(k, _)| k == "from_workspace")
1245 .map(|(_, v)| match v.as_str() {
1246 "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
1247 "LOCAL" => Ok(WorkspaceTarget::Local),
1248 "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
1249 "SYSTEM" => Ok(WorkspaceTarget::System),
1250 other => Err(ParseError::validation("SYMLINK", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
1251 })
1252 .transpose()?;
1253 let mut it = args.into_iter();
1254 let from = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a source".to_string(), &SpanContext::line_only(0)))?;
1255 let to = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a target".to_string(), &SpanContext::line_only(0)))?;
1256 Ok(StepKind::Symlink { from_workspace, from, to })
1257 },
1258 ],
1259
1260 Mkdir => [
1261 name: "MKDIR",
1262 variant: Mkdir(Arg),
1263 syntax: "MKDIR <path>",
1264 summary: "Create directory.",
1265 description: "Creates dir with parents.",
1266 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1267 flags: &[],
1268 default_output: None,
1269 examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"
1270 IMPORT [STD]
1271 MKDIR deeply/nested/tree
1272
1273 LET $t: STRING = PATH_TYPE("deeply/nested/tree")
1274 ASSERT_EQ $t "dir"
1275 "#} } ],
1276 lower: |_flags, args| Ok(StepKind::Mkdir(args.into_iter().next().ok_or_else(|| ParseError::validation("MKDIR", "MKDIR requires a path".to_string(), &SpanContext::line_only(0)))?)),
1277 ],
1278
1279 Install => [
1280 name: "INSTALL",
1281 variant: Install { from: Arg, to: Arg },
1282 syntax: "INSTALL <source> <dest>",
1283 summary: "Atomically install a verified binary as executable.",
1284 description: "Stages the source as a temp file in the dest dir, sets executable mode, then renames into place.",
1285 args: &[
1286 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source file", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1287 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest file", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1288 ],
1289 flags: &[],
1290 default_output: None,
1291 examples: &[ Example { name: "install", fence_meta: Some("roots:unified"), code: indoc! {r#"
1292 # Place a verified binary with executable mode.
1293 IMPORT [STD]
1294 WRITE src.bin payload
1295 INSTALL src.bin dst-bin/tool
1296
1297 LET $t: STRING = PATH_TYPE("dst-bin/tool")
1298 ASSERT_EQ $t "file"
1299 "#} } ],
1300 lower: |_flags, args| {
1301 let mut it = args.into_iter();
1302 let from = it.next().ok_or_else(|| ParseError::validation("INSTALL", "INSTALL requires a source".to_string(), &SpanContext::line_only(0)))?;
1303 let to = it.next().ok_or_else(|| ParseError::validation("INSTALL", "INSTALL requires a destination".to_string(), &SpanContext::line_only(0)))?;
1304 Ok(StepKind::Install { from, to })
1305 },
1306 ],
1307
1308 Ls => [
1309 name: "LS",
1310 variant: Ls(Option<Arg>),
1311 syntax: "LS [<path>]",
1312 summary: "List directory.",
1313 description: "Lists entries.",
1314 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1315 flags: &[],
1316 default_output: Some(Stream::Stdout),
1317 examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
1318 MKDIR inventory
1319 WRITE inventory/a.txt a
1320 LS inventory
1321 ASSERT_CONTAINS stdout "a.txt"
1322 "#} } ],
1323 lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
1324 ],
1325
1326 Cwd => [
1327 name: "CWD",
1328 variant: Cwd,
1329 syntax: "CWD",
1330 summary: "Print working directory.",
1331 description: "Outputs cwd.",
1332 args: &[],
1333 flags: &[],
1334 default_output: Some(Stream::Stdout),
1335 examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"
1336 CWD
1337
1338 # CWD tracks WORKDIR: the listing names the new directory.
1339 MKDIR sub
1340 WORKDIR sub
1341 LET $c: STRING = CWD
1342 ASSERT_CONTAINS $c "sub"
1343 "#} } ],
1344 lower: |_flags, _args| Ok(StepKind::Cwd),
1345 ],
1346
1347 Read => [
1348 name: "READ",
1349 variant: Read(Option<Arg>),
1350 syntax: "READ [<path>]",
1351 summary: "Read file to stdout.",
1352 description: "Outputs file contents.",
1353 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1354 flags: &[],
1355 default_output: Some(Stream::Stdout),
1356 examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
1357 WRITE note.txt "hello"
1358 READ note.txt
1359
1360 LET $body: STRING = READ note.txt
1361 ASSERT_EQ $body "hello"
1362 "#} } ],
1363 lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
1364 ],
1365
1366 ReadLine => [
1367 name: "READ_LINE",
1368 variant: ReadLine { var: String },
1369 syntax: "READ_LINE $var",
1370 summary: "Read one line from stdin into a variable.",
1371 description: indoc! {r#"
1372 Reads bytes until newline without waiting for EOF, leaving the pipe open.
1373
1374 Trailing newline is stripped (shell-read parity). On premature EOF
1375 assigns accumulated bytes and returns.
1376 "#},
1377 args: &[ ArgSpec { name: "var", arg_type: ArgType::String, description: "Target variable (`$name`); the line binds as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1378 flags: &[],
1379 default_output: None,
1380 examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
1381 # The trailing newline is stripped: the variable holds exactly `first`.
1382 LET $lines: PIPE
1383 WITH_IO [stdout=$lines] ECHO "first"
1384 WITH_IO [stdin=$lines] READ_LINE $reply
1385 ASSERT_EQ $reply "first"
1386 "#} } ],
1387 lower: |_flags, args| {
1388 let arg = args.into_iter().next().ok_or_else(|| ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))?;
1389 let var = match arg {
1390 Arg::Expr(Expr::Var(name)) => name,
1391 Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1394 other => return Err(ParseError::validation("READ_LINE", format!("READ_LINE requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1395 };
1396 if var.is_empty() {
1397 return Err(ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))
1398 }
1399 Ok(StepKind::ReadLine { var })
1400 },
1401 ],
1402
1403 Prompt => [
1404 name: "PROMPT",
1405 variant: Prompt { var: String, message: Arg, default: Option<Arg> },
1406 syntax: "PROMPT $var <message> [<default>]",
1407 summary: "Prompt on the terminal into a variable.",
1408 description: indoc! {r#"
1409 Prints `{message} [{default}]: ` (or `{message}: ` with no
1410 default) to stderr and reads one line from the terminal.
1411
1412 Empty input yields the default when one is given, else an
1413 empty string. EOF aborts loudly. Non-TTY stdin never reads:
1414 a default binds with a stderr note, while no default bails.
1415 "#},
1416 args: &[
1417 ArgSpec { name: "var", arg_type: ArgType::String, description: "Target variable (`$name`); the answer binds as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1418 ArgSpec { name: "message", arg_type: ArgType::String, description: "Prompt message", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1419 ArgSpec { name: "default", arg_type: ArgType::String, description: "Default on empty input", io: IoDirection::Read, index: 2, required: false, fallback_stream: None },
1420 ],
1421 flags: &[],
1422 default_output: None,
1423 examples: &[ Example { name: "prompt needs a terminal", fence_meta: Some("expect_error:\"non-TTY\""), code: indoc! {r#"
1424 # Headless runs have no terminal: without a default the
1425 # prompt fails instead of hanging or reading piped stdin.
1426 PROMPT $dir "Install destination?"
1427 "#} } ],
1428 lower: |_flags, args| {
1429 let mut it = args.into_iter();
1430 let var_arg = it.next().ok_or_else(|| ParseError::validation("PROMPT", "PROMPT requires a variable".to_string(), &SpanContext::line_only(0)))?;
1431 let var = match var_arg {
1432 Arg::Expr(Expr::Var(name)) => name,
1433 Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1434 other => return Err(ParseError::validation("PROMPT", format!("PROMPT requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1435 };
1436 if var.is_empty() {
1437 return Err(ParseError::validation("PROMPT", "PROMPT requires a variable".to_string(), &SpanContext::line_only(0)))
1438 }
1439 let message = it.next().ok_or_else(|| ParseError::validation("PROMPT", "PROMPT requires a message".to_string(), &SpanContext::line_only(0)))?;
1440 let default = it.next();
1441 Ok(StepKind::Prompt { var, message, default })
1442 },
1443 ],
1444
1445 Write => [
1446 name: "WRITE",
1447 variant: Write { path: Arg, contents: Option<Arg> },
1448 syntax: "WRITE <path> [<contents>]",
1449 summary: "Write to file.",
1450 description: "Writes contents.",
1451 args: &[
1452 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1453 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1454 ],
1455 flags: &[],
1456 default_output: None,
1457 examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"
1458 WRITE output.txt hello-world
1459 LET $body: STRING = READ output.txt
1460 ASSERT_EQ $body "hello-world"
1461 "#} } ],
1462 lower: |_flags, args| {
1463 let mut it = args.into_iter();
1464 let path = it.next().ok_or_else(|| ParseError::validation("WRITE", "WRITE requires a path".to_string(), &SpanContext::line_only(0)))?;
1465 let remaining: Vec<Arg> = it.collect();
1466 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
1467 Ok(StepKind::Write { path, contents })
1468 },
1469 ],
1470
1471 Append => [
1472 name: "APPEND",
1473 variant: Append { path: Arg, contents: Option<Arg> },
1474 syntax: "APPEND <path> [<contents>]",
1475 summary: "Append to file.",
1476 description: "Appends contents.",
1477 args: &[
1478 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1479 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1480 ],
1481 flags: &[],
1482 default_output: None,
1483 examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
1484 WRITE log.txt line1
1485 APPEND log.txt line2
1486
1487 # APPEND concatenates with no separator.
1488 LET $all: STRING = READ log.txt
1489 ASSERT_EQ $all "line1line2"
1490 "#} } ],
1491 lower: |_flags, args| {
1492 let mut it = args.into_iter();
1493 let path = it.next().ok_or_else(|| ParseError::validation("APPEND", "APPEND requires a path".to_string(), &SpanContext::line_only(0)))?;
1494 let remaining: Vec<Arg> = it.collect();
1495 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
1496 Ok(StepKind::Append { path, contents })
1497 },
1498 ],
1499
1500 Remove => [
1501 name: "REMOVE",
1502 variant: Remove { path: Arg, recursive: bool },
1503 syntax: "REMOVE [--recursive] <path>",
1504 summary: "Remove a file, symlink, or directory.",
1505 description: "Removes a file, symlink (the link itself), or empty dir. With --recursive removes a directory tree.",
1506 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Path to remove", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1507 flags: &[ FlagSpec { name: "recursive", long: "--recursive", value_type: FlagValueType::Flag, required: false, description: "Remove a directory tree" } ],
1508 default_output: None,
1509 examples: &[ Example { name: "remove", fence_meta: None, code: indoc! {r#"
1510 # WRITE then REMOVE leaves nothing behind.
1511 IMPORT [STD]
1512 WRITE scratch.txt content
1513
1514 # Assert original file is written
1515 LET $o: STRING = PATH_TYPE("scratch.txt")
1516 ASSERT_EQ $o "file"
1517
1518 REMOVE scratch.txt
1519
1520 # Assert file has been removed
1521 LET $t: STRING = PATH_TYPE("scratch.txt")
1522 ASSERT_EQ $t "absent"
1523 "#} } ],
1524 lower: |flags, args| {
1525 let recursive = flags.iter().any(|(k, _)| k == "recursive");
1526 let path = args.into_iter().next().ok_or_else(|| ParseError::validation("REMOVE", "REMOVE requires a path".to_string(), &SpanContext::line_only(0)))?;
1527 Ok(StepKind::Remove { path, recursive })
1528 },
1529 ],
1530
1531 Expand => [
1532 name: "EXPAND",
1533 variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
1534 syntax: "EXPAND [<path>] [<KEY=val> ...]",
1535 summary: "Expand a template file (or stdin) to stdout.",
1536 description: indoc! {r#"
1537 A template is any text file — or piped stdin when no path is given —
1538 containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
1539 and prints the result to stdout.
1540
1541 Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
1542 command; `{{ env:NAME }}` reads an override, falling back to the
1543 environment; `{{ $var }}` reads a script variable (dotted paths allowed).
1544 A missing key is an error, never a silent empty.
1545
1546 Substitution runs in a single pass. EXPAND is not recursive and does not
1547 expand nested placeholders: a value that itself contains `{{ ... }}` is
1548 inserted verbatim and never expanded again.
1549
1550 A bare `$var` argument is a template path; `KEY=val` arguments are
1551 overrides whose values follow the unified string-value rules (same as
1552 `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1553 `{{ ... }}` interpolates).
1554
1555 NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1556 (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1557
1558 With no path, the template arrives on stdin through a pipe. When piping
1559 from a shell, single-quote the template (`echo '{{ $x }}'`): double
1560 quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1561 placeholder and errors.
1562 "#},
1563 args: &[
1564 ArgSpec { name: "path", arg_type: ArgType::Path, description: "Template file to expand; omit to expand stdin", io: IoDirection::Read, index: 0, required: false, fallback_stream: None },
1565 ArgSpec { name: "overrides", arg_type: ArgType::Rest(&ArgType::String), description: "Template overrides shadowing that key (unified string values)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1566 ],
1567 flags: &[],
1568 default_output: Some(Stream::Stdout),
1569 examples: &[
1570 Example { name: "expand", fence_meta: None, code: indoc! {r#"
1571 # Placeholders read overrides first, then the environment.
1572 ENV NAME="Alice"
1573 WRITE template.md "Hello {{ env:NAME }}!"
1574 EXPAND template.md
1575
1576 ASSERT_CONTAINS stdout "Hello Alice!"
1577 "#} },
1578 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1579 # WRITE would interpolate {{ }} right away, so escape it.
1580 # The file must literally contain {{ env:NAME }} for EXPAND.
1581 WRITE template.md "Hello \{{ env:NAME }}!"
1582 EXPAND template.md NAME="Alice Smith"
1583
1584 ASSERT_CONTAINS stdout "Hello Alice Smith!"
1585 "#} },
1586 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1587 # Same escaping: keep the placeholder literal until EXPAND.
1588 # A lone $who evaluates, like ECHO $who.
1589 LET $who: STRING = "Bob"
1590 WRITE template.md "Hi \{{ env:WHO }}!"
1591 EXPAND template.md WHO=$who
1592
1593 ASSERT_CONTAINS stdout "Hi Bob!"
1594 "#} },
1595 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1596 # A bare variable and a template-with-tail expand identically.
1597 LET $x: STRING = "Ada"
1598 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1599 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1600
1601 ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1602 "#} },
1603 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1604 # No path: the template arrives on stdin through a pipe.
1605 LET $tpl: PIPE
1606 WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1607 WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1608
1609 ASSERT_CONTAINS stdout "Hello Alice!"
1610 "#} },
1611 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1612 # KEY=val overrides shadow env for that EXPAND only.
1613 # They never update the environment itself.
1614 ENV NAME="Alice"
1615 WRITE template.md "Hi \{{ env:NAME }}!"
1616
1617 EXPAND template.md NAME="Bob"
1618 ASSERT_CONTAINS stdout "Hi Bob!"
1619
1620 EXPAND template.md
1621 ASSERT_CONTAINS stdout "Hi Alice!"
1622 "#} },
1623 ],
1624 lower: |_flags, args| {
1625 let mut path = None;
1626 let mut overrides = Vec::new();
1627 for arg in args {
1628 let text = arg.as_str();
1629 if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1630 overrides.push((key, value));
1631 } else if path.is_none() { path = Some(arg); }
1632 else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1633 }
1634 Ok(StepKind::Expand { path, overrides })
1635 },
1636 ],
1637
1638 AssertEq => [
1639 name: "ASSERT_EQ",
1640 variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1641 syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1642 summary: "Assert strict equality.",
1643 description: indoc! {r#"
1644 Compares two evaluated values with typed equality (no coercion:
1645 `INT(42)` never equals `STRING("42")`), aborting the pipeline
1646 with a step-numbered error showing expected vs actual otherwise.
1647
1648 Both sides are values: `$var`, literals, templates, and calls
1649 evaluate in memory and never touch disk. Read files explicitly
1650 first (`LET $text: STRING = READ "out.txt"`, then
1651 `ASSERT_EQ $text ...`).
1652 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1653 holding a `PIPE` observes its backend bytes. `--hash` compares
1654 the SHA-256 of a string, pipe, or captured-stdout actual
1655 instead of the raw bytes (`stderr` is unsupported).
1656 "#},
1657 args: &[
1658 ArgSpec { name: "actual", arg_type: ArgType::Any, description: "Value, stdout, stderr, or a $var holding a PIPE", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1659 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1660 ],
1661 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1662 default_output: None,
1663 examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1664 LET $status: INT = 200
1665 ASSERT_EQ $status 200
1666 "#} },
1667 Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1668 WRITE payload.bin stable-content
1669 LET $body: STRING = READ payload.bin
1670 ASSERT_EQ $body "stable-content"
1671 "#} },
1672 Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1673 # --hash compares the SHA-256 digest instead of raw bytes.
1674 WRITE payload.bin stable-content
1675 LET $body: STRING = READ payload.bin
1676 ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1677 "#} } ],
1678 lower: |flags, args| {
1679 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1680 let mut it = args.into_iter();
1681 let actual = lower_assert_target(it.next().ok_or_else(|| ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires a value".to_string(), &SpanContext::line_only(0)))?)?;
1682 let remaining: Vec<Arg> = it
1683 .map(lower_assert_operand)
1684 .collect::<Vec<Arg>>();
1685 let expected = if remaining.is_empty() {
1688 if hash.is_some() {
1689 None
1690 } else {
1691 return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1692 }
1693 } else {
1694 Some(join_value(remaining, "ASSERT_EQ")?)
1695 };
1696 Ok(StepKind::AssertEq { hash, actual, expected })
1697 },
1698 ],
1699
1700 AssertContains => [
1701 name: "ASSERT_CONTAINS",
1702 variant: AssertContains { haystack: AssertTarget, needle: Arg },
1703 syntax: "ASSERT_CONTAINS <haystack> <needle>",
1704 summary: "Assert containment.",
1705 description: indoc! {r#"
1706 Checks containment and aborts the pipeline with a step-numbered
1707 error otherwise: substring for strings, element match for lists,
1708 key presence for maps, substring over stream and pipe buffers.
1709
1710 Like `ASSERT_EQ`, both sides are values read without implicit
1711 I/O; read files explicitly first
1712 (`LET $text: STRING = READ "cfg.txt"`).
1713 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1714 holding a `PIPE` observes its backend bytes.
1715 "#},
1716 args: &[
1717 ArgSpec { name: "haystack", arg_type: ArgType::Any, description: "Value, stdout, stderr, or a $var holding a PIPE", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1718 ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1719 ],
1720 flags: &[],
1721 default_output: None,
1722 examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1723 ECHO build-complete
1724 ASSERT_CONTAINS stdout "build-complete"
1725 "#} } ],
1726 lower: |flags, args| {
1727 let _ = flags;
1728 let mut it = args.into_iter();
1729 let haystack = lower_assert_target(it.next().ok_or_else(|| ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a value".to_string(), &SpanContext::line_only(0)))?)?;
1730 let remaining: Vec<Arg> = it
1731 .map(lower_assert_operand)
1732 .collect::<Vec<Arg>>();
1733 if remaining.is_empty() {
1734 return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1735 }
1736 let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1737 Ok(StepKind::AssertContains { haystack, needle })
1738 },
1739 ],
1740
1741 HashSha256 => [
1742 name: "HASH_SHA256",
1743 variant: HashSha256 { path: Arg },
1744 syntax: "HASH_SHA256 <path>",
1745 summary: "Print SHA-256.",
1746 description: "Computes digest.",
1747 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1748 flags: &[],
1749 default_output: Some(Stream::Stdout),
1750 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1751 WRITE payload.txt hello
1752 HASH_SHA256 payload.txt
1753
1754 LET $digest: STRING = HASH_SHA256 payload.txt
1755 ASSERT_EQ $digest "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\n"
1756 "#} } ],
1757 lower: |_flags, args| Ok(StepKind::HashSha256 { path: args.into_iter().next().ok_or_else(|| ParseError::validation("HASH_SHA256", "HASH_SHA256 requires a path".to_string(), &SpanContext::line_only(0)))? }),
1758 ],
1759
1760 Exit => [
1761 name: "EXIT",
1762 variant: Exit(Arg),
1763 syntax: "EXIT <code>",
1764 summary: "Exit pipeline.",
1765 description: indoc! {r#"
1766 Stops the pipeline immediately with an `EXIT requested with code <code>`
1767 error; steps after it never run, at any nesting depth.
1768
1769 Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1770 anonymous background tasks are killed synchronously, and files written
1771 before the EXIT persist.
1772
1773 The code reaches the OS: the CLI exits with `<code>` clamped to
1774 0..=255, and a guest EXIT relays through `REMOTE` the same way.
1775 "#},
1776 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1777 flags: &[],
1778 default_output: None,
1779 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1780 lower: |_flags, args| {
1781 let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1784 Ok(StepKind::Exit(code))
1785 },
1786 ],
1787
1788 Sleep => [
1789 name: "SLEEP",
1790 variant: Sleep { duration: Arg },
1791 syntax: "SLEEP <duration>",
1792 summary: "Pause execution for a duration.",
1793 description: indoc! {r#"
1794 Parks the step for the duration (e.g. 500ms, 10s, 2m).
1795
1796 Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1797 teardown interrupts the sleep. Cross-platform alternative to shell sleep
1798 for testing time boundaries.
1799 "#},
1800 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1801 flags: &[],
1802 default_output: None,
1803 examples: &[
1804 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1805 Example {
1806 name: "sleep variable duration",
1807 fence_meta: None,
1808 code: indoc! {r#"
1809 # Durations resolve at runtime, so variables work too:
1810 # quoted or bare, both bind the same string.
1811 LET $pause: STRING = "100ms"
1812 SLEEP $pause
1813
1814 LET $bare: STRING = 100ms
1815 SLEEP $bare
1816 "#},
1817 },
1818 ],
1819 lower: |_flags, args| {
1820 let mut it = args.into_iter();
1821 let raw = it
1822 .next()
1823 .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1824 if it.next().is_some() {
1825 return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1826 }
1827 Ok(StepKind::Sleep { duration: raw })
1830 },
1831 ],
1832
1833 ListAppend => [
1834 name: "LIST_APPEND",
1835 variant: ListAppend { list: String, item: Arg },
1836 syntax: "LIST_APPEND $list <item>",
1837 summary: "Append an item to a LIST variable in place.",
1838 description: indoc! {r#"
1839 Appends the item to the LIST variable in place.
1840
1841 When the binding holds the only reference the push runs in
1842 amortized constant time. Aliased buffers detach first, so
1843 other holders keep their contents.
1844 "#},
1845 args: &[
1846 ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1847 ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1848 ],
1849 flags: &[],
1850 default_output: None,
1851 examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
1852 # Appends accumulate in order.
1853 LET $items: LIST<STRING> = []
1854 LIST_APPEND $items "first"
1855 LIST_APPEND $items "second"
1856
1857 LET $want: LIST<STRING> = ["first", "second"]
1858 ASSERT_EQ $items $want
1859 "#} } ],
1860 lower: |_flags, args| {
1861 let mut it = args.into_iter();
1862 let raw_list = it
1863 .next()
1864 .ok_or_else(|| ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))?;
1865 let list = match raw_list {
1866 Arg::Expr(Expr::Var(name)) => name,
1867 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
1868 other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1869 };
1870 if list.is_empty() {
1871 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
1872 }
1873 let item = it
1874 .next()
1875 .ok_or_else(|| ParseError::validation("LIST_APPEND", "LIST_APPEND requires an item to append (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))?;
1876 if it.next().is_some() {
1877 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
1878 }
1879 Ok(StepKind::ListAppend { list, item })
1880 },
1881 ],
1882}
1883
1884pub fn all_structural_metadata() -> Vec<CommandMeta> {
1892 vec![
1893 CommandMeta {
1894 name: "WITH_IO",
1895 syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
1896 summary: "Reroute standard streams.",
1897 description: indoc! {r#"
1898 Reroutes the standard streams of the next command or, in block form,
1899 of every enclosed command.
1900
1901 Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
1902 variable (`stdout=$p`, `stdin=$p`), resolved from the variable
1903 when the step runs. Both stdout and stderr pipes capture output
1904 the same way. Declare the handle first with `LET $p: PIPE`.
1905
1906 Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
1907 producer can finish before the consumer starts.
1908
1909 If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
1910 not, the pipe is a zero copy OS kernel pipe instead: pair it with a
1911 consumer that runs while the producer is alive, since output past the
1912 64 KiB kernel buffer stalls until drained. That promotion never crosses
1913 a function boundary: pipes created, bound, or passed by variable inside FUNC
1914 bodies are always script pipes, even when the surrounding task would
1915 otherwise promote.
1916
1917 A second producer or consumer on a live handle is an explicit
1918 error. A handle bound as output can later feed another
1919 command's `stdin`, connecting commands without touching the
1920 terminal. Binding `stdout` and `stderr` to the same live
1921 handle fails deterministically. Merge streams in shell
1922 via `2>&1` instead.
1923
1924 Nested blocks stack defaults; inline bindings override inherited ones for
1925 their command only; closing a block restores previous wiring.
1926 "#},
1927 args: &[],
1928 flags: &[],
1929 default_output: None,
1930 examples: &[
1931 Example {
1932 name: "with_io block",
1933 fence_meta: None,
1934 code: indoc! {r#"
1935 LET $log: PIPE
1936 WITH_IO [stdout=$log] {
1937 ECHO first
1938 ECHO second
1939 }
1940 WITH_IO [stdin=$log] WRITE captured.txt
1941
1942 # The piped bytes landed in the file.
1943 LET $body: STRING = READ captured.txt
1944 ASSERT_CONTAINS $body "first"
1945 ASSERT_CONTAINS $body "second"
1946 "#},
1947 },
1948 Example {
1949 name: "variable pipe binding",
1950 fence_meta: None,
1951 code: indoc! {r#"
1952 # Declare the pipe first: `LET $p: PIPE` mints a fresh
1953 # backend without touching a stream. A plain string here
1954 # would be a TypeMismatch.
1955 LET $p: PIPE
1956 WITH_IO [stdout=$p] ECHO hello
1957 WITH_IO [stdin=$p] READ_LINE $line
1958 ASSERT_EQ $line "hello"
1959 "#},
1960 },
1961 ],
1962 },
1963 CommandMeta {
1964 name: "FOR",
1965 syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
1966 summary: "Iterate over a list or map.",
1967 description: indoc! {r#"
1968 The loop variable receives each element (lists) or value (maps); with
1969 two variables, the first receives the key.
1970
1971 Loop variables are declared with explicit types and scoped per iteration;
1972 they do not leak outward. The body may be a braced block
1973 or a single-line `{ ... }` command.
1974
1975 `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
1976 `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
1977 empty when nothing matches, and rejects `..` escapes.
1978 "#},
1979 args: &[],
1980 flags: &[],
1981 default_output: None,
1982 examples: &[
1983 Example {
1984 name: "for loop",
1985 fence_meta: None,
1986 code: indoc! {r#"
1987 # Each element binds in turn; the loop body sees every one.
1988 LET $items: LIST<STRING> = ["a", "b"]
1989 FOR $item: STRING IN $items {
1990 ECHO $item
1991 }
1992 ASSERT_CONTAINS stdout "a"
1993 ASSERT_CONTAINS stdout "b"
1994
1995 # Key and value bind together for maps.
1996 LET $map: MAP<x: INT> = {"x": 1}
1997 FOR $k: STRING, $v: INT IN $map {
1998 ECHO "{{ $k }}={{ $v }}"
1999 }
2000 ASSERT_CONTAINS stdout "x=1"
2001 "#},
2002 },
2003 Example {
2004 name: "expand every match",
2005 fence_meta: None,
2006 code: indoc! {r#"
2007 # Single-line body; $x is a template path, WHO an override.
2008 IMPORT [STD]
2009 WRITE a.txt "hi \{{ env:WHO }}!"
2010 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2011
2012 ASSERT_CONTAINS stdout "hi World!"
2013 "#},
2014 },
2015 ],
2016 },
2017 CommandMeta {
2018 name: "IF",
2019 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
2020 summary: "Conditional execution.",
2021 description: indoc! {r#"
2022 The condition is evaluated as a boolean expression.
2023
2024 Prefix `!` negates (`IF !false`); `&&` binds tighter than
2025 `||`, and both short-circuit, so `IF true || $missing`
2026 never evaluates the right side. Only Bool values are
2027 accepted as conditions.
2028 "#},
2029 args: &[],
2030 flags: &[],
2031 default_output: None,
2032 examples: &[
2033 Example {
2034 name: "if else",
2035 fence_meta: None,
2036 code: indoc! {r#"
2037 IMPORT [STD]
2038
2039 # True branch runs; the false branch is skipped.
2040 IF true {
2041 WRITE yes.txt taken
2042 } ELSE {
2043 WRITE yes.txt skipped
2044 }
2045
2046 # ELSE IF selects the first true branch.
2047 IF false {
2048 WRITE skipped.txt no
2049 } ELSE IF true {
2050 WRITE fallback.txt taken
2051 }
2052
2053 # !false evaluates to true, so this branch runs.
2054 IF !false {
2055 WRITE negated.txt taken
2056 }
2057
2058 LET $yes_body: STRING = READ yes.txt
2059 LET $fallback_body: STRING = READ fallback.txt
2060 LET $negated_body: STRING = READ negated.txt
2061 ASSERT_EQ $yes_body "taken"
2062 ASSERT_EQ $fallback_body "taken"
2063 ASSERT_EQ $negated_body "taken"
2064 LET $t: STRING = PATH_TYPE("skipped.txt")
2065 ASSERT_EQ $t "absent"
2066 "#},
2067 },
2068 Example {
2069 name: "logical condition composition",
2070 fence_meta: None,
2071 code: indoc! {r#"
2072 IMPORT [STD]
2073 LET $role: STRING = "admin"
2074 LET $level: INT = 3
2075
2076 # || is true when either side holds; && needs both.
2077 IF $role == "owner" || $level >= 5 {
2078 WRITE unexpected.txt no
2079 } ELSE {
2080 WRITE fallback.txt or-false
2081 }
2082
2083 LET $fb: STRING = READ fallback.txt
2084 ASSERT_EQ $fb "or-false"
2085 LET $t1: STRING = PATH_TYPE("unexpected.txt")
2086 ASSERT_EQ $t1 "absent"
2087
2088 IF $role == "admin" || $level >= 5 {
2089 WRITE chosen.txt or-true
2090 }
2091
2092 LET $ch: STRING = READ chosen.txt
2093 ASSERT_EQ $ch "or-true"
2094
2095 IF $role == "admin" && $level >= 5 {
2096 WRITE unexpected-too.txt no
2097 } ELSE {
2098 WRITE and.txt and-false
2099 }
2100
2101 LET $an: STRING = READ and.txt
2102 ASSERT_EQ $an "and-false"
2103 LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
2104 ASSERT_EQ $t2 "absent"
2105 "#},
2106 },
2107 ],
2108 },
2109 CommandMeta {
2110 name: "LET",
2111 syntax: "LET $var: TYPE = <expr> | LET $p: PIPE | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task | LET $var: TYPE = { <commands> }",
2112 summary: "Bind script-local variables.",
2113 description: indoc! {r#"
2114 Declares a script-local variable with an explicit type (STRING, INT,
2115 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
2116 in the same scope frame is a redeclaration error; mutate with
2117 `$var = <expr>`.
2118
2119 Variables are usable in templates (`{{ $var }}`), guards, and
2120 expressions. With `ASYNC`, spawns a background task and stores its
2121 handle (see ASYNC). The `$` sigil on the name is mandatory.
2122
2123 No hoisting: a variable exists only after its LET runs, in
2124 execution order. Reading `$var` before its LET (or after the
2125 block that declared it exits) fails with
2126 `undefined variable $var`. Scopes are a stack of frames and
2127 resolution walks innermost outward, so nothing pre-declares
2128 names. Function bodies read outer variables through the same
2129 walk, but their own LETs never leak out (see FUNC).
2130
2131 The right-hand side is always an expression — literals, lists, maps,
2132 arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
2133 parentheses), comparisons (`< <= > >=` binding tighter than
2134 `== !=`), logical `&&` (tighter) and `||` with short-circuit,
2135 `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
2136 `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
2137 `{{ ... }}` template; interpolation happens in string values,
2138 not here.
2139 The one exception is pipes: `LET $p: PIPE` with no `=`
2140 and no initializer mints a fresh anonymous backend,
2141 lazily materialized at first binding, so two declarations
2142 never share a channel.
2143
2144 Numbers are numeric literals: `42` binds `INT`, `3.14` binds
2145 `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
2146 so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
2147 Division by zero, overflow, and non-finite results are errors.
2148 Both numeric sides compare numerically (`1 == 1.0` is true);
2149 otherwise `==`/`!=` compare rendered strings and ordering on
2150 non-numerics is a Type Error. Constant subtrees fold at parse
2151 time and dynamic arithmetic compiles to flat RPN with
2152 identical semantics.
2153
2154 Float equality is exact with no epsilon. Floats store decimals
2155 in binary, so a value is exact only when its reduced fraction
2156 has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
2157 (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
2158 repeat forever in binary (like 1/3 in decimal) and truncate,
2159 so `0.1 + 0.2 == 0.3` is false (the sum is
2160 `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
2161 .125, .625, .875 are exact; .1, .2, .3 and similar are
2162 approximations. Bound approximations instead of comparing
2163 them: `IF $sum > 0.299999 && $sum < 0.300001`.
2164
2165 Comparisons do not chain: `a < b < c` is a parse error, not
2166 `(a < b) < c`. Chaining would compare a `BOOL` against a
2167 number (a runtime Type Error in C-style parsing) or evaluate
2168 the middle term twice (Python-style chaining), so the grammar
2169 accepts exactly one comparison operator per level. Write the
2170 conjunction explicitly: `$a < $b && $b < $c`. The same holds
2171 for equality (`$a == $b == $c` is rejected).
2172
2173 Captured command output is a string, so convert before math:
2174 `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
2175 whitespace; `FLOAT` accepts int strings and rejects
2176 non-finite).
2177
2178 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
2179 as quoted.
2180
2181 When the right-hand side is a synchronous command
2182 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
2183 exact stdout bytes are captured into the variable as a string (no newline
2184 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
2185 an error). Combining capture with an explicit
2186 `WITH_IO [stdout=$var]` is a parse error.
2187
2188 Coming from Bash, the capture line looks familiar but behaves
2189 strictly:
2190
2191 | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
2192 | --- | --- | --- |
2193 | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
2194 | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
2195 | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
2196 | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
2197
2198 `LET $out: TYPE = AWAIT $var` binds the background task's
2199 explicit `RETURN` value instead (tasks stream their stdout
2200 live, so there is no output left to capture); a task that
2201 succeeded without `RETURN` yields `INT` 0, like a process
2202 exit status.
2203
2204 An inline block (`LET $var: TYPE = { <commands> }`) runs its
2205 steps in a fresh scope and binds the nearest `RETURN` value,
2206 like a zero-arg function body: fallthrough without `RETURN`
2207 binds `""`, and `BREAK`/`CONTINUE` escaping the block are
2208 errors. The block reads outer variables but its own LETs
2209 never leak out. A `{k: v}` shape still parses as a map
2210 literal; anything else in braces is a block.
2211
2212 The split is deliberate: synchronous commands capture
2213 stdout because they run inline to completion on the same
2214 thread; background tasks never capture stdout because
2215 concurrent output has no well-defined value. Task results
2216 travel only through `RETURN` (or `INT` 0 for void tasks).
2217
2218 `LET $e: STRING = env:FOO` reads the script environment into a plain
2219 string.
2220 "#},
2221 args: &[],
2222 flags: &[],
2223 default_output: None,
2224 examples: &[
2225 Example {
2226 name: "let",
2227 fence_meta: None,
2228 code: indoc! {r#"
2229 LET $name: STRING = "world"
2230 ECHO "hello, {{ $name }}"
2231 ASSERT_CONTAINS stdout "hello, world"
2232
2233 LET $items: LIST<STRING> = ["a", "b"]
2234 ASSERT_CONTAINS $items "a"
2235 ASSERT_CONTAINS $items "b"
2236
2237 LET $count: INT = 42
2238 ASSERT_EQ $count 42
2239 "#},
2240 },
2241 Example {
2242 name: "no hoisting",
2243 fence_meta: Some("expect_error:\"undefined variable\""),
2244 code: indoc! {r#"
2245 # Reading before the LET runs is an error, not an empty value.
2246 ECHO $too_early
2247 LET $too_early: STRING = "too late"
2248 "#},
2249 },
2250 Example {
2251 name: "glob binding",
2252 fence_meta: None,
2253 code: indoc! {r#"
2254 # The RHS is an expression: GLOB(...) runs and binds a list.
2255 IMPORT [STD]
2256 WRITE a.txt "x"
2257 LET $files: LIST<STRING> = GLOB("*.txt")
2258 FOR $f: STRING IN $files { ECHO $f }
2259
2260 ASSERT_CONTAINS stdout "a.txt"
2261 "#},
2262 },
2263 Example {
2264 name: "scoped variable reverts",
2265 fence_meta: None,
2266 code: indoc! {r#"
2267 # LET inside a braced block reverts when the block exits.
2268 LET $a: STRING = "outer"
2269
2270 [bool:true] {
2271 LET $a: STRING = "inner"
2272 WRITE inner.txt "{{ $a }}"
2273 }
2274
2275 WRITE outer.txt "{{ $a }}"
2276
2277 LET $in_body: STRING = READ inner.txt
2278 ASSERT_EQ $in_body "inner"
2279
2280 LET $out_body: STRING = READ outer.txt
2281 ASSERT_EQ $out_body "outer"
2282 "#},
2283 },
2284 Example {
2285 name: "capture command output",
2286 fence_meta: None,
2287 code: indoc! {r#"
2288 # Capture keeps the trailing newline.
2289 LET $out: STRING = ECHO hi
2290 ASSERT_EQ $out "hi\n"
2291 "#},
2292 },
2293 Example {
2294 name: "inline block",
2295 fence_meta: None,
2296 code: indoc! {r#"
2297 LET $who: STRING = "ada"
2298
2299 # An inline block binds its RETURN value like a function body.
2300 LET $res: STRING = {
2301 LET $loud: STRING = "{{ $who }}!"
2302 RETURN $loud
2303 }
2304 ASSERT_EQ $res "ada!"
2305
2306 # Any declared type works: the block value checks like any RHS.
2307 LET $n: INT = {
2308 RETURN 40 + 2
2309 }
2310 ASSERT_EQ $n 42
2311 "#},
2312 },
2313 Example {
2314 name: "arithmetic over captured output",
2315 fence_meta: None,
2316 code: indoc! {r#"
2317 # Captured output converts explicitly: INT() then arithmetic.
2318 IMPORT [STD]
2319 LET $size_str: STRING = ECHO 41
2320 LET $total: INT = INT($size_str) + 1
2321 ASSERT_EQ $total 42
2322
2323 # FLOAT() promotes instead of truncating.
2324 LET $ratio: FLOAT = 1 + 2.5
2325 ASSERT_EQ $ratio 3.5
2326
2327 # Int x Int stays INT: integer division truncates.
2328 LET $half: INT = 7 / 2
2329 ASSERT_EQ $half 3
2330 "#},
2331 },
2332 Example {
2333 name: "float equality is exact",
2334 fence_meta: None,
2335 code: indoc! {r#"
2336 # Binary fractions compare cleanly; decimal fractions may not:
2337 # 0.1 + 0.2 is 0.30000000000000004, so == is false.
2338 IMPORT [STD]
2339 LET $exact: BOOL = 0.5 + 0.25 == 0.75
2340 LET $decimal: BOOL = 0.1 + 0.2 == 0.3
2341 IF $exact {
2342 WRITE exact.txt yes
2343 }
2344 IF $decimal {
2345 WRITE unexpected.txt no
2346 }
2347
2348 LET $ok: STRING = READ exact.txt
2349 ASSERT_EQ $ok "yes"
2350
2351 LET $t: STRING = PATH_TYPE("unexpected.txt")
2352 ASSERT_EQ $t "absent"
2353 "#},
2354 },
2355 Example {
2356 name: "bound inexact decimals",
2357 fence_meta: None,
2358 code: indoc! {r#"
2359 # Never test inexact decimals for equality; bound them.
2360 LET $sum: FLOAT = 0.1 + 0.2
2361 IF $sum > 0.299999 && $sum < 0.300001 {
2362 WRITE bounded.txt yes
2363 }
2364
2365 LET $ok: STRING = READ bounded.txt
2366 ASSERT_EQ $ok "yes"
2367 "#},
2368 },
2369 Example {
2370 name: "inspect a variable",
2371 fence_meta: None,
2372 code: indoc! {r#"
2373 # INSPECT($var) snapshots a variable into a MAP: declared
2374 # type plus live details (pipe backend stats here), so
2375 # scripts can branch on engine state.
2376 IMPORT [STD]
2377 LET $p: PIPE
2378 WITH_IO [stdout=$p] ECHO hello
2379 LET $info: MAP<ANY> = INSPECT($p)
2380 IF $info.is_os_pipe {
2381 WRITE unexpected.txt "should be a script pipe"
2382 }
2383
2384 ASSERT_EQ $info.type "PIPE"
2385 LET $t: STRING = PATH_TYPE("unexpected.txt")
2386 ASSERT_EQ $t "absent"
2387 "#},
2388 },
2389 ],
2390 },
2391 CommandMeta {
2392 name: "MUTATION",
2393 syntax: "$var = <expr>",
2394 summary: "Mutate a declared variable.",
2395 description: indoc! {r#"
2396 Reassigns an existing variable, converting the new value to
2397 the type declared at LET time. The explicit annotation is
2398 what authorizes string-to-number conversion here (`$n = "42"`
2399 binds 42 for an INT); a non-numeric string is an error.
2400 Expressions never convert: `"100" + 1` is a Type Error, use
2401 `INT()` / `FLOAT()` to cross that boundary explicitly.
2402
2403 The leading `$` distinguishes mutation from `KEY=value` command
2404 assignments. Assigning an undeclared variable or a mismatched type is
2405 an error.
2406
2407 Mutation writes through to the scope where the variable was
2408 declared, so it survives block exit: `LET $x` outside a block
2409 followed by `$x = ...` inside still reads back the new value
2410 afterwards, for every type. This is the counterpart to LET
2411 shadowing, where `LET $x` *inside* the block declares a
2412 separate inner variable that reverts on exit.
2413
2414 Reassignment rebinds the variable to a new value: values
2415 captured earlier keep the old word (see the rebinding
2416 example below).
2417 "#},
2418 args: &[],
2419 flags: &[],
2420 default_output: None,
2421 examples: &[
2422 Example {
2423 name: "mutate",
2424 fence_meta: None,
2425 code: indoc! {r#"
2426 # Mutation writes through: the binding holds the new value.
2427 LET $count: INT = 1
2428 $count = 2
2429 ASSERT_EQ $count 2
2430 "#},
2431 },
2432 Example {
2433 name: "rebind keeps earlier captures",
2434 fence_meta: None,
2435 code: indoc! {r#"
2436 # Reassignment rebinds: a map built from `$s` still
2437 # reads the original string after `$s` moves on.
2438 LET $s: STRING = "test"
2439 LET $m: MAP<ANY> = {a: $s}
2440 $s = "changed"
2441 ASSERT_EQ $m.a "test"
2442
2443 # Appending to one list binding never touches its clones.
2444 LET $a: LIST<STRING> = ["1"]
2445 LET $b: LIST<STRING> = $a
2446 LIST_APPEND $a "2"
2447 LET $want: LIST<STRING> = ["1"]
2448 ASSERT_EQ $b $want
2449 "#},
2450 },
2451 Example {
2452 name: "convert before math",
2453 fence_meta: None,
2454 code: indoc! {r#"
2455 # Captured output is a string: `"100" + 1` is a Type Error.
2456 # Convert explicitly, then mutate with arithmetic.
2457 IMPORT [STD]
2458 LET $raw: STRING = ECHO 100
2459 LET $n: INT = INT($raw)
2460 $n = $n + 1
2461
2462 # The declared type also converts plain strings on assignment.
2463 $n = "42"
2464 ASSERT_EQ $n 42
2465
2466 # Same crossing for decimals via FLOAT().
2467 LET $frac_str: STRING = ECHO 2.5
2468 LET $f: FLOAT = FLOAT($frac_str) + 0.25
2469 ASSERT_EQ $f 2.75
2470 "#},
2471 },
2472 ],
2473 },
2474 CommandMeta {
2475 name: "ASYNC",
2476 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
2477 summary: "Run steps in a background thread.",
2478 description: indoc! {r#"
2479 Runs a command or block of commands in a background thread with
2480 subshell isolation.
2481
2482 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
2483 task handle for `AWAIT`. Task output streams live to the parent
2484 stdout; a task publishes a value with an explicit `RETURN`,
2485 which `LET $out: TYPE = AWAIT $task` binds.
2486
2487 `WITH_IO` must wrap `ASYNC` from the outside (`LET $p: PIPE` first,
2488 then `WITH_IO [stdout=$p] ASYNC ...`) so pipe endpoints allocate on
2489 the main thread before the worker spawns. Placing `WITH_IO` directly
2490 inside `ASYNC` is rejected at parse time.
2491 "#},
2492 args: &[],
2493 flags: &[],
2494 default_output: None,
2495 examples: &[
2496 Example {
2497 name: "async",
2498 fence_meta: None,
2499 code: indoc! {r#"
2500 # Inline and block forms both run in the background; AWAIT joins them.
2501 ASYNC ECHO "warming-up"
2502 LET $a: HANDLE = ASYNC ECHO "first"
2503 LET $b: HANDLE = ASYNC {
2504 ECHO "second"
2505 }
2506 AWAIT $a
2507 AWAIT $b
2508 ASSERT_CONTAINS stdout "first"
2509 ASSERT_CONTAINS stdout "second"
2510 "#},
2511 },
2512 Example {
2513 name: "async task handle",
2514 fence_meta: None,
2515 code: indoc! {r#"
2516 LET $task: HANDLE = ASYNC {
2517 ECHO "built"
2518 }
2519 AWAIT $task
2520 ASSERT_CONTAINS stdout "built"
2521 "#},
2522 },
2523 ],
2524 },
2525 CommandMeta {
2526 name: "AWAIT",
2527 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2528 summary: "Join a background task.",
2529 description: indoc! {r#"
2530 Blocks until the named task completes. Propagates errors if the task failed.
2531
2532 Task output streams live during the run; joining binds nothing by
2533 itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2534 `RETURN` value instead, or `INT` 0 when the task succeeded
2535 without one (add `RETURN <expr>` to the task body to yield
2536 a value).
2537 "#},
2538 args: &[],
2539 flags: &[],
2540 default_output: None,
2541 examples: &[
2542 Example {
2543 name: "await",
2544 fence_meta: None,
2545 code: indoc! {r#"
2546 LET $task: HANDLE = ASYNC ECHO "done"
2547 AWAIT $task
2548 ASSERT_CONTAINS stdout "done"
2549 "#},
2550 },
2551 Example {
2552 name: "await capture",
2553 fence_meta: None,
2554 code: indoc! {r#"
2555 LET $task: HANDLE = ASYNC {
2556 ECHO "logged"
2557 RETURN "returned"
2558 }
2559
2560 # AWAIT binds the RETURN value, not the streamed output.
2561 LET $out: STRING = AWAIT $task
2562 ASSERT_EQ $out "returned"
2563 "#},
2564 },
2565 ],
2566 },
2567 CommandMeta {
2568 name: "CANCEL",
2569 syntax: "CANCEL $var",
2570 summary: "Synchronously cancel a background task.",
2571 description: indoc! {r#"
2572 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2573
2574 Blocking: returns only after the task thread has been joined and its OS
2575 process reaped, so no residual filesystem or stream mutation follows. A
2576 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2577 "#},
2578 args: &[],
2579 flags: &[],
2580 default_output: None,
2581 examples: &[
2582 Example {
2583 name: "cancel",
2584 fence_meta: None,
2585 code: indoc! {r#"
2586 LET $task: HANDLE = ASYNC SLEEP 30s
2587 CANCEL $task
2588 "#},
2589 },
2590 Example {
2591 name: "await after cancel reports cancellation",
2592 fence_meta: Some("expect_error:\"was cancelled\""),
2593 code: indoc! {r#"
2594 # A cancelled task stays cancelled: joining it reports.
2595 LET $task: HANDLE = ASYNC SLEEP 30s
2596 CANCEL $task
2597 AWAIT $task
2598 "#},
2599 },
2600 ],
2601 },
2602 CommandMeta {
2603 name: "TIMEOUT",
2604 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2605 summary: "Enforce an execution deadline.",
2606 description: indoc! {r#"
2607 Aborts the wrapped step or block with a deadline error if it exceeds the
2608 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2609
2610 A blocking foreground process is killed.
2611
2612 ASYNC composes in either nesting order with order-dependent deadline
2613 semantics: `LET $task: HANDLE = ASYNC TIMEOUT 30s RUN "build"` enforces
2614 the deadline inside the background thread (a later `AWAIT $task`
2615 surfaces the `TIMEOUT` error), while `TIMEOUT 30s AWAIT $task`
2616 preempts a hung task from the awaiting side.
2617 "#},
2618 args: &[],
2619 flags: &[],
2620 default_output: None,
2621 examples: &[
2622 Example {
2623 name: "timeout",
2624 fence_meta: None,
2625 code: indoc! {r#"
2626 TIMEOUT 30s WRITE heartbeat.txt alive
2627 LET $beat: STRING = READ heartbeat.txt
2628 ASSERT_EQ $beat "alive"
2629 "#},
2630 },
2631 Example {
2632 name: "timeout block",
2633 fence_meta: None,
2634 code: indoc! {r#"
2635 TIMEOUT 30s {
2636 WRITE a.txt one
2637 WRITE b.txt two
2638 }
2639 LET $a: STRING = READ a.txt
2640 LET $b: STRING = READ b.txt
2641 ASSERT_EQ $a "one"
2642 ASSERT_EQ $b "two"
2643 "#},
2644 },
2645 Example {
2646 name: "deadline aborts the step",
2647 fence_meta: Some("expect_error:\"TIMEOUT after\""),
2648 code: indoc! {r#"
2649 # 50ms expires long before the sleep does: the step dies
2650 # with a deadline error instead of running out the clock.
2651 TIMEOUT 50ms SLEEP 30s
2652 "#},
2653 },
2654 Example {
2655 name: "timeout variable duration",
2656 fence_meta: None,
2657 code: indoc! {r#"
2658 # Durations resolve at runtime, so variables work too.
2659 LET $budget: DURATION = "30s"
2660 TIMEOUT $budget WRITE heartbeat.txt alive
2661
2662 LET $beat: STRING = READ heartbeat.txt
2663 ASSERT_EQ $beat "alive"
2664 "#},
2665 },
2666 Example {
2667 name: "timeout await",
2668 fence_meta: None,
2669 code: indoc! {r#"
2670 # AWAIT form bounds a task join instead of a step.
2671 LET $quick: HANDLE = ASYNC {
2672 ECHO hi
2673 }
2674 TIMEOUT 30s AWAIT $quick
2675 "#},
2676 },
2677 ],
2678 },
2679 CommandMeta {
2680 name: "REMOTE",
2681 syntax: "REMOTE <target> [[$var, ...] [env:NAME, ...]] { <commands> }",
2682 summary: "Run a sealed block on a remote target.",
2683 description: indoc! {r#"
2684 Runs the braced block on the named remote target, which the
2685 host binds at startup (`--remote`). The block is sealed:
2686 only header-listed `$var` and `env:NAME` entries cross, the
2687 guest starts empty, and results return through declared
2688 transfers or `WITH_IO` pipes. The target is a static
2689 literal, never a variable. Unknown targets fail before
2690 execution starts. Bodies check twice: the host static
2691 pass validates the body before anything ships, and the
2692 guest re-validates on arrival through the same entry
2693 point, so a body type error fails locally, never mid-run
2694 on the remote. A failing guest fails the host step: EXIT
2695 in the body exits the guest, and the host relays the code
2696 to its own process exit status (see EXIT).
2697 "#},
2698 args: &[],
2699 flags: &[],
2700 default_output: None,
2701 examples: &[
2702 Example {
2703 name: "sealed header values cross, outer scope does not",
2704 fence_meta: Some("mock_remote:prod"),
2705 code: indoc! {r#"
2706 # Only header-listed names arrive: `$version` is visible
2707 # on the guest, `$secret` was never sent.
2708 LET $version: STRING = "1.2.3"
2709 LET $secret: STRING = "host-only"
2710 REMOTE prod [$version] {
2711 WRITE version.txt "{{ $version }}"
2712 COPY --to-host ./version.txt back.txt
2713 }
2714
2715 # The pushed file proves the guest saw the injected value.
2716 LET $back: STRING = READ back.txt
2717 ASSERT_EQ $back "1.2.3"
2718 "#},
2719 },
2720 Example {
2721 name: "background tasks stream through shared pipes",
2722 fence_meta: Some("mock_remote:prod"),
2723 code: indoc! {r#"
2724 # Two background REMOTEs share one capture pipe while a
2725 # WITH_IO block holds the write end open.
2726 IMPORT [STD]
2727 LET $cap: PIPE
2728 WITH_IO [stdout=$cap] {
2729 LET $a: HANDLE = ASYNC REMOTE prod {
2730 ECHO "from-a"
2731 }
2732 LET $b: HANDLE = ASYNC REMOTE prod {
2733 ECHO "from-b"
2734 }
2735 AWAIT $a
2736 AWAIT $b
2737 }
2738
2739 # EOF ends the drain: no sentinel line, no baked-in count.
2740 LET $n: INT = 0
2741 WHILE !EOF($cap) {
2742 WITH_IO [stdin=$cap] READ_LINE $line
2743 $n = $n + 1
2744 }
2745 ASSERT_EQ $n 2
2746 "#},
2747 },
2748 Example {
2749 name: "declared files cross both directions",
2750 fence_meta: Some("mock_remote:prod roots:unified"),
2751 code: indoc! {r#"
2752 # Fetches ride in, pushes ride back. Anything undeclared
2753 # never leaves its side.
2754 WRITE send.txt "payload"
2755 REMOTE prod {
2756 COPY --from-host ./send.txt got.txt
2757 LET $v: STRING = READ got.txt
2758 ASSERT_EQ $v "payload"
2759 WRITE back.txt "returned"
2760 COPY --to-host ./back.txt back.txt
2761 }
2762
2763 # The push landed on the host; guest staging is gone.
2764 LET $b: STRING = READ back.txt
2765 ASSERT_EQ $b "returned"
2766 "#},
2767 },
2768 Example {
2769 name: "guest workspace selection stays on the guest",
2770 fence_meta: Some("mock_remote:prod"),
2771 code: indoc! {r#"
2772 # WORKSPACE switches inside the block resolve against the
2773 # guest staging dir, which serves as both the SNAPSHOT
2774 # root and the LOCAL build context. The host selection
2775 # restores when the block exits.
2776 REMOTE prod {
2777 WORKSPACE SNAPSHOT
2778 WRITE out.txt "from-snapshot"
2779 COPY --to-host ./out.txt snap.txt
2780 }
2781
2782 LET $s: STRING = READ snap.txt
2783 ASSERT_EQ $s "from-snapshot"
2784 "#},
2785 },
2786 Example {
2787 name: "guest cache persists across execs",
2788 fence_meta: Some("mock_remote:prod"),
2789 code: indoc! {r#"
2790 # WORKSPACE CACHE addresses guest-local persistent
2791 # storage: the second block reads what the first wrote,
2792 # while the guest staging starts empty every time.
2793 REMOTE prod {
2794 WORKSPACE CACHE
2795 WRITE marker.txt "run-marker"
2796 }
2797 REMOTE prod {
2798 WORKSPACE CACHE
2799 LET $m: STRING = READ marker.txt
2800 ASSERT_EQ $m "run-marker"
2801 }
2802 "#},
2803 },
2804 Example {
2805 name: "pushes outside staging fail closed",
2806 fence_meta: Some("mock_remote:prod expect_error:\"escapes staging\""),
2807 code: indoc! {r#"
2808 # WORKSPACE SYSTEM exposes the whole guest machine, but
2809 # transfers stay staging scoped: a push source outside
2810 # staging fails loudly instead of crossing.
2811 REMOTE prod {
2812 WORKSPACE SYSTEM
2813 COPY --to-host /no/such/file.txt got.txt
2814 }
2815 "#},
2816 },
2817 Example {
2818 name: "guest exit fails the step, host survives",
2819 fence_meta: Some(
2820 "mock_remote:prod expect_error:\"REMOTE 'prod' exited with code 3\"",
2821 ),
2822 code: indoc! {r#"
2823 # EXIT in the body exits the guest with code 3. The
2824 # host is unaffected: it keeps executing after the
2825 # guest is already dead, and the failure surfaces as
2826 # a host step error carrying the same code. The CLI
2827 # then exits with it, so the guest code reaches the OS.
2828 LET $t: HANDLE = ASYNC REMOTE prod {
2829 EXIT 3
2830 }
2831 WRITE host-alive.txt "still here"
2832 AWAIT $t
2833 "#},
2834 },
2835 Example {
2836 name: "remote block without a runner",
2837 fence_meta: Some("expect_error:\"has no registered session\""),
2838 code: indoc! {r#"
2839 # Without a bound session, the block bails instead of
2840 # executing anywhere unexpected.
2841 REMOTE prod {
2842 ECHO "do something on remote"
2843 }
2844 "#},
2845 },
2846 ],
2847 },
2848 CommandMeta {
2849 name: "FUNC",
2850 syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
2851 summary: "Define a user function.",
2852 description: indoc! {r#"
2853 Defines a user function with UPPERCASE name and explicitly typed
2854 parameters.
2855
2856 Params bind by position, converting each argument to its
2857 declared parameter type before the body runs.
2858 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
2859 FUNC definition is scoped to its block and reverts on exit. Names share
2860 one namespace with native and host-registered functions, which a FUNC
2861 may never shadow.
2862
2863 Functions resolve like variables: a name is visible from its
2864 definition line, so recursion works but mutual recursion does
2865 not (the second name does not exist while the first body
2866 lowers). Calls name their module (`STD::GLOB(...)`) unless
2867 imported; see IMPORT.
2868
2869 Invoke any function with one syntax: `NAME(...)` as a statement
2870 (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
2871 the RETURN value (fallthrough without RETURN captures as "").
2872 "#},
2873 args: &[],
2874 flags: &[],
2875 default_output: None,
2876 examples: &[
2877 Example {
2878 name: "func def call",
2879 fence_meta: None,
2880 code: indoc! {r#"
2881 FUNC GREET($name: STRING) {
2882 RETURN $name
2883 }
2884
2885 LET $res: STRING = GREET("ada")
2886 ASSERT_EQ $res "ada"
2887
2888 # Statement form: parens stay, the value drops.
2889 GREET("bex")
2890 "#},
2891 },
2892 Example {
2893 name: "call with pipes",
2894 fence_meta: None,
2895 code: indoc! {r#"
2896 # A pipe handle travels into a function as a typed argument
2897 # and is usable as a binding target in both directions.
2898 # `LET $p: PIPE` mints the handle; `$p` passes it on.
2899 FUNC DRAIN($q: PIPE) {
2900 WITH_IO [stdin=$q] READ_LINE $line
2901 RETURN $line
2902 }
2903
2904 LET $p: PIPE
2905 WITH_IO [stdout=$p] ECHO "payload"
2906
2907 LET $got: STRING = DRAIN($p)
2908 ASSERT_EQ $got "payload"
2909 "#},
2910 },
2911 ],
2912 },
2913 CommandMeta {
2914 name: "RETURN",
2915 syntax: "RETURN [<expr>]",
2916 summary: "Return a value from a function, task, or inline block.",
2917 description: indoc! {r#"
2918 Ends the nearest enclosing boundary with a value: a function
2919 call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
2920 inline `LET` block. Bare `RETURN` with no expression yields
2921 `""`.
2922
2923 Falling off the end without RETURN yields "". RETURN with no
2924 enclosing boundary (including at top level) is an error; use
2925 EXIT or ECHO there.
2926 "#},
2927 args: &[],
2928 flags: &[],
2929 default_output: None,
2930 examples: &[Example {
2931 name: "return",
2932 fence_meta: None,
2933 code: indoc! {r#"
2934 FUNC PICK($flag: BOOL) {
2935 IF $flag {
2936 RETURN "yes"
2937 }
2938 RETURN "no"
2939 }
2940
2941 LET $res: STRING = PICK(true)
2942 ASSERT_EQ $res "yes"
2943
2944 # Fallthrough without RETURN yields its own value.
2945 LET $no: STRING = PICK(false)
2946 ASSERT_EQ $no "no"
2947 "#},
2948 }],
2949 },
2950 CommandMeta {
2951 name: "WHILE",
2952 syntax: "WHILE <bool-expr> { <commands> }",
2953 summary: "Loop while a condition holds.",
2954 description: indoc! {r#"
2955 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
2956 non-Bool is a type error).
2957
2958 Each iteration runs in a fresh scope; mutate outer state with $var = ...
2959 so the next check observes it. BREAK exits the loop; CONTINUE skips to
2960 the next check.
2961 "#},
2962 args: &[],
2963 flags: &[],
2964 default_output: None,
2965 examples: &[Example {
2966 name: "while loop",
2967 fence_meta: None,
2968 code: indoc! {r#"
2969 # The condition re-evaluates every iteration: three passes, then stop.
2970 LET $n: INT = 0
2971 WHILE $n < 3 {
2972 WRITE tick.txt "{{ $n }}"
2973 $n = $n + 1
2974 }
2975
2976 ASSERT_EQ $n 3
2977 LET $tick: STRING = READ tick.txt
2978 ASSERT_EQ $tick "2"
2979 "#},
2980 }],
2981 },
2982 CommandMeta {
2983 name: "BREAK",
2984 syntax: "BREAK",
2985 summary: "Exit the innermost loop.",
2986 description: indoc! {r#"
2987 Exits the innermost enclosing FOR or WHILE loop.
2988
2989 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
2990 "#},
2991 args: &[],
2992 flags: &[],
2993 default_output: None,
2994 examples: &[Example {
2995 name: "break",
2996 fence_meta: None,
2997 code: indoc! {r#"
2998 # BREAK leaves after the first pass: only "a" is written.
2999 FOR $x: STRING IN ["a", "b"] {
3000 WRITE picked.txt "{{ $x }}"
3001 BREAK
3002 }
3003
3004 LET $body: STRING = READ picked.txt
3005 ASSERT_EQ $body "a"
3006 "#},
3007 }],
3008 },
3009 CommandMeta {
3010 name: "CONTINUE",
3011 syntax: "CONTINUE",
3012 summary: "Skip to the next loop iteration.",
3013 description: indoc! {r#"
3014 Skips the rest of the innermost enclosing FOR or WHILE body and starts
3015 the next iteration.
3016
3017 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
3018 "#},
3019 args: &[],
3020 flags: &[],
3021 default_output: None,
3022 examples: &[Example {
3023 name: "continue",
3024 fence_meta: None,
3025 code: indoc! {r#"
3026 # CONTINUE skips the write on "a": only "b" lands.
3027 FOR $x: STRING IN ["a", "b"] {
3028 IF $x == "a" {
3029 CONTINUE
3030 }
3031 WRITE picked.txt "{{ $x }}"
3032 }
3033
3034 LET $body: STRING = READ picked.txt
3035 ASSERT_EQ $body "b"
3036 "#},
3037 }],
3038 },
3039 CommandMeta {
3040 name: KEYWORD_IMPORT,
3041 syntax: "IMPORT [<module>, ...] | IMPORT <module>",
3042 summary: "Bring module functions into bare-call scope.",
3043 description: indoc! {r#"
3044 Every function call names its module (`STD::GLOB(...)`,
3045 `MOCK::READ_CSV(...)`) unless the module is imported:
3046 `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
3047 bare. Calls resolve at parse time against `SCRIPT`
3048 definitions first, then imported modules; unknown modules,
3049 unknown functions, and unimported bare calls are parse
3050 errors, never runtime surprises.
3051
3052 IMPORT is a lowering directive, not a step: it applies from
3053 its line to the enclosing block exit, then reverts, exactly
3054 like `LET` scoping but with no runtime footprint. Guards do
3055 not apply to it. Two imported modules exporting one name is
3056 an ambiguity error: qualify the call instead.
3057
3058 `EXPORT` is reserved for future script-module support and
3059 cannot be used yet.
3060 "#},
3061 args: &[],
3062 flags: &[],
3063 default_output: None,
3064 examples: &[Example {
3065 name: "import",
3066 fence_meta: None,
3067 code: indoc! {r#"
3068 # Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
3069 WRITE a.txt "hi \{{ env:WHO }}!"
3070 IMPORT [STD]
3071 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
3072 ASSERT_CONTAINS stdout "hi World!"
3073 "#},
3074 }],
3075 },
3076 ]
3077}
3078
3079impl fmt::Display for StepKind {
3082 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3083 match self {
3084 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
3085 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
3086 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
3087 StepKind::Env { key, value } => {
3088 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
3089 }
3090 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
3091 StepKind::RunExec { argv } => {
3092 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
3093 write!(f, "RUN [{}]", parts.join(", "))
3094 }
3095 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
3096 StepKind::Copy {
3097 from_workspace,
3098 from_host,
3099 to_host,
3100 from,
3101 to,
3102 } => {
3103 let mut head = String::from("COPY");
3104 if let Some(target) = from_workspace {
3105 head.push_str(&format!(" --from-workspace {target}"));
3106 }
3107 if *from_host {
3108 head.push_str(" --from-host");
3109 }
3110 if *to_host {
3111 head.push_str(" --to-host");
3112 }
3113 write!(
3114 f,
3115 "{head} {} {}",
3116 fmt_value(from, quote_arg),
3117 fmt_value(to, quote_arg)
3118 )
3119 }
3120 StepKind::Symlink {
3121 from_workspace,
3122 from,
3123 to,
3124 } => {
3125 if let Some(target) = from_workspace {
3126 write!(
3127 f,
3128 "SYMLINK --from-workspace {} {} {}",
3129 target,
3130 fmt_value(from, quote_arg),
3131 fmt_value(to, quote_arg)
3132 )
3133 } else {
3134 write!(
3135 f,
3136 "SYMLINK {} {}",
3137 fmt_value(from, quote_arg),
3138 fmt_value(to, quote_arg)
3139 )
3140 }
3141 }
3142 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
3143 StepKind::Install { from, to } => write!(
3144 f,
3145 "INSTALL {} {}",
3146 fmt_value(from, quote_arg),
3147 fmt_value(to, quote_arg)
3148 ),
3149 StepKind::Ls(a) => {
3150 write!(f, "LS")?;
3151 if let Some(x) = a {
3152 write!(f, " {}", fmt_value(x, quote_arg))?;
3153 }
3154 Ok(())
3155 }
3156 StepKind::Cwd => write!(f, "CWD"),
3157 StepKind::Read(a) => {
3158 write!(f, "READ")?;
3159 if let Some(x) = a {
3160 write!(f, " {}", fmt_value(x, quote_arg))?;
3161 }
3162 Ok(())
3163 }
3164 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
3165 StepKind::Prompt {
3166 var,
3167 message,
3168 default,
3169 } => {
3170 write!(f, "PROMPT ${} {}", var, fmt_value(message, quote_arg))?;
3171 if let Some(d) = default {
3172 write!(f, " {}", fmt_value(d, quote_arg))?;
3173 }
3174 Ok(())
3175 }
3176 StepKind::Write { path, contents } => {
3177 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
3178 if let Some(b) = contents {
3179 write!(f, " {}", fmt_value(b, quote_msg))?;
3180 }
3181 Ok(())
3182 }
3183 StepKind::Append { path, contents } => {
3184 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
3185 if let Some(b) = contents {
3186 write!(f, " {}", fmt_value(b, quote_msg))?;
3187 }
3188 Ok(())
3189 }
3190 StepKind::Remove { path, recursive } => {
3191 if *recursive {
3192 write!(f, "REMOVE --recursive {}", fmt_value(path, quote_arg))
3193 } else {
3194 write!(f, "REMOVE {}", fmt_value(path, quote_arg))
3195 }
3196 }
3197 StepKind::Expand { path, overrides } => {
3198 write!(f, "EXPAND")?;
3199 if let Some(p) = path {
3200 write!(f, " {}", fmt_value(p, quote_arg))?;
3201 }
3202 for (k, v) in overrides {
3203 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
3204 }
3205 Ok(())
3206 }
3207 StepKind::AssertEq {
3208 hash,
3209 actual,
3210 expected,
3211 } => {
3212 if let Some(d) = hash {
3213 write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
3214 } else {
3215 write!(
3216 f,
3217 "ASSERT_EQ {} {}",
3218 fmt_assert_target(actual),
3219 fmt_value(
3220 expected
3221 .as_ref()
3222 .expect("Display of ASSERT_EQ without --hash needs expected"),
3223 quote_msg
3224 )
3225 )?;
3226 }
3227 Ok(())
3228 }
3229 StepKind::AssertContains { haystack, needle } => write!(
3230 f,
3231 "ASSERT_CONTAINS {} {}",
3232 fmt_assert_target(haystack),
3233 fmt_value(needle, quote_msg)
3234 ),
3235 StepKind::WithIo { bindings, cmd } => {
3236 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3237 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
3238 }
3239 StepKind::WithIoBlock { bindings } => {
3240 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3241 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
3242 }
3243 StepKind::CopyGit {
3244 rev,
3245 from,
3246 to,
3247 include_dirty,
3248 } => {
3249 if *include_dirty {
3250 write!(
3251 f,
3252 "COPY_GIT --include-dirty {} {} {}",
3253 fmt_value(rev, quote_arg),
3254 fmt_value(from, quote_arg),
3255 fmt_value(to, quote_arg)
3256 )
3257 } else {
3258 write!(
3259 f,
3260 "COPY_GIT {} {} {}",
3261 fmt_value(rev, quote_arg),
3262 fmt_value(from, quote_arg),
3263 fmt_value(to, quote_arg)
3264 )
3265 }
3266 }
3267 StepKind::HashSha256 { path } => {
3268 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
3269 }
3270 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
3271 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
3272 StepKind::ListAppend { list, item } => {
3273 write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
3274 }
3275 StepKind::For {
3276 key_var,
3277 key_type,
3278 var,
3279 var_type,
3280 in_expr,
3281 body,
3282 } => {
3283 match key_var {
3284 Some(k) => {
3285 let kt = key_type.as_deref().unwrap_or("STRING");
3286 write!(
3287 f,
3288 "FOR ${}: {}, ${}: {} IN {} {{",
3289 k, kt, var, var_type, in_expr
3290 )?
3291 }
3292 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
3293 }
3294 for s in body {
3295 write!(f, "\n {}", s)?;
3296 }
3297 write!(f, "\n}}")
3298 }
3299 StepKind::If {
3300 cond,
3301 then_body,
3302 else_ifs,
3303 else_body,
3304 } => {
3305 write!(f, "IF {} {{", cond)?;
3306 for s in then_body {
3307 write!(f, "\n {}", s)?;
3308 }
3309 write!(f, " }}")?;
3310 for (c, b) in else_ifs {
3311 write!(f, " ELSE IF {} {{", c)?;
3312 for s in b {
3313 write!(f, "\n {}", s)?;
3314 }
3315 write!(f, " }}")?;
3316 }
3317 if let Some(b) = else_body {
3318 write!(f, " ELSE {{")?;
3319 for s in b {
3320 write!(f, "\n {}", s)?;
3321 }
3322 write!(f, " }}")?;
3323 }
3324 Ok(())
3325 }
3326 StepKind::Assign {
3327 var,
3328 decl_type,
3329 expr,
3330 } => {
3331 if matches!(expr, Expr::FreshPipe) {
3333 write!(f, "LET ${}: {}", var, decl_type)
3334 } else {
3335 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
3336 }
3337 }
3338 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
3339 StepKind::AssignCapture {
3340 var,
3341 decl_type,
3342 cmd,
3343 } => {
3344 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
3345 }
3346 StepKind::AsyncBlock { body } => {
3347 write!(f, "ASYNC {{")?;
3348 for s in body {
3349 write!(f, "\n {}", s)?;
3350 }
3351 write!(f, "\n}}")
3352 }
3353 StepKind::AssignAsync {
3354 var,
3355 decl_type,
3356 body,
3357 } => {
3358 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
3359 for s in body {
3360 write!(f, "\n {}", s)?;
3361 }
3362 write!(f, "\n}}")
3363 }
3364 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
3365 StepKind::AwaitCapture {
3366 out_var,
3367 out_type,
3368 task_var,
3369 } => {
3370 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
3371 }
3372 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
3373 StepKind::Timeout { duration, body } => {
3374 let budget = fmt_raw_arg(duration);
3375 if body.len() == 1 {
3376 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
3377 } else {
3378 write!(f, "TIMEOUT {} {{", budget)?;
3379 for s in body {
3380 write!(f, "\n {}", s)?;
3381 }
3382 write!(f, "\n}}")
3383 }
3384 }
3385 StepKind::RemoteBlock {
3386 target,
3387 vars,
3388 env,
3389 body,
3390 } => {
3391 let mut entries: Vec<String> = vars.iter().map(|v| format!("${v}")).collect();
3392 entries.extend(env.iter().map(|e| format!("env:{e}")));
3393 if entries.is_empty() {
3394 write!(f, "REMOTE {target} {{")?;
3395 } else {
3396 write!(f, "REMOTE {target} [{}] {{", entries.join(", "))?;
3397 }
3398 for s in body {
3399 write!(f, "\n {}", s)?;
3400 }
3401 write!(f, "\n}}")
3402 }
3403 StepKind::FuncDef { name, params, body } => {
3404 let ps: Vec<String> = params
3405 .iter()
3406 .map(|(p, t)| format!("${}: {}", p, t))
3407 .collect();
3408 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
3409 for s in body {
3410 write!(f, "\n {}", s)?;
3411 }
3412 write!(f, "\n}}")
3413 }
3414 StepKind::Call { name, args } => {
3415 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
3416 write!(f, "{}({})", name, ps.join(", "))
3417 }
3418 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
3419 StepKind::TypeAlias { name, target } => write!(f, "TYPE {} = {}", name, target),
3420 StepKind::While { cond, body } => {
3421 write!(f, "WHILE {} {{", cond)?;
3422 for s in body {
3423 write!(f, "\n {}", s)?;
3424 }
3425 write!(f, "\n}}")
3426 }
3427 StepKind::Break => write!(f, "BREAK"),
3428 StepKind::Continue => write!(f, "CONTINUE"),
3429 }
3430 }
3431}
3432
3433#[cfg(test)]
3434mod tests {
3435 use super::*;
3436 use crate::command::{format_duration, parse_duration};
3437 use crate::parser::parse_script;
3438
3439 fn parse_err(script: &str) -> String {
3440 parse_script(script, lower_command)
3441 .expect_err("script must fail to parse")
3442 .to_string()
3443 }
3444
3445 #[test]
3446 fn malformed_with_io_binding_names_the_bad_binding() {
3447 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
3448 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
3449 assert!(!err.contains("unknown command"), "{err}");
3450 assert!(err.contains("stdout=discard"), "{err}");
3451 assert!(err.contains("[stdout=$p]"), "{err}");
3452 }
3453
3454 #[test]
3455 fn connect_is_unknown_command() {
3456 let err = parse_err("CONNECT 127.0.0.1:8080\n");
3459 assert!(err.contains("unknown command"), "{err}");
3460 assert!(err.contains("CONNECT"), "{err}");
3461 }
3462
3463 #[test]
3464 fn listen_is_unknown_command() {
3465 let err = parse_err("LISTEN 127.0.0.1:8080\n");
3468 assert!(err.contains("unknown command"), "{err}");
3469 assert!(err.contains("LISTEN"), "{err}");
3470 }
3471
3472 #[test]
3473 fn await_without_task_variable_points_at_syntax() {
3474 let err = parse_err("AWAIT ECHO \"test\"\n");
3475 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
3476 assert!(!err.contains("unknown command"), "{err}");
3477 assert!(err.contains("AWAIT $t"), "{err}");
3478 assert!(err.contains("ECHO"), "{err}");
3479 }
3480
3481 #[test]
3482 fn bare_let_without_type_points_at_typed_syntax() {
3483 let err = parse_err("LET $x = 1\n");
3484 assert!(err.contains("invalid syntax for command LET"), "{err}");
3485 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
3486 }
3487
3488 #[test]
3489 fn workspace_accepts_all_four_targets_uppercase_only() {
3490 for (spelling, target) in [
3493 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3494 ("LOCAL", WorkspaceTarget::Local),
3495 ("CACHE", WorkspaceTarget::Cache { local: false }),
3496 ("SYSTEM", WorkspaceTarget::System),
3497 ] {
3498 let steps =
3499 parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
3500 assert_eq!(steps.len(), 1);
3501 assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
3502 assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
3503 }
3504 for spelling in ["snapshot", "local", "cache", "system"] {
3505 let err = parse_err(&format!("WORKSPACE {spelling}\n"));
3506 assert!(
3507 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3508 "{spelling}: {err}"
3509 );
3510 }
3511 let err = parse_err("WORKSPACE REMOTE\n");
3512 assert!(
3513 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3514 "{err}"
3515 );
3516
3517 let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
3520 assert_eq!(
3521 steps[0].kind,
3522 StepKind::Workspace(WorkspaceTarget::Cache { local: true })
3523 );
3524 assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
3525 for bad in [
3526 "WORKSPACE SNAPSHOT --local\n",
3527 "WORKSPACE LOCAL --local\n",
3528 "WORKSPACE SYSTEM --local\n",
3529 ] {
3530 let err = parse_err(bad);
3531 assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
3532 }
3533 }
3534
3535 #[test]
3536 fn copy_from_workspace_selects_source_root() {
3537 for (spelling, target) in [
3538 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3539 ("LOCAL", WorkspaceTarget::Local),
3540 ("CACHE", WorkspaceTarget::Cache { local: false }),
3541 ("SYSTEM", WorkspaceTarget::System),
3542 ] {
3543 let steps = parse_script(
3544 &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
3545 lower_command,
3546 )
3547 .expect("parses");
3548 assert!(
3549 matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
3550 "unexpected lowering for {spelling}: {:?}",
3551 steps[0].kind
3552 );
3553 }
3554 for script in [
3557 "COPY --from-workspace=CACHE a.txt b.txt\n",
3558 "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
3559 ] {
3560 let steps = parse_script(script, lower_command).expect("parses");
3561 assert!(
3562 matches!(
3563 &steps[0].kind,
3564 StepKind::Copy {
3565 from_workspace: Some(WorkspaceTarget::Cache { local: false }),
3566 ..
3567 }
3568 ),
3569 "unexpected lowering for {script:?}: {:?}",
3570 steps[0].kind
3571 );
3572 }
3573 let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
3575 assert!(
3576 matches!(
3577 &steps[0].kind,
3578 StepKind::Copy {
3579 from_workspace: None,
3580 ..
3581 }
3582 ),
3583 "unexpected lowering: {:?}",
3584 steps[0].kind
3585 );
3586 for bad in ["REMOTE", "local"] {
3588 let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
3589 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3590 }
3591 }
3592
3593 #[test]
3594 fn symlink_from_workspace_selects_source_root() {
3595 for (spelling, target) in [
3596 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3597 ("LOCAL", WorkspaceTarget::Local),
3598 ("CACHE", WorkspaceTarget::Cache { local: false }),
3599 ("SYSTEM", WorkspaceTarget::System),
3600 ] {
3601 let steps = parse_script(
3602 &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
3603 lower_command,
3604 )
3605 .expect("parses");
3606 assert!(
3607 matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
3608 "unexpected lowering for {spelling}: {:?}",
3609 steps[0].kind
3610 );
3611 let roundtrip = steps[0].kind.to_string();
3612 assert!(
3613 roundtrip.contains("--from-workspace"),
3614 "display should round-trip the flag: {roundtrip}"
3615 );
3616 }
3617 let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
3618 assert!(
3619 matches!(
3620 &steps[0].kind,
3621 StepKind::Symlink {
3622 from_workspace: None,
3623 ..
3624 }
3625 ),
3626 "unexpected lowering: {:?}",
3627 steps[0].kind
3628 );
3629 for bad in ["REMOTE", "local"] {
3630 let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
3631 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3632 }
3633 let steps = parse_script(
3635 "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
3636 lower_command,
3637 )
3638 .expect("parses");
3639 assert!(
3640 matches!(
3641 &steps[0].kind,
3642 StepKind::Symlink {
3643 from_workspace: Some(WorkspaceTarget::Local),
3644 ..
3645 }
3646 ),
3647 "unexpected lowering: {:?}",
3648 steps[0].kind
3649 );
3650 }
3651
3652 #[test]
3653 fn dash_dash_equals_tokens_bypass_assignment() {
3654 let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
3657 let StepKind::Env { key, value } = &steps[0].kind else {
3658 panic!("expected Env, got {:?}", steps[0].kind);
3659 };
3660 assert_eq!(key, "--foo");
3661 assert_eq!(value.as_str(), "bar");
3662
3663 let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
3664 let StepKind::Run(cmd) = &steps[0].kind else {
3665 panic!("expected Run, got {:?}", steps[0].kind);
3666 };
3667 assert!(
3668 cmd.as_str().contains("--foo=bar"),
3669 "unexpected RUN lowering: {cmd:?}"
3670 );
3671
3672 let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
3673 let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
3674 .expect("parses");
3675 let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
3676 panic!("expected AssertEq, got {:?}", steps[0].kind);
3677 };
3678 assert_eq!(hash.as_deref(), Some(digest));
3679
3680 let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
3682 let StepKind::Expand { path, overrides } = &steps[0].kind else {
3683 panic!("expected Expand, got {:?}", steps[0].kind);
3684 };
3685 assert!(path.is_none());
3686 assert_eq!(overrides.len(), 1);
3687 assert_eq!(overrides[0].0.as_str(), "--k");
3688 }
3689
3690 #[test]
3691 fn space_before_paren_is_not_a_call() {
3692 let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
3695 assert_eq!(steps.len(), 1);
3696 assert!(
3697 !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
3698 "space before paren must not route to a call: {:?}",
3699 steps[0].kind
3700 );
3701 }
3702
3703 #[test]
3704 fn unknown_type_tag_parses_as_custom() {
3705 let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
3709 let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
3710 panic!("expected Assign, got {:?}", steps[0].kind);
3711 };
3712 assert_eq!(decl_type, "FOO");
3713 }
3714
3715 #[test]
3716 fn bare_for_without_types_is_rejected() {
3717 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
3718 assert!(err.contains("FOR requires explicit types"), "{err}");
3719 }
3720
3721 #[test]
3722 fn mutate_statement_parses_without_keyword() {
3723 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
3724 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
3725 }
3726
3727 #[test]
3728 fn set_keyword_is_rejected_with_mutation_hint() {
3729 let err = parse_err("SET $y = 2\n");
3730 assert!(err.contains("not a keyword"), "{err}");
3731 assert!(err.contains("$var = <expr>"), "{err}");
3732 }
3733
3734 #[test]
3735 fn structural_fallthrough_commits_per_keyword() {
3736 for (script, cmd) in [
3737 ("CANCEL foo\n", "CANCEL"),
3738 ("TIMEOUT foo\n", "TIMEOUT"),
3739 ("FOR foo\n", "FOR"),
3740 ("IF foo\n", "IF"),
3741 ("LET foo\n", "LET"),
3742 ("ASYNC\n", "ASYNC"),
3746 ("ELSE foo\n", "ELSE"),
3747 ] {
3748 let err = parse_err(script);
3749 assert!(
3750 err.contains(&format!("invalid syntax for command {cmd}")),
3751 "{cmd}: {err}"
3752 );
3753 assert!(!err.contains("unknown command"), "{cmd}: {err}");
3754 }
3755 }
3756
3757 #[test]
3758 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
3759 let err = parse_err("SLEEP 1s 2s\n");
3760 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
3761 assert!(!err.contains("unknown command"), "{err}");
3762 }
3763
3764 #[test]
3765 fn list_append_lowers_variable_and_item() {
3766 let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
3767 let StepKind::ListAppend { list, item } = &steps[0].kind else {
3768 panic!("expected ListAppend, got {:?}", steps[0].kind);
3769 };
3770 assert_eq!(list, "items");
3771 assert!(matches!(item, Arg::String(s, _) if s == "hi"));
3772 assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
3773 }
3774
3775 #[test]
3776 fn list_append_rejects_wrong_arity() {
3777 for script in [
3778 "LIST_APPEND\n",
3779 "LIST_APPEND $items\n",
3780 "LIST_APPEND $items \"a\" \"b\"\n",
3781 ] {
3782 let err = parse_err(script);
3783 assert!(
3784 err.contains("invalid syntax for command LIST_APPEND"),
3785 "{script}: {err}"
3786 );
3787 assert!(!err.contains("unknown command"), "{script}: {err}");
3788 }
3789 }
3790
3791 #[test]
3792 fn list_append_rejects_non_variable_target() {
3793 let err = parse_err("LIST_APPEND items \"a\"\n");
3794 assert!(
3795 err.contains("invalid syntax for command LIST_APPEND"),
3796 "{err}"
3797 );
3798 assert!(!err.contains("unknown command"), "{err}");
3799 }
3800
3801 #[test]
3802 fn read_line_rejects_bare_word_target() {
3803 let err = parse_err("READ_LINE reply\n");
3807 assert!(err.contains("READ_LINE requires a $variable"), "{err}");
3808 assert!(!err.contains("unknown command"), "{err}");
3809 }
3810
3811 #[test]
3812 fn genuinely_unknown_command_keeps_bare_message() {
3813 let err = parse_err("FROBNICATE hi\n");
3814 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
3815 assert!(!err.contains("did you mean"), "{err}");
3816 }
3817
3818 #[test]
3819 fn lowercase_command_suggests_uppercase() {
3820 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
3824 .expect_err("must fail")
3825 .to_string();
3826 assert!(err.contains("unknown command: echo"), "{err}");
3827 assert!(err.contains("did you mean `ECHO`"), "{err}");
3828 }
3829
3830 #[test]
3831 fn func_def_requires_typed_uppercase_name() {
3832 let steps = parse_script(
3833 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
3834 lower_command,
3835 )
3836 .expect("func def parses");
3837 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
3838 panic!("expected FuncDef, got {:?}", steps[0].kind);
3839 };
3840 assert_eq!(name, "GREET");
3841 assert_eq!(
3842 params,
3843 &vec![("name".to_string(), "STRING".to_string())],
3844 "{params:?}"
3845 );
3846 assert!(matches!(body[0].kind, StepKind::Return { .. }));
3847 }
3848
3849 #[test]
3850 fn lowercase_func_name_is_rejected() {
3851 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
3852 assert!(err.contains("FUNC"), "{err}");
3853 }
3854
3855 #[test]
3856 fn call_and_while_lower_correctly() {
3857 let steps = parse_script(
3858 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nGREET(\"ada\")\n",
3859 lower_command,
3860 )
3861 .expect("call parses");
3862 assert!(
3863 matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
3864 "{:?}",
3865 steps[1].kind
3866 );
3867 let steps = parse_script(
3868 "FUNC GREET($a: STRING, $b: STRING) {\n RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
3869 lower_command,
3870 )
3871 .expect("spaced call parses");
3872 assert!(
3873 matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
3874 "{:?}",
3875 steps[1].kind
3876 );
3877 let steps = parse_script(
3878 indoc! {r#"
3879 WHILE !$done {
3880 BREAK
3881 }
3882 "#},
3883 lower_command,
3884 )
3885 .expect("while parses");
3886 let StepKind::While { body, .. } = &steps[0].kind else {
3887 panic!("expected While, got {:?}", steps[0].kind);
3888 };
3889 assert!(matches!(body[0].kind, StepKind::Break));
3890 }
3891
3892 #[test]
3893 fn let_capture_call_and_async_call_lower() {
3894 let steps = parse_script(
3897 indoc! {r#"
3898 FUNC GREET($name: STRING) {
3899 RETURN $name
3900 }
3901 LET $r: STRING = GREET("ada")
3902 "#},
3903 lower_command,
3904 )
3905 .expect("capture call parses");
3906 let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
3907 panic!("expected Assign, got {:?}", steps[1].kind);
3908 };
3909 assert_eq!(var, "r");
3910 assert!(
3911 matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
3912 "{expr:?}"
3913 );
3914 let steps = parse_script(
3915 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
3916 lower_command,
3917 )
3918 .expect("async call parses");
3919 assert!(
3920 matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
3921 "{:?}",
3922 steps[1].kind
3923 );
3924 }
3925
3926 #[test]
3927 fn multiline_call_args_span_lines() {
3928 let steps = parse_script(
3932 "FUNC SERVE($b: STRING, $u: STRING, $p: STRING, $o: MAP) {\n RETURN $b\n}\nLET $m: MAP = SERVE(\n \"127.0.0.1:2241\",\n \"test\",\n \"test123\", {\n key_path: \"test_key\"\n }\n)\n",
3933 lower_command,
3934 )
3935 .expect("multiline call parses");
3936 let StepKind::Assign { expr, .. } = &steps[1].kind else {
3937 panic!("expected Assign, got {:?}", steps[1].kind);
3938 };
3939 let Expr::Call { name, args } = expr else {
3940 panic!("expected Call expr, got {expr:?}");
3941 };
3942 assert_eq!(name, "SCRIPT::SERVE");
3943 assert_eq!(args.len(), 4);
3944 assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
3945 let rendered = steps[1].to_string();
3947 assert!(!rendered.contains('\n'), "{rendered}");
3948 let script = "FUNC SERVE($b: STRING, $u: STRING, $p: STRING, $o: MAP) {\n RETURN $b\n}\nLET $m: MAP = SERVE(\n \"127.0.0.1:2241\",\n \"test\",\n \"test123\", {\n key_path: \"test_key\"\n }\n)\n";
3949 let again = parse_script(script, lower_command).expect("reparse ok");
3950 assert_eq!(again, steps);
3951 }
3952
3953 #[test]
3954 fn multiline_bare_call_and_list_span_lines() {
3955 let steps = parse_script(
3956 indoc! {r#"
3957 FUNC GREET($a: STRING) {
3958 RETURN $a
3959 }
3960 GREET(
3961 "ada"
3962 )
3963 "#},
3964 lower_command,
3965 )
3966 .expect("multiline bare call parses");
3967 let StepKind::Call { name, args } = &steps[1].kind else {
3968 panic!("expected Call, got {:?}", steps[1].kind);
3969 };
3970 assert_eq!(name, "SCRIPT::GREET");
3971 assert_eq!(args.len(), 1);
3972 let steps = parse_script(
3973 indoc! {r#"
3974 LET $l: LIST = [
3975 "a",
3976 "b"
3977 ]
3978 "#},
3979 lower_command,
3980 )
3981 .expect("multiline list parses");
3982 let StepKind::Assign { expr, .. } = &steps[0].kind else {
3983 panic!("expected Assign, got {:?}", steps[0].kind);
3984 };
3985 assert!(
3986 matches!(expr, Expr::List(items) if items.len() == 2),
3987 "{expr:?}"
3988 );
3989 }
3990
3991 #[test]
3992 fn parse_duration_units() {
3993 use std::time::Duration;
3994 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3995 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
3996 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
3997 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
3998 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3999 }
4000
4001 #[test]
4002 fn parse_duration_rejects_garbage() {
4003 assert!(parse_duration("").is_err());
4004 assert!(parse_duration("banana").is_err());
4005 assert!(parse_duration("10x").is_err());
4006 assert!(parse_duration("0s").is_err());
4007 assert!(parse_duration("0").is_err());
4008 assert!(parse_duration("-5s").is_err());
4009 }
4010
4011 #[test]
4012 fn format_duration_round_trips() {
4013 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
4014 let parsed = parse_duration(text).unwrap();
4015 let rendered = format_duration(&parsed);
4016 assert_eq!(
4017 parse_duration(&rendered).unwrap(),
4018 parsed,
4019 "round-trip failed for {text}"
4020 );
4021 }
4022 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
4023 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
4024 }
4025
4026 #[test]
4027 fn structural_metadata_covers_all_structural_kinds() {
4028 use crate::ast::Value;
4029
4030 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
4034 match kind {
4035 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
4036 StepKind::For { .. } => Some("FOR"),
4037 StepKind::If { .. } => Some("IF"),
4038 StepKind::Assign { .. } => Some("LET"),
4039 StepKind::Set { .. } => Some("MUTATION"),
4040 StepKind::AssignCapture { .. } => Some("LET"),
4041 StepKind::AwaitCapture { .. } => Some("AWAIT"),
4042 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
4043 StepKind::Await { .. } => Some("AWAIT"),
4044 StepKind::Cancel { .. } => Some("CANCEL"),
4045 StepKind::Timeout { .. } => Some("TIMEOUT"),
4046 StepKind::RemoteBlock { .. } => Some("REMOTE"),
4047 StepKind::FuncDef { .. } => Some("FUNC"),
4048 StepKind::Call { .. } => None,
4051 StepKind::Return { .. } => Some("RETURN"),
4052 StepKind::While { .. } => Some("WHILE"),
4053 StepKind::Break => Some("BREAK"),
4054 StepKind::Continue => Some("CONTINUE"),
4055 StepKind::TypeAlias { .. } => Some("LET"),
4059 StepKind::RunExec { .. } => None,
4060 StepKind::Workdir(_)
4061 | StepKind::Workspace(_)
4062 | StepKind::Env { .. }
4063 | StepKind::InheritEnv { .. }
4064 | StepKind::Run(_)
4065 | StepKind::Echo(_)
4066 | StepKind::Copy { .. }
4067 | StepKind::Symlink { .. }
4068 | StepKind::Mkdir(_)
4069 | StepKind::Install { .. }
4070 | StepKind::Ls(_)
4071 | StepKind::Cwd
4072 | StepKind::Read(_)
4073 | StepKind::ReadLine { .. }
4074 | StepKind::Prompt { .. }
4075 | StepKind::Write { .. }
4076 | StepKind::Append { .. }
4077 | StepKind::Remove { .. }
4078 | StepKind::Expand { .. }
4079 | StepKind::AssertEq { .. }
4080 | StepKind::AssertContains { .. }
4081 | StepKind::CopyGit { .. }
4082 | StepKind::HashSha256 { .. }
4083 | StepKind::Exit(_)
4084 | StepKind::Sleep { .. }
4085 | StepKind::ListAppend { .. } => None,
4086 }
4087 }
4088
4089 let dummies: Vec<StepKind> = vec![
4092 StepKind::WithIo {
4093 bindings: Vec::new(),
4094 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4095 "x".to_string(),
4096 false,
4097 ))),
4098 },
4099 StepKind::For {
4100 key_var: None,
4101 key_type: None,
4102 var: "i".to_string(),
4103 var_type: "STRING".to_string(),
4104 in_expr: Expr::Literal(Value::bool(true)),
4105 body: Vec::new(),
4106 },
4107 StepKind::If {
4108 cond: Box::new(Expr::Literal(Value::bool(true))),
4109 then_body: Vec::new(),
4110 else_ifs: Vec::new(),
4111 else_body: None,
4112 },
4113 StepKind::Assign {
4114 var: "v".to_string(),
4115 decl_type: "BOOL".to_string(),
4116 expr: Expr::Literal(Value::bool(true)),
4117 },
4118 StepKind::Set {
4119 var: "v".to_string(),
4120 expr: Expr::Literal(Value::bool(true)),
4121 },
4122 StepKind::AssignCapture {
4123 var: "v".to_string(),
4124 decl_type: "STRING".to_string(),
4125 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4126 "x".to_string(),
4127 false,
4128 ))),
4129 },
4130 StepKind::AwaitCapture {
4131 out_var: "o".to_string(),
4132 out_type: "STRING".to_string(),
4133 task_var: "t".to_string(),
4134 },
4135 StepKind::AsyncBlock { body: Vec::new() },
4136 StepKind::AssignAsync {
4137 var: "t".to_string(),
4138 decl_type: "HANDLE".to_string(),
4139 body: Vec::new(),
4140 },
4141 StepKind::Await {
4142 var: "t".to_string(),
4143 },
4144 StepKind::Cancel {
4145 var: "t".to_string(),
4146 },
4147 StepKind::Timeout {
4148 duration: Arg::String("1s".to_string(), false),
4149 body: Vec::new(),
4150 },
4151 StepKind::RemoteBlock {
4152 target: "prod".to_string(),
4153 vars: Vec::new(),
4154 env: Vec::new(),
4155 body: Vec::new(),
4156 },
4157 StepKind::FuncDef {
4158 name: "F".to_string(),
4159 params: Vec::new(),
4160 body: Vec::new(),
4161 },
4162 StepKind::Call {
4163 name: "F".to_string(),
4164 args: Vec::new(),
4165 },
4166 StepKind::Return {
4167 expr: Box::new(Expr::Literal(Value::bool(true))),
4168 },
4169 StepKind::While {
4170 cond: Box::new(Expr::Literal(Value::bool(true))),
4171 body: Vec::new(),
4172 },
4173 StepKind::Break,
4174 StepKind::Continue,
4175 StepKind::TypeAlias {
4176 name: "PERSON".to_string(),
4177 target: "MAP<name: STRING>".to_string(),
4178 },
4179 ];
4180 let registry = all_structural_metadata();
4181 for kind in &dummies {
4182 let Some(name) = metadata_name(kind) else {
4184 continue;
4185 };
4186 assert!(
4187 registry.iter().any(|meta| meta.name == name),
4188 "no structural metadata entry for {}",
4189 name
4190 );
4191 }
4192 }
4193}