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::Archive {
596 src: from, dst: to, ..
597 }
598 | StepKind::Install { from, to } => {
599 walk_arg(from, f);
600 walk_arg(to, f);
601 }
602 StepKind::Unarchive {
603 src,
604 dst,
605 max_bytes,
606 } => {
607 walk_arg(src, f);
608 walk_arg(dst, f);
609 walk_opt(max_bytes, f);
610 }
611 StepKind::CopyGit { rev, from, to, .. } => {
612 walk_arg(rev, f);
613 walk_arg(from, f);
614 walk_arg(to, f);
615 }
616 StepKind::Ls(arg) | StepKind::Read(arg) => walk_opt(arg, f),
617 StepKind::Remove { path, .. } => walk_arg(path, f),
618 StepKind::Prompt {
619 message, default, ..
620 } => {
621 walk_arg(message, f);
622 walk_opt(default, f);
623 }
624 StepKind::Write { path, contents } | StepKind::Append { path, contents } => {
625 walk_arg(path, f);
626 walk_opt(contents, f);
627 }
628 StepKind::Expand { path, overrides } => {
629 walk_opt(path, f);
630 for (_, value) in overrides {
631 walk_arg(value, f);
632 }
633 }
634 StepKind::AssertEq {
635 actual, expected, ..
636 } => {
637 walk_target(actual, f);
638 walk_opt(expected, f);
639 }
640 StepKind::AssertContains { haystack, needle } => {
641 walk_target(haystack, f);
642 walk_arg(needle, f);
643 }
644 StepKind::ListAppend { item, .. } => walk_arg(item, f),
645 StepKind::RunExec { argv } => {
646 for arg in argv {
647 walk_arg(arg, f);
648 }
649 }
650 StepKind::WithIo { .. }
651 | StepKind::WithIoBlock { .. }
652 | StepKind::TypeAlias { .. }
653 | StepKind::Workspace(_)
654 | StepKind::InheritEnv { .. }
655 | StepKind::Cwd
656 | StepKind::ReadLine { .. }
657 | StepKind::AssignCapture { .. }
658 | StepKind::AwaitCapture { .. }
659 | StepKind::AsyncBlock { .. }
660 | StepKind::AssignAsync { .. }
661 | StepKind::Await { .. }
662 | StepKind::Cancel { .. }
663 | StepKind::RemoteBlock { .. }
664 | StepKind::FuncDef { .. }
665 | StepKind::Break
666 | StepKind::Continue => {}
667 }
668 }
669
670 pub fn walk_child_kinds(&self, f: &mut impl FnMut(&StepKind)) {
674 fn walk_body(body: &[Step], f: &mut impl FnMut(&StepKind)) {
675 for step in body {
676 f(&step.kind);
677 }
678 }
679 match self {
680 StepKind::WithIo { cmd, .. } => f(cmd),
681 StepKind::AssignCapture { cmd, .. } => f(cmd),
682 StepKind::For { body, .. }
683 | StepKind::While { body, .. }
684 | StepKind::FuncDef { body, .. }
685 | StepKind::Timeout { body, .. }
686 | StepKind::AssignAsync { body, .. }
687 | StepKind::AsyncBlock { body }
688 | StepKind::RemoteBlock { body, .. } => walk_body(body, f),
689 StepKind::If {
690 then_body,
691 else_ifs,
692 else_body,
693 ..
694 } => {
695 walk_body(then_body, f);
696 for (_, body) in else_ifs {
697 walk_body(body, f);
698 }
699 if let Some(body) = else_body {
700 walk_body(body, f);
701 }
702 }
703 StepKind::InheritEnv { .. }
704 | StepKind::TypeAlias { .. }
705 | StepKind::Workdir(_)
706 | StepKind::Workspace(_)
707 | StepKind::Env { .. }
708 | StepKind::Echo(_)
709 | StepKind::Run(_)
710 | StepKind::RunExec { .. }
711 | StepKind::Copy { .. }
712 | StepKind::CopyGit { .. }
713 | StepKind::Symlink { .. }
714 | StepKind::Archive { .. }
715 | StepKind::Unarchive { .. }
716 | StepKind::Install { .. }
717 | StepKind::Mkdir(_)
718 | StepKind::Ls(_)
719 | StepKind::Cwd
720 | StepKind::Read(_)
721 | StepKind::ReadLine { .. }
722 | StepKind::Prompt { .. }
723 | StepKind::Write { .. }
724 | StepKind::Append { .. }
725 | StepKind::Remove { .. }
726 | StepKind::Expand { .. }
727 | StepKind::AssertEq { .. }
728 | StepKind::AssertContains { .. }
729 | StepKind::HashSha256 { .. }
730 | StepKind::Exit(_)
731 | StepKind::WithIoBlock { .. }
732 | StepKind::Assign { .. }
733 | StepKind::Set { .. }
734 | StepKind::AwaitCapture { .. }
735 | StepKind::Await { .. }
736 | StepKind::Cancel { .. }
737 | StepKind::Sleep { .. }
738 | StepKind::ListAppend { .. }
739 | StepKind::Call { .. }
740 | StepKind::Return { .. }
741 | StepKind::Break
742 | StepKind::Continue => {}
743 }
744 }
745
746 pub fn walk_step_exprs(&self, f: &mut impl FnMut(&Expr)) {
751 self.walk_exprs(&mut |expr| expr.walk(f));
752 self.walk_child_kinds(&mut |kind| kind.walk_step_exprs(f));
753 }
754}
755
756#[derive(Debug, Clone, PartialEq)]
765pub enum AssertTarget {
766 Value(Arg),
767 Stdout,
768 Stderr,
769}
770
771impl AssertTarget {
772 pub fn render(&self) -> String {
773 match self {
774 AssertTarget::Value(arg) => arg.render(),
775 AssertTarget::Stdout => "stdout".to_string(),
776 AssertTarget::Stderr => "stderr".to_string(),
777 }
778 }
779}
780
781fn lower_assert_target(arg: Arg) -> ParseResult<AssertTarget> {
790 match arg {
791 Arg::Expr(_) => Ok(AssertTarget::Value(arg)),
792 Arg::String(text, quoted) if !quoted => match text.as_str() {
793 "stdout" => Ok(AssertTarget::Stdout),
794 "stderr" => Ok(AssertTarget::Stderr),
795 _ => Ok(AssertTarget::Value(lower_assert_operand(Arg::String(
796 text, false,
797 )))),
798 },
799 other => Ok(AssertTarget::Value(lower_assert_operand(other))),
800 }
801}
802
803fn lower_assert_operand(arg: Arg) -> Arg {
812 match arg {
813 Arg::String(text, false) => {
814 let clean = crate::command::strip_digit_separators(&text);
819 if let Ok(i) = clean.parse::<i64>() {
820 Arg::Expr(Expr::Literal(Value::int(i)))
821 } else if clean.contains('.') && clean.parse::<f64>().is_ok() {
822 Arg::Expr(Expr::Literal(Value::float(
823 clean.parse::<f64>().unwrap_or(f64::NAN),
824 )))
825 } else if text == "true" {
826 Arg::Expr(Expr::Literal(Value::bool(true)))
827 } else if text == "false" {
828 Arg::Expr(Expr::Literal(Value::bool(false)))
829 } else {
830 Arg::String(text, false)
831 }
832 }
833 other => other,
834 }
835}
836
837declare_commands! {
838 structural [
839 WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
840 WithIoBlock { bindings: Vec<IoBinding> },
841 TypeAlias { name: String, target: String },
842 For { key_var: Option<String>, key_type: Option<String>, var: String, var_type: String, in_expr: Expr, body: Vec<Step> },
843 If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
844 Assign { var: String, decl_type: String, expr: Expr },
845 Set { var: String, expr: Expr },
846 AssignCapture { var: String, decl_type: String, cmd: Box<StepKind> },
847 AwaitCapture { out_var: String, out_type: String, task_var: String },
848 AsyncBlock { body: Vec<Step> },
849 AssignAsync { var: String, decl_type: String, body: Vec<Step> },
850 Await { var: String },
851 Cancel { var: String },
852 Timeout { duration: Arg, body: Vec<Step> },
853 RemoteBlock { target: String, vars: Vec<String>, env: Vec<String>, body: Vec<Step> },
854 RunExec { argv: Vec<Arg> },
855 FuncDef { name: String, params: Vec<(String, String)>, body: Vec<Step> },
856 Call { name: String, args: Vec<Expr> },
857 Return { expr: Box<Expr> },
858 While { cond: Box<Expr>, body: Vec<Step> },
859 Break,
860 Continue,
861 ]
862
863 Workdir => [
864 name: "WORKDIR",
865 variant: Workdir(Arg),
866 syntax: "WORKDIR <path>",
867 summary: "Change the working directory.",
868 description: indoc! {r#"
869 Sets the current working directory.
870
871 Relative paths resolve against the current directory; `/` resets to
872 the workspace root. Paths cannot escape the workspace.
873 "#},
874 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
875 flags: &[],
876 default_output: None,
877 examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
878 # Later relative paths resolve under the new directory.
879 WORKDIR project/src
880 WRITE generated.txt generated-under-workdir
881
882 LET $body: STRING = READ generated.txt
883 ASSERT_EQ $body "generated-under-workdir"
884 "#} }, Example { name: "workdir in a scoped block", fence_meta: None, code: indoc! {r#"
885 # The block reverts to the starting directory on exit.
886 LET $outside: STRING = CWD
887 MKDIR project
888
889 [bool:true] {
890 WORKDIR project
891 WRITE inner.txt inner
892 }
893
894 LET $back: STRING = CWD
895 ASSERT_EQ $back $outside
896 LET $body: STRING = READ project/inner.txt
897 ASSERT_EQ $body "inner"
898 "#} } ],
899 lower: |_flags, args| {
900 let path = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKDIR", "WORKDIR requires a path".to_string(), &SpanContext::line_only(0)))?;
901 Ok(StepKind::Workdir(path))
902 },
903 ],
904
905 Workspace => [
906 name: "WORKSPACE",
907 variant: Workspace(WorkspaceTarget),
908 syntax: "WORKSPACE (SNAPSHOT|LOCAL|CACHE|SYSTEM) [--local]",
909 summary: "Switch workspace roots.",
910 description: indoc! {r#"
911 Switches the workspace root. The selection reverts at scope
912 exit like `WORKDIR`.
913
914 - `SNAPSHOT`: the materialized build snapshot (the default).
915 - `LOCAL`: the local workspace directory.
916 - `CACHE`: a persistent per-project directory shared across
917 runs, never evicted. It lives under the OS user cache
918 (`OXDOCK_CACHE_DIR` pins an exact directory);
919 `WORKSPACE CACHE --local` keeps it in
920 `<project>/.cache/workspace` instead.
921 - `SYSTEM`: full filesystem access. Scripts using it are not
922 hermetic.
923 "#},
924 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 } ],
925 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)" } ],
926 default_output: None,
927 examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"
928 IMPORT [STD]
929 WORKSPACE LOCAL
930
931 LET $t: STRING = PATH_TYPE(".")
932 ASSERT_EQ $t "dir"
933 "#} }, Example { name: "workspace cache in a scoped block", fence_meta: None, code: indoc! {r#"
934 [bool:true] {
935 WORKSPACE CACHE
936 WRITE cached.txt cached-content
937 }
938
939 COPY --from-workspace CACHE cached.txt restored.txt
940 LET $body: STRING = READ restored.txt
941 ASSERT_EQ $body "cached-content"
942 "#} } ],
943 lower: |flags, args| {
944 let local = flags.iter().any(|(k, _)| k == "local");
945 let target = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKSPACE", "WORKSPACE requires a target".to_string(), &SpanContext::line_only(0)))?;
946 match target.as_str() {
947 "SNAPSHOT" | "LOCAL" | "SYSTEM" if local => Err(ParseError::validation("WORKSPACE", "WORKSPACE --local requires CACHE".to_string(), &SpanContext::line_only(0))),
948 "SNAPSHOT" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
949 "LOCAL" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
950 "CACHE" => Ok(StepKind::Workspace(WorkspaceTarget::Cache { local })),
951 "SYSTEM" => Ok(StepKind::Workspace(WorkspaceTarget::System)),
952 other => Err(ParseError::validation("WORKSPACE", format!("unknown workspace target: {other}"), &SpanContext::line_only(0))),
953 }
954 },
955 ],
956
957 Env => [
958 name: "ENV",
959 variant: Env { key: String, value: Arg },
960 syntax: "ENV KEY=value",
961 summary: "Set an environment variable.",
962 description: indoc! {r#"
963 Inserts or updates an env var.
964
965 The value uses the unified string-value rules shared by every command:
966 `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
967 evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
968 words join with single spaces, and the first `=` splits key from value
969 (`KEY=a=b` stores `a=b`).
970
971 A `$var` inside larger text stays literal — write `{{ $var }}` to
972 interpolate there.
973 "#},
974 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 } ],
975 flags: &[],
976 default_output: None,
977 examples: &[
978 Example { name: "set env", fence_meta: None, code: indoc! {r#"
979 ENV APP_MODE=production
980 LET $mode: STRING = env:APP_MODE
981 ASSERT_EQ $mode "production"
982 "#} },
983 Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
984 # Quotes keep the space: SET_FORTH stores `outer scope`.
985 ENV SET_FORTH="outer scope"
986 WRITE out.txt "{{ env:SET_FORTH }}"
987
988 LET $body: STRING = READ out.txt
989 ASSERT_EQ $body "outer scope"
990 "#} },
991 Example { name: "variable value", fence_meta: None, code: indoc! {r#"
992 # A lone $var evaluates, like ECHO $var.
993 LET $who: STRING = "Alice"
994 ENV GREETING=$who
995 WRITE out.txt "{{ env:GREETING }}"
996
997 LET $body: STRING = READ out.txt
998 ASSERT_EQ $body "Alice"
999 "#} },
1000 Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
1001 # A bare variable, a quoted literal, and a template all
1002 # store plain strings through the same value rules.
1003 LET $x: STRING = "Ada"
1004 ENV A=$x
1005 ENV B="hello world"
1006 ENV C="{{ $x }} concatenated"
1007 WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
1008
1009 LET $body: STRING = READ check.txt
1010 ASSERT_EQ $body "Ada|hello world|Ada concatenated"
1011 "#} },
1012 Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
1013 # ENV inside a braced block reverts when the block exits
1014 ENV MODE=production
1015
1016 [bool:true] {
1017 ENV MODE=staging
1018 WRITE inner.txt "{{ env:MODE }}"
1019 }
1020
1021 WRITE outer.txt "{{ env:MODE }}"
1022
1023 LET $inner_body: STRING = READ inner.txt
1024 ASSERT_EQ $inner_body "staging"
1025
1026 LET $outer_body: STRING = READ outer.txt
1027 ASSERT_EQ $outer_body "production"
1028 "#} },
1029 Example { name: "shell reads env per platform", fence_meta: None, code: indoc! {r#"
1030 # A shell command reads its own environment, with
1031 # per-platform spelling: quoted "$VAR" passes the parser
1032 # through untouched on Unix ...
1033 ENV PROXY_PORT=23791
1034
1035 [family:unix] LET $o: STRING = RUN echo serving on "$PROXY_PORT"
1036
1037 # ... while cmd expands %VAR% on Windows.
1038 [family:windows] LET $o: STRING = RUN echo serving on %PROXY_PORT%
1039
1040 ASSERT_CONTAINS $o "23791"
1041 "#} },
1042 ],
1043 lower: |_flags, args| lower_env_assignment(args),
1044 ],
1045
1046 InheritEnv => [
1047 name: "INHERIT_ENV",
1048 variant: InheritEnv { keys: Vec<String> },
1049 syntax: "INHERIT_ENV [<key>, ...]",
1050 summary: "Inherit env vars from host.",
1051 description: indoc! {r#"
1052 Declares which host environment variables to inherit into the script.
1053
1054 Must appear before any other commands and at most once. Without this
1055 directive, the script starts with an empty environment.
1056 "#},
1057 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 } ],
1058 flags: &[],
1059 default_output: None,
1060 examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"
1061 INHERIT_ENV [PATH, HOME]
1062 LET $path: STRING = env:PATH
1063 ASSERT_CONTAINS $path ":"
1064 "#} } ],
1065 lower: |_flags, args| {
1066 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
1067 Ok(StepKind::InheritEnv { keys })
1068 },
1069 ],
1070
1071 Echo => [
1072 name: "ECHO",
1073 variant: Echo(Arg),
1074 syntax: "ECHO <message>",
1075 summary: "Print to stdout.",
1076 description: "Outputs message to stdout.",
1077 args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1078 flags: &[],
1079 default_output: Some(Stream::Stdout),
1080 examples: &[
1081 Example { name: "echo", fence_meta: None, code: indoc! {r#"
1082 ECHO build-complete
1083 ASSERT_CONTAINS stdout "build-complete"
1084 "#} },
1085 Example { name: "variables", fence_meta: None, code: indoc! {r#"
1086 # {{ }} interpolates inside text; a lone $var evaluates on its own.
1087 LET $x: STRING = "World"
1088 ECHO "braced:{{ $x }}"
1089 ECHO $x
1090 ASSERT_EQ stdout "braced:World\nWorld\n"
1091 "#} },
1092 Example { name: "interpolation", fence_meta: None, code: indoc! {r#"
1093 # `{{ env:KEY }}` reads the script environment; a bare name
1094 # resolves a DSL variable instead. Unknown keys expand to
1095 # empty with no host fallback.
1096 ENV USER=OxDock
1097 LET $WHO: STRING = "Ada"
1098 ECHO "Hello {{ env:USER }}! Hi {{ WHO }}!{{ env:OXDOCK_DOC_NO_SUCH_KEY }}"
1099 ASSERT_CONTAINS stdout "Hello OxDock! Hi Ada!"
1100 "#} },
1101 ],
1102 lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
1103 ],
1104
1105 Run => [
1106 name: "RUN",
1107 variant: Run(Arg),
1108 syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
1109 summary: "Execute shell command or direct executable.",
1110 description: indoc! {r#"
1111 Shell form (`RUN <command...>`) runs the joined command string in the
1112 system shell (`$SHELL -c` / `COMSPEC /C`).
1113
1114 Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
1115 with no shell, so there is no shell expansion, globbing, redirection,
1116 or pipes; use it for portable commands.
1117
1118 Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
1119 both forms.
1120 "#},
1121 args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1122 flags: &[],
1123 default_output: None,
1124 examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"
1125 RUN echo hello
1126
1127 # Captured runs prove the output, not just the exit status.
1128 LET $o: STRING = RUN echo hello
1129 ASSERT_CONTAINS $o "hello"
1130 "#} }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"
1131 # No shell: `>` stays a literal argument, so no file is created.
1132 IMPORT [STD]
1133 RUN ["cargo", "--version", ">", "x.txt"]
1134 ASSERT_CONTAINS stdout "cargo"
1135
1136 LET $t: STRING = PATH_TYPE("x.txt")
1137 ASSERT_EQ $t "absent"
1138 "#} } ],
1139 lower: |_flags, args| match args.as_slice() {
1140 [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
1141 Err(ParseError::validation("RUN", "RUN requires at least one argument".to_string(), &SpanContext::line_only(0)))
1142 }
1143 [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
1144 argv: elems.iter().cloned().map(Arg::Expr).collect(),
1145 }),
1146 _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
1147 },
1148 ],
1149
1150 Copy => [
1151 name: "COPY",
1152 variant: Copy { from_workspace: Option<WorkspaceTarget>, from_host: bool, to_host: bool, from: Arg, to: Arg },
1153 syntax: "COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] [--from-host | --to-host] <from> <to>",
1154 summary: "Copy file into workspace.",
1155 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.",
1156 args: &[
1157 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1158 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1159 ],
1160 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)" } ],
1161 default_output: None,
1162 examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
1163 # Copy to a new name, then read back.
1164 WRITE src.txt content
1165 COPY src.txt dst.txt
1166
1167 LET $body: STRING = READ dst.txt
1168 ASSERT_EQ $body "content"
1169 "#} }, Example { name: "copy from workspace", fence_meta: None, code: indoc! {r#"
1170 # Same name, different contents per root: only LOCAL has ws-content.
1171 WRITE shared.txt from-snapshot
1172 WORKSPACE LOCAL
1173 WRITE shared.txt ws-content
1174
1175 WORKSPACE SNAPSHOT
1176 COPY --from-workspace LOCAL shared.txt ws-copy.txt
1177
1178 LET $body: STRING = READ ws-copy.txt
1179 ASSERT_EQ $body "ws-content"
1180 "#} } ],
1181 lower: |flags, args| {
1182 let from_workspace = flags
1183 .iter()
1184 .find(|(k, _)| k == "from_workspace")
1185 .map(|(_, v)| match v.as_str() {
1186 "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
1187 "LOCAL" => Ok(WorkspaceTarget::Local),
1188 "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
1189 "SYSTEM" => Ok(WorkspaceTarget::System),
1190 other => Err(ParseError::validation("COPY", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
1191 })
1192 .transpose()?;
1193 let mut it = args.into_iter();
1194 let from = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a source".to_string(), &SpanContext::line_only(0)))?;
1195 let to = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a destination".to_string(), &SpanContext::line_only(0)))?;
1196 let from_host = flags.iter().any(|(k, _)| k == "from_host");
1197 let to_host = flags.iter().any(|(k, _)| k == "to_host");
1198 if from_host && to_host {
1199 return Err(ParseError::validation("COPY", "COPY --from-host and --to-host are mutually exclusive".to_string(), &SpanContext::line_only(0)));
1200 }
1201 Ok(StepKind::Copy { from_workspace, from_host, to_host, from, to })
1202 },
1203 ],
1204
1205 CopyGit => [
1206 name: "COPY_GIT",
1207 variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
1208 syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
1209 summary: "Copy from git revision.",
1210 description: "Checkout and copy.",
1211 args: &[
1212 ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1213 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1214 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
1215 ],
1216 flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
1217 default_output: None,
1218 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"#} } ],
1219 lower: |flags, args| {
1220 let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
1221 let mut it = args.into_iter();
1222 let rev = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a revision".to_string(), &SpanContext::line_only(0)))?;
1223 let from = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a source".to_string(), &SpanContext::line_only(0)))?;
1224 let to = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a destination".to_string(), &SpanContext::line_only(0)))?;
1225 Ok(StepKind::CopyGit { rev, from, to, include_dirty })
1226 },
1227 ],
1228
1229 Symlink => [
1230 name: "SYMLINK",
1231 variant: Symlink { from_workspace: Option<WorkspaceTarget>, from: Arg, to: Arg },
1232 syntax: "SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
1233 summary: "Create symlink.",
1234 description: "Creates symlink. A directory destination (existing, or a trailing-slash spell) receives the link under the source basename.",
1235 args: &[
1236 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1237 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1238 ],
1239 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" } ],
1240 default_output: None,
1241 examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
1242 # A symlink reads like its target.
1243 WRITE original.txt content
1244 SYMLINK original.txt link.txt
1245
1246 LET $body: STRING = READ link.txt
1247 ASSERT_EQ $body "content"
1248 "#} }, Example { name: "symlink from workspace", fence_meta: None, code: indoc! {r#"
1249 # Same name, different contents per root: only LOCAL has ws-content.
1250 WRITE shared.txt from-snapshot
1251 WORKSPACE LOCAL
1252 WRITE shared.txt ws-content
1253
1254 WORKSPACE SNAPSHOT
1255 SYMLINK --from-workspace LOCAL shared.txt ws-link.txt
1256
1257 LET $body: STRING = READ ws-link.txt
1258 ASSERT_EQ $body "ws-content"
1259 "#} } ],
1260 lower: |flags, args| {
1261 let from_workspace = flags
1262 .iter()
1263 .find(|(k, _)| k == "from_workspace")
1264 .map(|(_, v)| match v.as_str() {
1265 "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
1266 "LOCAL" => Ok(WorkspaceTarget::Local),
1267 "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
1268 "SYSTEM" => Ok(WorkspaceTarget::System),
1269 other => Err(ParseError::validation("SYMLINK", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
1270 })
1271 .transpose()?;
1272 let mut it = args.into_iter();
1273 let from = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a source".to_string(), &SpanContext::line_only(0)))?;
1274 let to = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a target".to_string(), &SpanContext::line_only(0)))?;
1275 Ok(StepKind::Symlink { from_workspace, from, to })
1276 },
1277 ],
1278
1279 Mkdir => [
1280 name: "MKDIR",
1281 variant: Mkdir(Arg),
1282 syntax: "MKDIR <path>",
1283 summary: "Create directory.",
1284 description: "Creates dir with parents.",
1285 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1286 flags: &[],
1287 default_output: None,
1288 examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"
1289 IMPORT [STD]
1290 MKDIR deeply/nested/tree
1291
1292 LET $t: STRING = PATH_TYPE("deeply/nested/tree")
1293 ASSERT_EQ $t "dir"
1294 "#} } ],
1295 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)))?)),
1296 ],
1297
1298 Install => [
1299 name: "INSTALL",
1300 variant: Install { from: Arg, to: Arg },
1301 syntax: "INSTALL <source> <dest>",
1302 summary: "Atomically install a verified binary as executable.",
1303 description: "Stages the source as a temp file in the dest dir, sets executable mode, then renames into place.",
1304 args: &[
1305 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source file", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1306 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest file", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1307 ],
1308 flags: &[],
1309 default_output: None,
1310 examples: &[ Example { name: "install", fence_meta: Some("roots:unified"), code: indoc! {r#"
1311 # Place a verified binary with executable mode.
1312 IMPORT [STD]
1313 WRITE src.bin payload
1314 INSTALL src.bin dst-bin/tool
1315
1316 LET $t: STRING = PATH_TYPE("dst-bin/tool")
1317 ASSERT_EQ $t "file"
1318 "#} } ],
1319 lower: |_flags, args| {
1320 let mut it = args.into_iter();
1321 let from = it.next().ok_or_else(|| ParseError::validation("INSTALL", "INSTALL requires a source".to_string(), &SpanContext::line_only(0)))?;
1322 let to = it.next().ok_or_else(|| ParseError::validation("INSTALL", "INSTALL requires a destination".to_string(), &SpanContext::line_only(0)))?;
1323 Ok(StepKind::Install { from, to })
1324 },
1325 ],
1326
1327 Archive => [
1328 name: "ARCHIVE",
1329 variant: Archive { src: Arg, dst: Arg, raw: bool },
1330 syntax: "ARCHIVE [--raw] <src> <dst>",
1331 summary: "Pack a file or directory into a tarball.",
1332 description: indoc! {r#"
1333 Packs a file or directory into a tarball, gzip-compressed by
1334 default (`--raw` writes an uncompressed tar instead).
1335
1336 A lone file packs as a single top-level entry under its
1337 basename; a directory recurses, preserving empty dirs. Source
1338 modes ride in the headers on Unix, so system `tar` restores
1339 them elsewhere. Symlink sources refuse: archives cross trust
1340 boundaries, so links never resolve into member bytes. Hashing
1341 stays with `HASH_SHA256`, which reads the finished archive
1342 like any other file.
1343 "#},
1344 args: &[
1345 ArgSpec { name: "src", arg_type: ArgType::Path, description: "File or directory to pack", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1346 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Archive file to write", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1347 ],
1348 flags: &[ FlagSpec { name: "raw", long: "--raw", value_type: FlagValueType::Flag, required: false, description: "Write an uncompressed tar instead of gzip" } ],
1349 default_output: None,
1350 examples: &[ Example { name: "archive directory", fence_meta: None, code: indoc! {r#"
1351 # Stage a small tree to pack.
1352 WRITE bundle/app.txt bundled-payload
1353 WRITE bundle/nested/deep.txt deep-payload
1354
1355 # ARCHIVE packs it gzip-compressed by default.
1356 ARCHIVE bundle bundle.tar.gz
1357
1358 # UNARCHIVE restores the tree under the destination.
1359 UNARCHIVE bundle.tar.gz restored
1360
1361 # The payloads arrive byte-identical.
1362 LET $body: STRING = READ restored/app.txt
1363 ASSERT_EQ $body "bundled-payload"
1364
1365 LET $deep: STRING = READ restored/nested/deep.txt
1366 ASSERT_EQ $deep "deep-payload"
1367 "#} }, Example { name: "archive raw", fence_meta: None, code: indoc! {r#"
1368 # A lone file packs as a single top-level entry.
1369 WRITE tool.bin tool-payload
1370
1371 # Raw archives skip gzip and stay readable by any tar.
1372 ARCHIVE --raw tool.bin tool.tar
1373 UNARCHIVE tool.tar tool-out
1374
1375 # The entry lands under its basename.
1376 LET $body: STRING = READ tool-out/tool.bin
1377 ASSERT_EQ $body "tool-payload"
1378 "#} } ],
1379 lower: |flags, args| {
1380 let raw = flags.iter().any(|(k, _)| k == "raw");
1381 let mut it = args.into_iter();
1382 let src = it.next().ok_or_else(|| ParseError::validation("ARCHIVE", "ARCHIVE requires a source".to_string(), &SpanContext::line_only(0)))?;
1383 let dst = it.next().ok_or_else(|| ParseError::validation("ARCHIVE", "ARCHIVE requires a destination".to_string(), &SpanContext::line_only(0)))?;
1384 Ok(StepKind::Archive { src, dst, raw })
1385 },
1386 ],
1387
1388 Unarchive => [
1389 name: "UNARCHIVE",
1390 variant: Unarchive { src: Arg, dst: Arg, max_bytes: Option<Arg> },
1391 syntax: "UNARCHIVE <src> <dst> [--max-bytes <bytes>]",
1392 summary: "Unpack a tarball into a directory.",
1393 description: indoc! {r#"
1394 Unpacks a tarball under the destination directory, creating
1395 it first. Gzip and raw (`ARCHIVE --raw`) tarballs
1396 self-identify by magic bytes, so no flag is needed.
1397
1398 Member bytes meter against a 1 GiB default expansion cap
1399 across the whole archive: a decompression bomb trips it
1400 instead of filling the disk, and `--max-bytes` moves the
1401 ceiling either way (plain integer bytes). The tripped member
1402 stays truncated on disk while the step fails loudly.
1403
1404 Symlink and special members refuse naming the entry, and
1405 absolute or escaping paths fail closed before any byte
1406 lands: the member sanitizer plus the destination join check
1407 form two independent walls. Unix modes recorded in the
1408 headers restore on unpack; other platforms land default
1409 modes (confer executability with `INSTALL` there).
1410 "#},
1411 args: &[
1412 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Archive file to unpack", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1413 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Directory to unpack under", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1414 ],
1415 flags: &[ FlagSpec { name: "max_bytes", long: "--max-bytes", value_type: FlagValueType::String, required: false, description: "Cap total unpacked member bytes: bare digits are bytes (`1_000_000` grouping allowed), suffixes B/KB/MB/GB/TB (decimal) and KIB/MIB/GIB/TIB (binary), any case, one optional space before the unit (defaults to 1 GiB)" } ],
1416 default_output: None,
1417 examples: &[ Example { name: "unarchive", fence_meta: None, code: indoc! {r#"
1418 # Pack a note, then restore it under a fresh destination.
1419 WRITE pack/note.txt packed-note
1420 ARCHIVE pack pack.tar.gz
1421 UNARCHIVE pack.tar.gz unpacked
1422
1423 LET $body: STRING = READ unpacked/note.txt
1424 ASSERT_EQ $body "packed-note"
1425 "#} }, Example { name: "unarchive with a lower cap", fence_meta: None, code: indoc! {r#"
1426 # Small payloads clear even a tight ceiling.
1427 WRITE pack/note.txt packed-note
1428 ARCHIVE pack pack.tar.gz
1429 UNARCHIVE pack.tar.gz capped --max-bytes 100KB
1430
1431 LET $body: STRING = READ capped/note.txt
1432 ASSERT_EQ $body "packed-note"
1433 "#} } ],
1434 lower: |flags, args| {
1435 let mut it = args.into_iter();
1436 let src = it.next().ok_or_else(|| ParseError::validation("UNARCHIVE", "UNARCHIVE requires an archive".to_string(), &SpanContext::line_only(0)))?;
1437 let dst = it.next().ok_or_else(|| ParseError::validation("UNARCHIVE", "UNARCHIVE requires a destination".to_string(), &SpanContext::line_only(0)))?;
1438 let max_bytes = flags
1439 .into_iter()
1440 .find_map(|(k, v)| (k == "max_bytes").then_some(v));
1441 if let Some(Arg::String(text, _)) = max_bytes.as_ref()
1445 && !text.contains("{{")
1446 && let Err(e) = crate::command::parse_byte_count(text)
1447 {
1448 return Err(ParseError::validation(
1449 "UNARCHIVE",
1450 format!("UNARCHIVE --max-bytes {e:#}"),
1451 &SpanContext::line_only(0),
1452 ));
1453 }
1454 Ok(StepKind::Unarchive { src, dst, max_bytes })
1455 },
1456 ],
1457
1458 Ls => [
1459 name: "LS",
1460 variant: Ls(Option<Arg>),
1461 syntax: "LS [<path>]",
1462 summary: "List directory.",
1463 description: "Lists entries.",
1464 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1465 flags: &[],
1466 default_output: Some(Stream::Stdout),
1467 examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
1468 MKDIR inventory
1469 WRITE inventory/a.txt a
1470 LS inventory
1471 ASSERT_CONTAINS stdout "a.txt"
1472 "#} } ],
1473 lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
1474 ],
1475
1476 Cwd => [
1477 name: "CWD",
1478 variant: Cwd,
1479 syntax: "CWD",
1480 summary: "Print working directory.",
1481 description: "Outputs cwd.",
1482 args: &[],
1483 flags: &[],
1484 default_output: Some(Stream::Stdout),
1485 examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"
1486 CWD
1487
1488 # CWD tracks WORKDIR: the listing names the new directory.
1489 MKDIR sub
1490 WORKDIR sub
1491 LET $c: STRING = CWD
1492 ASSERT_CONTAINS $c "sub"
1493 "#} } ],
1494 lower: |_flags, _args| Ok(StepKind::Cwd),
1495 ],
1496
1497 Read => [
1498 name: "READ",
1499 variant: Read(Option<Arg>),
1500 syntax: "READ [<path>]",
1501 summary: "Read file to stdout.",
1502 description: "Outputs file contents.",
1503 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
1504 flags: &[],
1505 default_output: Some(Stream::Stdout),
1506 examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
1507 WRITE note.txt "hello"
1508 READ note.txt
1509
1510 LET $body: STRING = READ note.txt
1511 ASSERT_EQ $body "hello"
1512 "#} } ],
1513 lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
1514 ],
1515
1516 ReadLine => [
1517 name: "READ_LINE",
1518 variant: ReadLine { var: String },
1519 syntax: "READ_LINE $var",
1520 summary: "Read one line from stdin into a variable.",
1521 description: indoc! {r#"
1522 Reads bytes until newline without waiting for EOF, leaving the pipe open.
1523
1524 Trailing newline is stripped (shell-read parity). On premature EOF
1525 assigns accumulated bytes and returns.
1526 "#},
1527 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 } ],
1528 flags: &[],
1529 default_output: None,
1530 examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
1531 # The trailing newline is stripped: the variable holds exactly `first`.
1532 LET $lines: PIPE
1533 WITH_IO [stdout=$lines] ECHO "first"
1534 WITH_IO [stdin=$lines] READ_LINE $reply
1535 ASSERT_EQ $reply "first"
1536 "#} } ],
1537 lower: |_flags, args| {
1538 let arg = args.into_iter().next().ok_or_else(|| ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))?;
1539 let var = match arg {
1540 Arg::Expr(Expr::Var(name)) => name,
1541 Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1544 other => return Err(ParseError::validation("READ_LINE", format!("READ_LINE requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1545 };
1546 if var.is_empty() {
1547 return Err(ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))
1548 }
1549 Ok(StepKind::ReadLine { var })
1550 },
1551 ],
1552
1553 Prompt => [
1554 name: "PROMPT",
1555 variant: Prompt { var: String, message: Arg, default: Option<Arg> },
1556 syntax: "PROMPT $var <message> [<default>]",
1557 summary: "Prompt on the terminal into a variable.",
1558 description: indoc! {r#"
1559 Prints `{message} [{default}]: ` (or `{message}: ` with no
1560 default) to stderr and reads one line from the terminal.
1561
1562 Empty input yields the default when one is given, else an
1563 empty string. EOF aborts loudly. Non-TTY stdin never reads:
1564 a default binds with a stderr note, while no default bails.
1565 "#},
1566 args: &[
1567 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 },
1568 ArgSpec { name: "message", arg_type: ArgType::String, description: "Prompt message", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1569 ArgSpec { name: "default", arg_type: ArgType::String, description: "Default on empty input", io: IoDirection::Read, index: 2, required: false, fallback_stream: None },
1570 ],
1571 flags: &[],
1572 default_output: None,
1573 examples: &[ Example { name: "prompt needs a terminal", fence_meta: Some("expect_error:\"non-TTY\""), code: indoc! {r#"
1574 # Headless runs have no terminal: without a default the
1575 # prompt fails instead of hanging or reading piped stdin.
1576 PROMPT $dir "Install destination?"
1577 "#} } ],
1578 lower: |_flags, args| {
1579 let mut it = args.into_iter();
1580 let var_arg = it.next().ok_or_else(|| ParseError::validation("PROMPT", "PROMPT requires a variable".to_string(), &SpanContext::line_only(0)))?;
1581 let var = match var_arg {
1582 Arg::Expr(Expr::Var(name)) => name,
1583 Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1584 other => return Err(ParseError::validation("PROMPT", format!("PROMPT requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1585 };
1586 if var.is_empty() {
1587 return Err(ParseError::validation("PROMPT", "PROMPT requires a variable".to_string(), &SpanContext::line_only(0)))
1588 }
1589 let message = it.next().ok_or_else(|| ParseError::validation("PROMPT", "PROMPT requires a message".to_string(), &SpanContext::line_only(0)))?;
1590 let default = it.next();
1591 Ok(StepKind::Prompt { var, message, default })
1592 },
1593 ],
1594
1595 Write => [
1596 name: "WRITE",
1597 variant: Write { path: Arg, contents: Option<Arg> },
1598 syntax: "WRITE <path> [<contents>]",
1599 summary: "Write to file.",
1600 description: "Writes contents.",
1601 args: &[
1602 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1603 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1604 ],
1605 flags: &[],
1606 default_output: None,
1607 examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"
1608 WRITE output.txt hello-world
1609 LET $body: STRING = READ output.txt
1610 ASSERT_EQ $body "hello-world"
1611 "#} } ],
1612 lower: |_flags, args| {
1613 let mut it = args.into_iter();
1614 let path = it.next().ok_or_else(|| ParseError::validation("WRITE", "WRITE requires a path".to_string(), &SpanContext::line_only(0)))?;
1615 let remaining: Vec<Arg> = it.collect();
1616 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
1617 Ok(StepKind::Write { path, contents })
1618 },
1619 ],
1620
1621 Append => [
1622 name: "APPEND",
1623 variant: Append { path: Arg, contents: Option<Arg> },
1624 syntax: "APPEND <path> [<contents>]",
1625 summary: "Append to file.",
1626 description: "Appends contents.",
1627 args: &[
1628 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1629 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1630 ],
1631 flags: &[],
1632 default_output: None,
1633 examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
1634 WRITE log.txt line1
1635 APPEND log.txt line2
1636
1637 # APPEND concatenates with no separator.
1638 LET $all: STRING = READ log.txt
1639 ASSERT_EQ $all "line1line2"
1640 "#} } ],
1641 lower: |_flags, args| {
1642 let mut it = args.into_iter();
1643 let path = it.next().ok_or_else(|| ParseError::validation("APPEND", "APPEND requires a path".to_string(), &SpanContext::line_only(0)))?;
1644 let remaining: Vec<Arg> = it.collect();
1645 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
1646 Ok(StepKind::Append { path, contents })
1647 },
1648 ],
1649
1650 Remove => [
1651 name: "REMOVE",
1652 variant: Remove { path: Arg, recursive: bool },
1653 syntax: "REMOVE [--recursive] <path>",
1654 summary: "Remove a file, symlink, or directory.",
1655 description: "Removes a file, symlink (the link itself), or empty dir. With --recursive removes a directory tree.",
1656 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Path to remove", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1657 flags: &[ FlagSpec { name: "recursive", long: "--recursive", value_type: FlagValueType::Flag, required: false, description: "Remove a directory tree" } ],
1658 default_output: None,
1659 examples: &[ Example { name: "remove", fence_meta: None, code: indoc! {r#"
1660 # WRITE then REMOVE leaves nothing behind.
1661 IMPORT [STD]
1662 WRITE scratch.txt content
1663
1664 # Assert original file is written
1665 LET $o: STRING = PATH_TYPE("scratch.txt")
1666 ASSERT_EQ $o "file"
1667
1668 REMOVE scratch.txt
1669
1670 # Assert file has been removed
1671 LET $t: STRING = PATH_TYPE("scratch.txt")
1672 ASSERT_EQ $t "absent"
1673 "#} } ],
1674 lower: |flags, args| {
1675 let recursive = flags.iter().any(|(k, _)| k == "recursive");
1676 let path = args.into_iter().next().ok_or_else(|| ParseError::validation("REMOVE", "REMOVE requires a path".to_string(), &SpanContext::line_only(0)))?;
1677 Ok(StepKind::Remove { path, recursive })
1678 },
1679 ],
1680
1681 Expand => [
1682 name: "EXPAND",
1683 variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
1684 syntax: "EXPAND [<path>] [<KEY=val> ...]",
1685 summary: "Expand a template file (or stdin) to stdout.",
1686 description: indoc! {r#"
1687 A template is any text file — or piped stdin when no path is given —
1688 containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
1689 and prints the result to stdout.
1690
1691 Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
1692 command; `{{ env:NAME }}` reads an override, falling back to the
1693 environment; `{{ $var }}` reads a script variable (dotted paths allowed).
1694 A missing key is an error, never a silent empty.
1695
1696 Substitution runs in a single pass. EXPAND is not recursive and does not
1697 expand nested placeholders: a value that itself contains `{{ ... }}` is
1698 inserted verbatim and never expanded again.
1699
1700 A bare `$var` argument is a template path; `KEY=val` arguments are
1701 overrides whose values follow the unified string-value rules (same as
1702 `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1703 `{{ ... }}` interpolates).
1704
1705 NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1706 (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1707
1708 With no path, the template arrives on stdin through a pipe. When piping
1709 from a shell, single-quote the template (`echo '{{ $x }}'`): double
1710 quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1711 placeholder and errors.
1712 "#},
1713 args: &[
1714 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 },
1715 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 },
1716 ],
1717 flags: &[],
1718 default_output: Some(Stream::Stdout),
1719 examples: &[
1720 Example { name: "expand", fence_meta: None, code: indoc! {r#"
1721 # Placeholders read overrides first, then the environment.
1722 ENV NAME="Alice"
1723 WRITE template.md "Hello {{ env:NAME }}!"
1724 EXPAND template.md
1725
1726 ASSERT_CONTAINS stdout "Hello Alice!"
1727 "#} },
1728 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1729 # WRITE would interpolate {{ }} right away, so escape it.
1730 # The file must literally contain {{ env:NAME }} for EXPAND.
1731 WRITE template.md "Hello \{{ env:NAME }}!"
1732 EXPAND template.md NAME="Alice Smith"
1733
1734 ASSERT_CONTAINS stdout "Hello Alice Smith!"
1735 "#} },
1736 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1737 # Same escaping: keep the placeholder literal until EXPAND.
1738 # A lone $who evaluates, like ECHO $who.
1739 LET $who: STRING = "Bob"
1740 WRITE template.md "Hi \{{ env:WHO }}!"
1741 EXPAND template.md WHO=$who
1742
1743 ASSERT_CONTAINS stdout "Hi Bob!"
1744 "#} },
1745 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1746 # A bare variable and a template-with-tail expand identically.
1747 LET $x: STRING = "Ada"
1748 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1749 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1750
1751 ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1752 "#} },
1753 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1754 # No path: the template arrives on stdin through a pipe.
1755 LET $tpl: PIPE
1756 WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1757 WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1758
1759 ASSERT_CONTAINS stdout "Hello Alice!"
1760 "#} },
1761 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1762 # KEY=val overrides shadow env for that EXPAND only.
1763 # They never update the environment itself.
1764 ENV NAME="Alice"
1765 WRITE template.md "Hi \{{ env:NAME }}!"
1766
1767 EXPAND template.md NAME="Bob"
1768 ASSERT_CONTAINS stdout "Hi Bob!"
1769
1770 EXPAND template.md
1771 ASSERT_CONTAINS stdout "Hi Alice!"
1772 "#} },
1773 ],
1774 lower: |_flags, args| {
1775 let mut path = None;
1776 let mut overrides = Vec::new();
1777 for arg in args {
1778 let text = arg.as_str();
1779 if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1780 overrides.push((key, value));
1781 } else if path.is_none() { path = Some(arg); }
1782 else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1783 }
1784 Ok(StepKind::Expand { path, overrides })
1785 },
1786 ],
1787
1788 AssertEq => [
1789 name: "ASSERT_EQ",
1790 variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1791 syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1792 summary: "Assert strict equality.",
1793 description: indoc! {r#"
1794 Compares two evaluated values with typed equality (no coercion:
1795 `INT(42)` never equals `STRING("42")`), aborting the pipeline
1796 with a step-numbered error showing expected vs actual otherwise.
1797
1798 Both sides are values: `$var`, literals, templates, and calls
1799 evaluate in memory and never touch disk. Read files explicitly
1800 first (`LET $text: STRING = READ "out.txt"`, then
1801 `ASSERT_EQ $text ...`).
1802 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1803 holding a `PIPE` observes its backend bytes. `--hash` compares
1804 the SHA-256 of a string, pipe, or captured-stdout actual
1805 instead of the raw bytes (`stderr` is unsupported).
1806 "#},
1807 args: &[
1808 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 },
1809 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1810 ],
1811 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1812 default_output: None,
1813 examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1814 LET $status: INT = 200
1815 ASSERT_EQ $status 200
1816 "#} },
1817 Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1818 WRITE payload.bin stable-content
1819 LET $body: STRING = READ payload.bin
1820 ASSERT_EQ $body "stable-content"
1821 "#} },
1822 Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1823 # --hash compares the SHA-256 digest instead of raw bytes.
1824 WRITE payload.bin stable-content
1825 LET $body: STRING = READ payload.bin
1826 ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1827 "#} } ],
1828 lower: |flags, args| {
1829 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1830 let mut it = args.into_iter();
1831 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)))?)?;
1832 let remaining: Vec<Arg> = it
1833 .map(lower_assert_operand)
1834 .collect::<Vec<Arg>>();
1835 let expected = if remaining.is_empty() {
1838 if hash.is_some() {
1839 None
1840 } else {
1841 return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1842 }
1843 } else {
1844 Some(join_value(remaining, "ASSERT_EQ")?)
1845 };
1846 Ok(StepKind::AssertEq { hash, actual, expected })
1847 },
1848 ],
1849
1850 AssertContains => [
1851 name: "ASSERT_CONTAINS",
1852 variant: AssertContains { haystack: AssertTarget, needle: Arg },
1853 syntax: "ASSERT_CONTAINS <haystack> <needle>",
1854 summary: "Assert containment.",
1855 description: indoc! {r#"
1856 Checks containment and aborts the pipeline with a step-numbered
1857 error otherwise: substring for strings, element match for lists,
1858 key presence for maps, substring over stream and pipe buffers.
1859
1860 Like `ASSERT_EQ`, both sides are values read without implicit
1861 I/O; read files explicitly first
1862 (`LET $text: STRING = READ "cfg.txt"`).
1863 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1864 holding a `PIPE` observes its backend bytes.
1865 "#},
1866 args: &[
1867 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 },
1868 ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1869 ],
1870 flags: &[],
1871 default_output: None,
1872 examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1873 ECHO build-complete
1874 ASSERT_CONTAINS stdout "build-complete"
1875 "#} } ],
1876 lower: |flags, args| {
1877 let _ = flags;
1878 let mut it = args.into_iter();
1879 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)))?)?;
1880 let remaining: Vec<Arg> = it
1881 .map(lower_assert_operand)
1882 .collect::<Vec<Arg>>();
1883 if remaining.is_empty() {
1884 return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1885 }
1886 let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1887 Ok(StepKind::AssertContains { haystack, needle })
1888 },
1889 ],
1890
1891 HashSha256 => [
1892 name: "HASH_SHA256",
1893 variant: HashSha256 { path: Arg },
1894 syntax: "HASH_SHA256 <path>",
1895 summary: "Print SHA-256.",
1896 description: "Computes digest.",
1897 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1898 flags: &[],
1899 default_output: Some(Stream::Stdout),
1900 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1901 WRITE payload.txt hello
1902 HASH_SHA256 payload.txt
1903
1904 LET $digest: STRING = HASH_SHA256 payload.txt
1905 ASSERT_EQ $digest "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824\n"
1906 "#} } ],
1907 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)))? }),
1908 ],
1909
1910 Exit => [
1911 name: "EXIT",
1912 variant: Exit(Arg),
1913 syntax: "EXIT <code>",
1914 summary: "Exit pipeline.",
1915 description: indoc! {r#"
1916 Stops the pipeline immediately with an `EXIT requested with code <code>`
1917 error; steps after it never run, at any nesting depth.
1918
1919 Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1920 anonymous background tasks are killed synchronously, and files written
1921 before the EXIT persist.
1922
1923 The code reaches the OS: the CLI exits with `<code>` clamped to
1924 0..=255, and a guest EXIT relays through `REMOTE` the same way.
1925 "#},
1926 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1927 flags: &[],
1928 default_output: None,
1929 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1930 lower: |_flags, args| {
1931 let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1934 Ok(StepKind::Exit(code))
1935 },
1936 ],
1937
1938 Sleep => [
1939 name: "SLEEP",
1940 variant: Sleep { duration: Arg },
1941 syntax: "SLEEP <duration>",
1942 summary: "Pause execution for a duration.",
1943 description: indoc! {r#"
1944 Parks the step for the duration (e.g. 500ms, 10s, 2m).
1945
1946 Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1947 teardown interrupts the sleep. Cross-platform alternative to shell sleep
1948 for testing time boundaries.
1949 "#},
1950 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1951 flags: &[],
1952 default_output: None,
1953 examples: &[
1954 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1955 Example {
1956 name: "sleep variable duration",
1957 fence_meta: None,
1958 code: indoc! {r#"
1959 # Durations resolve at runtime, so variables work too:
1960 # quoted or bare, both bind the same string.
1961 LET $pause: STRING = "100ms"
1962 SLEEP $pause
1963
1964 LET $bare: STRING = 100ms
1965 SLEEP $bare
1966 "#},
1967 },
1968 ],
1969 lower: |_flags, args| {
1970 let mut it = args.into_iter();
1971 let raw = it
1972 .next()
1973 .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1974 if it.next().is_some() {
1975 return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1976 }
1977 Ok(StepKind::Sleep { duration: raw })
1980 },
1981 ],
1982
1983 ListAppend => [
1984 name: "LIST_APPEND",
1985 variant: ListAppend { list: String, item: Arg },
1986 syntax: "LIST_APPEND $list <item>",
1987 summary: "Append an item to a LIST variable in place.",
1988 description: indoc! {r#"
1989 Appends the item to the LIST variable in place.
1990
1991 When the binding holds the only reference the push runs in
1992 amortized constant time. Aliased buffers detach first, so
1993 other holders keep their contents.
1994 "#},
1995 args: &[
1996 ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1997 ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1998 ],
1999 flags: &[],
2000 default_output: None,
2001 examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
2002 # Appends accumulate in order.
2003 LET $items: LIST<STRING> = []
2004 LIST_APPEND $items "first"
2005 LIST_APPEND $items "second"
2006
2007 LET $want: LIST<STRING> = ["first", "second"]
2008 ASSERT_EQ $items $want
2009 "#} } ],
2010 lower: |_flags, args| {
2011 let mut it = args.into_iter();
2012 let raw_list = it
2013 .next()
2014 .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)))?;
2015 let list = match raw_list {
2016 Arg::Expr(Expr::Var(name)) => name,
2017 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
2018 other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
2019 };
2020 if list.is_empty() {
2021 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
2022 }
2023 let item = it
2024 .next()
2025 .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)))?;
2026 if it.next().is_some() {
2027 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
2028 }
2029 Ok(StepKind::ListAppend { list, item })
2030 },
2031 ],
2032}
2033
2034pub fn all_structural_metadata() -> Vec<CommandMeta> {
2042 vec![
2043 CommandMeta {
2044 name: "WITH_IO",
2045 syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
2046 summary: "Reroute standard streams.",
2047 description: indoc! {r#"
2048 Reroutes the standard streams of the next command or, in block form,
2049 of every enclosed command.
2050
2051 Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
2052 variable (`stdout=$p`, `stdin=$p`), resolved from the variable
2053 when the step runs. Both stdout and stderr pipes capture output
2054 the same way. Declare the handle first with `LET $p: PIPE`.
2055
2056 Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
2057 producer can finish before the consumer starts.
2058
2059 If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
2060 not, the pipe is a zero copy OS kernel pipe instead: pair it with a
2061 consumer that runs while the producer is alive, since output past the
2062 64 KiB kernel buffer stalls until drained. That promotion never crosses
2063 a function boundary: pipes created, bound, or passed by variable inside FUNC
2064 bodies are always script pipes, even when the surrounding task would
2065 otherwise promote.
2066
2067 A second producer or consumer on a live handle is an explicit
2068 error. A handle bound as output can later feed another
2069 command's `stdin`, connecting commands without touching the
2070 terminal. Binding `stdout` and `stderr` to the same live
2071 handle fails deterministically. Merge streams in shell
2072 via `2>&1` instead.
2073
2074 Nested blocks stack defaults; inline bindings override inherited ones for
2075 their command only; closing a block restores previous wiring.
2076 "#},
2077 args: &[],
2078 flags: &[],
2079 default_output: None,
2080 examples: &[
2081 Example {
2082 name: "with_io block",
2083 fence_meta: None,
2084 code: indoc! {r#"
2085 LET $log: PIPE
2086 WITH_IO [stdout=$log] {
2087 ECHO first
2088 ECHO second
2089 }
2090 WITH_IO [stdin=$log] WRITE captured.txt
2091
2092 # The piped bytes landed in the file.
2093 LET $body: STRING = READ captured.txt
2094 ASSERT_CONTAINS $body "first"
2095 ASSERT_CONTAINS $body "second"
2096 "#},
2097 },
2098 Example {
2099 name: "variable pipe binding",
2100 fence_meta: None,
2101 code: indoc! {r#"
2102 # Declare the pipe first: `LET $p: PIPE` mints a fresh
2103 # backend without touching a stream. A plain string here
2104 # would be a TypeMismatch.
2105 LET $p: PIPE
2106 WITH_IO [stdout=$p] ECHO hello
2107 WITH_IO [stdin=$p] READ_LINE $line
2108 ASSERT_EQ $line "hello"
2109 "#},
2110 },
2111 ],
2112 },
2113 CommandMeta {
2114 name: "FOR",
2115 syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
2116 summary: "Iterate over a list or map.",
2117 description: indoc! {r#"
2118 The loop variable receives each element (lists) or value (maps); with
2119 two variables, the first receives the key.
2120
2121 Loop variables are declared with explicit types and scoped per iteration;
2122 they do not leak outward. The body may be a braced block
2123 or a single-line `{ ... }` command.
2124
2125 `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
2126 `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
2127 empty when nothing matches, and rejects `..` escapes.
2128 "#},
2129 args: &[],
2130 flags: &[],
2131 default_output: None,
2132 examples: &[
2133 Example {
2134 name: "for loop",
2135 fence_meta: None,
2136 code: indoc! {r#"
2137 # Each element binds in turn; the loop body sees every one.
2138 LET $items: LIST<STRING> = ["a", "b"]
2139 FOR $item: STRING IN $items {
2140 ECHO $item
2141 }
2142 ASSERT_CONTAINS stdout "a"
2143 ASSERT_CONTAINS stdout "b"
2144
2145 # Key and value bind together for maps.
2146 LET $map: MAP<x: INT> = {"x": 1}
2147 FOR $k: STRING, $v: INT IN $map {
2148 ECHO "{{ $k }}={{ $v }}"
2149 }
2150 ASSERT_CONTAINS stdout "x=1"
2151 "#},
2152 },
2153 Example {
2154 name: "expand every match",
2155 fence_meta: None,
2156 code: indoc! {r#"
2157 # Single-line body; $x is a template path, WHO an override.
2158 IMPORT [STD]
2159 WRITE a.txt "hi \{{ env:WHO }}!"
2160 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2161
2162 ASSERT_CONTAINS stdout "hi World!"
2163 "#},
2164 },
2165 ],
2166 },
2167 CommandMeta {
2168 name: "IF",
2169 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
2170 summary: "Conditional execution.",
2171 description: indoc! {r#"
2172 The condition is evaluated as a boolean expression.
2173
2174 Prefix `!` negates (`IF !false`); `&&` binds tighter than
2175 `||`, and both short-circuit, so `IF true || $missing`
2176 never evaluates the right side. Only Bool values are
2177 accepted as conditions.
2178 "#},
2179 args: &[],
2180 flags: &[],
2181 default_output: None,
2182 examples: &[
2183 Example {
2184 name: "if else",
2185 fence_meta: None,
2186 code: indoc! {r#"
2187 IMPORT [STD]
2188
2189 # True branch runs; the false branch is skipped.
2190 IF true {
2191 WRITE yes.txt taken
2192 } ELSE {
2193 WRITE yes.txt skipped
2194 }
2195
2196 # ELSE IF selects the first true branch.
2197 IF false {
2198 WRITE skipped.txt no
2199 } ELSE IF true {
2200 WRITE fallback.txt taken
2201 }
2202
2203 # !false evaluates to true, so this branch runs.
2204 IF !false {
2205 WRITE negated.txt taken
2206 }
2207
2208 LET $yes_body: STRING = READ yes.txt
2209 LET $fallback_body: STRING = READ fallback.txt
2210 LET $negated_body: STRING = READ negated.txt
2211 ASSERT_EQ $yes_body "taken"
2212 ASSERT_EQ $fallback_body "taken"
2213 ASSERT_EQ $negated_body "taken"
2214 LET $t: STRING = PATH_TYPE("skipped.txt")
2215 ASSERT_EQ $t "absent"
2216 "#},
2217 },
2218 Example {
2219 name: "logical condition composition",
2220 fence_meta: None,
2221 code: indoc! {r#"
2222 IMPORT [STD]
2223 LET $role: STRING = "admin"
2224 LET $level: INT = 3
2225
2226 # || is true when either side holds; && needs both.
2227 IF $role == "owner" || $level >= 5 {
2228 WRITE unexpected.txt no
2229 } ELSE {
2230 WRITE fallback.txt or-false
2231 }
2232
2233 LET $fb: STRING = READ fallback.txt
2234 ASSERT_EQ $fb "or-false"
2235 LET $t1: STRING = PATH_TYPE("unexpected.txt")
2236 ASSERT_EQ $t1 "absent"
2237
2238 IF $role == "admin" || $level >= 5 {
2239 WRITE chosen.txt or-true
2240 }
2241
2242 LET $ch: STRING = READ chosen.txt
2243 ASSERT_EQ $ch "or-true"
2244
2245 IF $role == "admin" && $level >= 5 {
2246 WRITE unexpected-too.txt no
2247 } ELSE {
2248 WRITE and.txt and-false
2249 }
2250
2251 LET $an: STRING = READ and.txt
2252 ASSERT_EQ $an "and-false"
2253 LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
2254 ASSERT_EQ $t2 "absent"
2255 "#},
2256 },
2257 ],
2258 },
2259 CommandMeta {
2260 name: "LET",
2261 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> }",
2262 summary: "Bind script-local variables.",
2263 description: indoc! {r#"
2264 Declares a script-local variable with an explicit type (STRING, INT,
2265 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
2266 in the same scope frame is a redeclaration error; mutate with
2267 `$var = <expr>`.
2268
2269 Variables are usable in templates (`{{ $var }}`), guards, and
2270 expressions. With `ASYNC`, spawns a background task and stores its
2271 handle (see ASYNC). The `$` sigil on the name is mandatory.
2272
2273 No hoisting: a variable exists only after its LET runs, in
2274 execution order. Reading `$var` before its LET (or after the
2275 block that declared it exits) fails with
2276 `undefined variable $var`. Scopes are a stack of frames and
2277 resolution walks innermost outward, so nothing pre-declares
2278 names. Function bodies read outer variables through the same
2279 walk, but their own LETs never leak out (see FUNC).
2280
2281 The right-hand side is always an expression — literals, lists, maps,
2282 arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
2283 parentheses), comparisons (`< <= > >=` binding tighter than
2284 `== !=`), logical `&&` (tighter) and `||` with short-circuit,
2285 `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
2286 `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
2287 `{{ ... }}` template; interpolation happens in string values,
2288 not here.
2289 The one exception is pipes: `LET $p: PIPE` with no `=`
2290 and no initializer mints a fresh anonymous backend,
2291 lazily materialized at first binding, so two declarations
2292 never share a channel.
2293
2294 Numbers are numeric literals: `42` binds `INT`, `3.14` binds
2295 `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
2296 so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
2297 Division by zero, overflow, and non-finite results are errors.
2298 Both numeric sides compare numerically (`1 == 1.0` is true);
2299 otherwise `==`/`!=` compare rendered strings and ordering on
2300 non-numerics is a Type Error. Constant subtrees fold at parse
2301 time and dynamic arithmetic compiles to flat RPN with
2302 identical semantics.
2303
2304 Float equality is exact with no epsilon. Floats store decimals
2305 in binary, so a value is exact only when its reduced fraction
2306 has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
2307 (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
2308 repeat forever in binary (like 1/3 in decimal) and truncate,
2309 so `0.1 + 0.2 == 0.3` is false (the sum is
2310 `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
2311 .125, .625, .875 are exact; .1, .2, .3 and similar are
2312 approximations. Bound approximations instead of comparing
2313 them: `IF $sum > 0.299999 && $sum < 0.300001`.
2314
2315 Comparisons do not chain: `a < b < c` is a parse error, not
2316 `(a < b) < c`. Chaining would compare a `BOOL` against a
2317 number (a runtime Type Error in C-style parsing) or evaluate
2318 the middle term twice (Python-style chaining), so the grammar
2319 accepts exactly one comparison operator per level. Write the
2320 conjunction explicitly: `$a < $b && $b < $c`. The same holds
2321 for equality (`$a == $b == $c` is rejected).
2322
2323 Captured command output is a string, so convert before math:
2324 `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
2325 whitespace; `FLOAT` accepts int strings and rejects
2326 non-finite).
2327
2328 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
2329 as quoted.
2330
2331 When the right-hand side is a synchronous command
2332 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
2333 exact stdout bytes are captured into the variable as a string (no newline
2334 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
2335 an error). Combining capture with an explicit
2336 `WITH_IO [stdout=$var]` is a parse error.
2337
2338 Coming from Bash, the capture line looks familiar but behaves
2339 strictly:
2340
2341 | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
2342 | --- | --- | --- |
2343 | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
2344 | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
2345 | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
2346 | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
2347
2348 `LET $out: TYPE = AWAIT $var` binds the background task's
2349 explicit `RETURN` value instead (tasks stream their stdout
2350 live, so there is no output left to capture); a task that
2351 succeeded without `RETURN` yields `INT` 0, like a process
2352 exit status.
2353
2354 An inline block (`LET $var: TYPE = { <commands> }`) runs its
2355 steps in a fresh scope and binds the nearest `RETURN` value,
2356 like a zero-arg function body: fallthrough without `RETURN`
2357 binds `""`, and `BREAK`/`CONTINUE` escaping the block are
2358 errors. The block reads outer variables but its own LETs
2359 never leak out. A `{k: v}` shape still parses as a map
2360 literal; anything else in braces is a block.
2361
2362 The split is deliberate: synchronous commands capture
2363 stdout because they run inline to completion on the same
2364 thread; background tasks never capture stdout because
2365 concurrent output has no well-defined value. Task results
2366 travel only through `RETURN` (or `INT` 0 for void tasks).
2367
2368 `LET $e: STRING = env:FOO` reads the script environment into a plain
2369 string.
2370 "#},
2371 args: &[],
2372 flags: &[],
2373 default_output: None,
2374 examples: &[
2375 Example {
2376 name: "let",
2377 fence_meta: None,
2378 code: indoc! {r#"
2379 LET $name: STRING = "world"
2380 ECHO "hello, {{ $name }}"
2381 ASSERT_CONTAINS stdout "hello, world"
2382
2383 LET $items: LIST<STRING> = ["a", "b"]
2384 ASSERT_CONTAINS $items "a"
2385 ASSERT_CONTAINS $items "b"
2386
2387 LET $count: INT = 42
2388 ASSERT_EQ $count 42
2389 "#},
2390 },
2391 Example {
2392 name: "no hoisting",
2393 fence_meta: Some("expect_error:\"undefined variable\""),
2394 code: indoc! {r#"
2395 # Reading before the LET runs is an error, not an empty value.
2396 ECHO $too_early
2397 LET $too_early: STRING = "too late"
2398 "#},
2399 },
2400 Example {
2401 name: "glob binding",
2402 fence_meta: None,
2403 code: indoc! {r#"
2404 # The RHS is an expression: GLOB(...) runs and binds a list.
2405 IMPORT [STD]
2406 WRITE a.txt "x"
2407 LET $files: LIST<STRING> = GLOB("*.txt")
2408 FOR $f: STRING IN $files { ECHO $f }
2409
2410 ASSERT_CONTAINS stdout "a.txt"
2411 "#},
2412 },
2413 Example {
2414 name: "scoped variable reverts",
2415 fence_meta: None,
2416 code: indoc! {r#"
2417 # LET inside a braced block reverts when the block exits.
2418 LET $a: STRING = "outer"
2419
2420 [bool:true] {
2421 LET $a: STRING = "inner"
2422 WRITE inner.txt "{{ $a }}"
2423 }
2424
2425 WRITE outer.txt "{{ $a }}"
2426
2427 LET $in_body: STRING = READ inner.txt
2428 ASSERT_EQ $in_body "inner"
2429
2430 LET $out_body: STRING = READ outer.txt
2431 ASSERT_EQ $out_body "outer"
2432 "#},
2433 },
2434 Example {
2435 name: "capture command output",
2436 fence_meta: None,
2437 code: indoc! {r#"
2438 # Capture keeps the trailing newline.
2439 LET $out: STRING = ECHO hi
2440 ASSERT_EQ $out "hi\n"
2441 "#},
2442 },
2443 Example {
2444 name: "inline block",
2445 fence_meta: None,
2446 code: indoc! {r#"
2447 LET $who: STRING = "ada"
2448
2449 # An inline block binds its RETURN value like a function body.
2450 LET $res: STRING = {
2451 LET $loud: STRING = "{{ $who }}!"
2452 RETURN $loud
2453 }
2454 ASSERT_EQ $res "ada!"
2455
2456 # Any declared type works: the block value checks like any RHS.
2457 LET $n: INT = {
2458 RETURN 40 + 2
2459 }
2460 ASSERT_EQ $n 42
2461 "#},
2462 },
2463 Example {
2464 name: "arithmetic over captured output",
2465 fence_meta: None,
2466 code: indoc! {r#"
2467 # Captured output converts explicitly: INT() then arithmetic.
2468 IMPORT [STD]
2469 LET $size_str: STRING = ECHO 41
2470 LET $total: INT = INT($size_str) + 1
2471 ASSERT_EQ $total 42
2472
2473 # FLOAT() promotes instead of truncating.
2474 LET $ratio: FLOAT = 1 + 2.5
2475 ASSERT_EQ $ratio 3.5
2476
2477 # Int x Int stays INT: integer division truncates.
2478 LET $half: INT = 7 / 2
2479 ASSERT_EQ $half 3
2480 "#},
2481 },
2482 Example {
2483 name: "float equality is exact",
2484 fence_meta: None,
2485 code: indoc! {r#"
2486 # Binary fractions compare cleanly; decimal fractions may not:
2487 # 0.1 + 0.2 is 0.30000000000000004, so == is false.
2488 IMPORT [STD]
2489 LET $exact: BOOL = 0.5 + 0.25 == 0.75
2490 LET $decimal: BOOL = 0.1 + 0.2 == 0.3
2491 IF $exact {
2492 WRITE exact.txt yes
2493 }
2494 IF $decimal {
2495 WRITE unexpected.txt no
2496 }
2497
2498 LET $ok: STRING = READ exact.txt
2499 ASSERT_EQ $ok "yes"
2500
2501 LET $t: STRING = PATH_TYPE("unexpected.txt")
2502 ASSERT_EQ $t "absent"
2503 "#},
2504 },
2505 Example {
2506 name: "bound inexact decimals",
2507 fence_meta: None,
2508 code: indoc! {r#"
2509 # Never test inexact decimals for equality; bound them.
2510 LET $sum: FLOAT = 0.1 + 0.2
2511 IF $sum > 0.299999 && $sum < 0.300001 {
2512 WRITE bounded.txt yes
2513 }
2514
2515 LET $ok: STRING = READ bounded.txt
2516 ASSERT_EQ $ok "yes"
2517 "#},
2518 },
2519 Example {
2520 name: "inspect a variable",
2521 fence_meta: None,
2522 code: indoc! {r#"
2523 # INSPECT($var) snapshots a variable into a MAP: declared
2524 # type plus live details (pipe backend stats here), so
2525 # scripts can branch on engine state.
2526 IMPORT [STD]
2527 LET $p: PIPE
2528 WITH_IO [stdout=$p] ECHO hello
2529 LET $info: MAP<ANY> = INSPECT($p)
2530 IF $info.is_os_pipe {
2531 WRITE unexpected.txt "should be a script pipe"
2532 }
2533
2534 ASSERT_EQ $info.type "PIPE"
2535 LET $t: STRING = PATH_TYPE("unexpected.txt")
2536 ASSERT_EQ $t "absent"
2537 "#},
2538 },
2539 ],
2540 },
2541 CommandMeta {
2542 name: "MUTATION",
2543 syntax: "$var = <expr>",
2544 summary: "Mutate a declared variable.",
2545 description: indoc! {r#"
2546 Reassigns an existing variable, converting the new value to
2547 the type declared at LET time. The explicit annotation is
2548 what authorizes string-to-number conversion here (`$n = "42"`
2549 binds 42 for an INT); a non-numeric string is an error.
2550 Expressions never convert: `"100" + 1` is a Type Error, use
2551 `INT()` / `FLOAT()` to cross that boundary explicitly.
2552
2553 The leading `$` distinguishes mutation from `KEY=value` command
2554 assignments. Assigning an undeclared variable or a mismatched type is
2555 an error.
2556
2557 Mutation writes through to the scope where the variable was
2558 declared, so it survives block exit: `LET $x` outside a block
2559 followed by `$x = ...` inside still reads back the new value
2560 afterwards, for every type. This is the counterpart to LET
2561 shadowing, where `LET $x` *inside* the block declares a
2562 separate inner variable that reverts on exit.
2563
2564 Reassignment rebinds the variable to a new value: values
2565 captured earlier keep the old word (see the rebinding
2566 example below).
2567 "#},
2568 args: &[],
2569 flags: &[],
2570 default_output: None,
2571 examples: &[
2572 Example {
2573 name: "mutate",
2574 fence_meta: None,
2575 code: indoc! {r#"
2576 # Mutation writes through: the binding holds the new value.
2577 LET $count: INT = 1
2578 $count = 2
2579 ASSERT_EQ $count 2
2580 "#},
2581 },
2582 Example {
2583 name: "rebind keeps earlier captures",
2584 fence_meta: None,
2585 code: indoc! {r#"
2586 # Reassignment rebinds: a map built from `$s` still
2587 # reads the original string after `$s` moves on.
2588 LET $s: STRING = "test"
2589 LET $m: MAP<ANY> = {a: $s}
2590 $s = "changed"
2591 ASSERT_EQ $m.a "test"
2592
2593 # Appending to one list binding never touches its clones.
2594 LET $a: LIST<STRING> = ["1"]
2595 LET $b: LIST<STRING> = $a
2596 LIST_APPEND $a "2"
2597 LET $want: LIST<STRING> = ["1"]
2598 ASSERT_EQ $b $want
2599 "#},
2600 },
2601 Example {
2602 name: "convert before math",
2603 fence_meta: None,
2604 code: indoc! {r#"
2605 # Captured output is a string: `"100" + 1` is a Type Error.
2606 # Convert explicitly, then mutate with arithmetic.
2607 IMPORT [STD]
2608 LET $raw: STRING = ECHO 100
2609 LET $n: INT = INT($raw)
2610 $n = $n + 1
2611
2612 # The declared slot stays strict: strings parse only
2613 # through the explicit converter, never on assignment.
2614 $n = INT("42")
2615 ASSERT_EQ $n 42
2616
2617 # Same crossing for decimals via FLOAT().
2618 LET $frac_str: STRING = ECHO 2.5
2619 LET $f: FLOAT = FLOAT($frac_str) + 0.25
2620 ASSERT_EQ $f 2.75
2621 "#},
2622 },
2623 ],
2624 },
2625 CommandMeta {
2626 name: "ASYNC",
2627 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
2628 summary: "Run steps in a background thread.",
2629 description: indoc! {r#"
2630 Runs a command or block of commands in a background thread with
2631 subshell isolation.
2632
2633 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
2634 task handle for `AWAIT`. Task output streams live to the parent
2635 stdout; a task publishes a value with an explicit `RETURN`,
2636 which `LET $out: TYPE = AWAIT $task` binds.
2637
2638 `WITH_IO` must wrap `ASYNC` from the outside (`LET $p: PIPE` first,
2639 then `WITH_IO [stdout=$p] ASYNC ...`) so pipe endpoints allocate on
2640 the main thread before the worker spawns. Placing `WITH_IO` directly
2641 inside `ASYNC` is rejected at parse time.
2642 "#},
2643 args: &[],
2644 flags: &[],
2645 default_output: None,
2646 examples: &[
2647 Example {
2648 name: "async",
2649 fence_meta: None,
2650 code: indoc! {r#"
2651 # Inline and block forms both run in the background; AWAIT joins them.
2652 ASYNC ECHO "warming-up"
2653 LET $a: HANDLE = ASYNC ECHO "first"
2654 LET $b: HANDLE = ASYNC {
2655 ECHO "second"
2656 }
2657 AWAIT $a
2658 AWAIT $b
2659 ASSERT_CONTAINS stdout "first"
2660 ASSERT_CONTAINS stdout "second"
2661 "#},
2662 },
2663 Example {
2664 name: "async task handle",
2665 fence_meta: None,
2666 code: indoc! {r#"
2667 LET $task: HANDLE = ASYNC {
2668 ECHO "built"
2669 }
2670 AWAIT $task
2671 ASSERT_CONTAINS stdout "built"
2672 "#},
2673 },
2674 ],
2675 },
2676 CommandMeta {
2677 name: "AWAIT",
2678 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2679 summary: "Join a background task.",
2680 description: indoc! {r#"
2681 Blocks until the named task completes. Propagates errors if the task failed.
2682
2683 Task output streams live during the run; joining binds nothing by
2684 itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2685 `RETURN` value instead, or `INT` 0 when the task succeeded
2686 without one (add `RETURN <expr>` to the task body to yield
2687 a value).
2688 "#},
2689 args: &[],
2690 flags: &[],
2691 default_output: None,
2692 examples: &[
2693 Example {
2694 name: "await",
2695 fence_meta: None,
2696 code: indoc! {r#"
2697 LET $task: HANDLE = ASYNC ECHO "done"
2698 AWAIT $task
2699 ASSERT_CONTAINS stdout "done"
2700 "#},
2701 },
2702 Example {
2703 name: "await capture",
2704 fence_meta: None,
2705 code: indoc! {r#"
2706 LET $task: HANDLE = ASYNC {
2707 ECHO "logged"
2708 RETURN "returned"
2709 }
2710
2711 # AWAIT binds the RETURN value, not the streamed output.
2712 LET $out: STRING = AWAIT $task
2713 ASSERT_EQ $out "returned"
2714 "#},
2715 },
2716 ],
2717 },
2718 CommandMeta {
2719 name: "CANCEL",
2720 syntax: "CANCEL $var",
2721 summary: "Synchronously cancel a background task.",
2722 description: indoc! {r#"
2723 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2724
2725 Blocking: returns only after the task thread has been joined and its OS
2726 process reaped, so no residual filesystem or stream mutation follows. A
2727 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2728 "#},
2729 args: &[],
2730 flags: &[],
2731 default_output: None,
2732 examples: &[
2733 Example {
2734 name: "cancel",
2735 fence_meta: None,
2736 code: indoc! {r#"
2737 LET $task: HANDLE = ASYNC SLEEP 30s
2738 CANCEL $task
2739 "#},
2740 },
2741 Example {
2742 name: "await after cancel reports cancellation",
2743 fence_meta: Some("expect_error:\"was cancelled\""),
2744 code: indoc! {r#"
2745 # A cancelled task stays cancelled: joining it reports.
2746 LET $task: HANDLE = ASYNC SLEEP 30s
2747 CANCEL $task
2748 AWAIT $task
2749 "#},
2750 },
2751 ],
2752 },
2753 CommandMeta {
2754 name: "TIMEOUT",
2755 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2756 summary: "Enforce an execution deadline.",
2757 description: indoc! {r#"
2758 Aborts the wrapped step or block with a deadline error if it exceeds the
2759 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2760
2761 A blocking foreground process is killed.
2762
2763 ASYNC composes in either nesting order with order-dependent deadline
2764 semantics: `LET $task: HANDLE = ASYNC TIMEOUT 30s RUN "build"` enforces
2765 the deadline inside the background thread (a later `AWAIT $task`
2766 surfaces the `TIMEOUT` error), while `TIMEOUT 30s AWAIT $task`
2767 preempts a hung task from the awaiting side.
2768 "#},
2769 args: &[],
2770 flags: &[],
2771 default_output: None,
2772 examples: &[
2773 Example {
2774 name: "timeout",
2775 fence_meta: None,
2776 code: indoc! {r#"
2777 TIMEOUT 30s WRITE heartbeat.txt alive
2778 LET $beat: STRING = READ heartbeat.txt
2779 ASSERT_EQ $beat "alive"
2780 "#},
2781 },
2782 Example {
2783 name: "timeout block",
2784 fence_meta: None,
2785 code: indoc! {r#"
2786 TIMEOUT 30s {
2787 WRITE a.txt one
2788 WRITE b.txt two
2789 }
2790 LET $a: STRING = READ a.txt
2791 LET $b: STRING = READ b.txt
2792 ASSERT_EQ $a "one"
2793 ASSERT_EQ $b "two"
2794 "#},
2795 },
2796 Example {
2797 name: "deadline aborts the step",
2798 fence_meta: Some("expect_error:\"TIMEOUT after\""),
2799 code: indoc! {r#"
2800 # 50ms expires long before the sleep does: the step dies
2801 # with a deadline error instead of running out the clock.
2802 TIMEOUT 50ms SLEEP 30s
2803 "#},
2804 },
2805 Example {
2806 name: "timeout variable duration",
2807 fence_meta: None,
2808 code: indoc! {r#"
2809 # Durations resolve at runtime, so variables work too.
2810 LET $budget: DURATION = "30s"
2811 TIMEOUT $budget WRITE heartbeat.txt alive
2812
2813 LET $beat: STRING = READ heartbeat.txt
2814 ASSERT_EQ $beat "alive"
2815 "#},
2816 },
2817 Example {
2818 name: "timeout await",
2819 fence_meta: None,
2820 code: indoc! {r#"
2821 # AWAIT form bounds a task join instead of a step.
2822 LET $quick: HANDLE = ASYNC {
2823 ECHO hi
2824 }
2825 TIMEOUT 30s AWAIT $quick
2826 "#},
2827 },
2828 ],
2829 },
2830 CommandMeta {
2831 name: "REMOTE",
2832 syntax: "REMOTE <target> [[$var, ...] [env:NAME, ...]] { <commands> }",
2833 summary: "Run a sealed block on a remote target.",
2834 description: indoc! {r#"
2835 Runs the braced block on the named remote target, which the
2836 host binds at startup (`--remote`). The block is sealed:
2837 only header-listed `$var` and `env:NAME` entries cross, the
2838 guest starts empty, and results return through declared
2839 transfers or `WITH_IO` pipes. The target is a static
2840 literal, never a variable. Unknown targets fail before
2841 execution starts. Bodies check twice: the host static
2842 pass validates the body before anything ships, and the
2843 guest re-validates on arrival through the same entry
2844 point, so a body type error fails locally, never mid-run
2845 on the remote. A failing guest fails the host step: EXIT
2846 in the body exits the guest, and the host relays the code
2847 to its own process exit status (see EXIT).
2848 "#},
2849 args: &[],
2850 flags: &[],
2851 default_output: None,
2852 examples: &[
2853 Example {
2854 name: "sealed header values cross, outer scope does not",
2855 fence_meta: Some("mock_remote:prod"),
2856 code: indoc! {r#"
2857 # Only header-listed names arrive: `$version` is visible
2858 # on the guest, `$secret` was never sent.
2859 LET $version: STRING = "1.2.3"
2860 LET $secret: STRING = "host-only"
2861 REMOTE prod [$version] {
2862 WRITE version.txt "{{ $version }}"
2863 COPY --to-host ./version.txt back.txt
2864 }
2865
2866 # The pushed file proves the guest saw the injected value.
2867 LET $back: STRING = READ back.txt
2868 ASSERT_EQ $back "1.2.3"
2869 "#},
2870 },
2871 Example {
2872 name: "background tasks stream through shared pipes",
2873 fence_meta: Some("mock_remote:prod"),
2874 code: indoc! {r#"
2875 # Two background REMOTEs share one capture pipe while a
2876 # WITH_IO block holds the write end open.
2877 IMPORT [STD]
2878 LET $cap: PIPE
2879 WITH_IO [stdout=$cap] {
2880 LET $a: HANDLE = ASYNC REMOTE prod {
2881 ECHO "from-a"
2882 }
2883 LET $b: HANDLE = ASYNC REMOTE prod {
2884 ECHO "from-b"
2885 }
2886 AWAIT $a
2887 AWAIT $b
2888 }
2889
2890 # EOF ends the drain: no sentinel line, no baked-in count.
2891 LET $n: INT = 0
2892 WHILE !EOF($cap) {
2893 WITH_IO [stdin=$cap] READ_LINE $line
2894 $n = $n + 1
2895 }
2896 ASSERT_EQ $n 2
2897 "#},
2898 },
2899 Example {
2900 name: "declared files cross both directions",
2901 fence_meta: Some("mock_remote:prod roots:unified"),
2902 code: indoc! {r#"
2903 # Fetches ride in, pushes ride back. Anything undeclared
2904 # never leaves its side.
2905 WRITE send.txt "payload"
2906 REMOTE prod {
2907 COPY --from-host ./send.txt got.txt
2908 LET $v: STRING = READ got.txt
2909 ASSERT_EQ $v "payload"
2910 WRITE back.txt "returned"
2911 COPY --to-host ./back.txt back.txt
2912 }
2913
2914 # The push landed on the host; guest staging is gone.
2915 LET $b: STRING = READ back.txt
2916 ASSERT_EQ $b "returned"
2917 "#},
2918 },
2919 Example {
2920 name: "guest workspace selection stays on the guest",
2921 fence_meta: Some("mock_remote:prod"),
2922 code: indoc! {r#"
2923 # WORKSPACE switches inside the block resolve against the
2924 # guest staging dir, which serves as both the SNAPSHOT
2925 # root and the LOCAL build context. The host selection
2926 # restores when the block exits.
2927 REMOTE prod {
2928 WORKSPACE SNAPSHOT
2929 WRITE out.txt "from-snapshot"
2930 COPY --to-host ./out.txt snap.txt
2931 }
2932
2933 LET $s: STRING = READ snap.txt
2934 ASSERT_EQ $s "from-snapshot"
2935 "#},
2936 },
2937 Example {
2938 name: "guest cache persists across execs",
2939 fence_meta: Some("mock_remote:prod"),
2940 code: indoc! {r#"
2941 # WORKSPACE CACHE addresses guest-local persistent
2942 # storage: the second block reads what the first wrote,
2943 # while the guest staging starts empty every time.
2944 REMOTE prod {
2945 WORKSPACE CACHE
2946 WRITE marker.txt "run-marker"
2947 }
2948 REMOTE prod {
2949 WORKSPACE CACHE
2950 LET $m: STRING = READ marker.txt
2951 ASSERT_EQ $m "run-marker"
2952 }
2953 "#},
2954 },
2955 Example {
2956 name: "pushes outside staging fail closed",
2957 fence_meta: Some("mock_remote:prod expect_error:\"escapes staging\""),
2958 code: indoc! {r#"
2959 # WORKSPACE SYSTEM exposes the whole guest machine, but
2960 # transfers stay staging scoped: a push source outside
2961 # staging fails loudly instead of crossing.
2962 REMOTE prod {
2963 WORKSPACE SYSTEM
2964 COPY --to-host /no/such/file.txt got.txt
2965 }
2966 "#},
2967 },
2968 Example {
2969 name: "guest exit fails the step, host survives",
2970 fence_meta: Some(
2971 "mock_remote:prod expect_error:\"REMOTE 'prod' exited with code 3\"",
2972 ),
2973 code: indoc! {r#"
2974 # EXIT in the body exits the guest with code 3. The
2975 # host is unaffected: it keeps executing after the
2976 # guest is already dead, and the failure surfaces as
2977 # a host step error carrying the same code. The CLI
2978 # then exits with it, so the guest code reaches the OS.
2979 LET $t: HANDLE = ASYNC REMOTE prod {
2980 EXIT 3
2981 }
2982 WRITE host-alive.txt "still here"
2983 AWAIT $t
2984 "#},
2985 },
2986 Example {
2987 name: "remote block without a runner",
2988 fence_meta: Some("expect_error:\"has no registered session\""),
2989 code: indoc! {r#"
2990 # Without a bound session, the block bails instead of
2991 # executing anywhere unexpected.
2992 REMOTE prod {
2993 ECHO "do something on remote"
2994 }
2995 "#},
2996 },
2997 ],
2998 },
2999 CommandMeta {
3000 name: "FUNC",
3001 syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
3002 summary: "Define a user function.",
3003 description: indoc! {r#"
3004 Defines a user function with UPPERCASE name and explicitly typed
3005 parameters.
3006
3007 Params bind by position, converting each argument to its
3008 declared parameter type before the body runs.
3009 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
3010 FUNC definition is scoped to its block and reverts on exit. Names share
3011 one namespace with native and host-registered functions, which a FUNC
3012 may never shadow.
3013
3014 Functions resolve like variables: a name is visible from its
3015 definition line, so recursion works but mutual recursion does
3016 not (the second name does not exist while the first body
3017 lowers). Calls name their module (`STD::GLOB(...)`) unless
3018 imported; see IMPORT.
3019
3020 Invoke any function with one syntax: `NAME(...)` as a statement
3021 (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
3022 the RETURN value (fallthrough without RETURN captures as "").
3023 "#},
3024 args: &[],
3025 flags: &[],
3026 default_output: None,
3027 examples: &[
3028 Example {
3029 name: "func def call",
3030 fence_meta: None,
3031 code: indoc! {r#"
3032 FUNC GREET($name: STRING) {
3033 RETURN $name
3034 }
3035
3036 LET $res: STRING = GREET("ada")
3037 ASSERT_EQ $res "ada"
3038
3039 # Statement form: parens stay, the value drops.
3040 GREET("bex")
3041 "#},
3042 },
3043 Example {
3044 name: "call with pipes",
3045 fence_meta: None,
3046 code: indoc! {r#"
3047 # A pipe handle travels into a function as a typed argument
3048 # and is usable as a binding target in both directions.
3049 # `LET $p: PIPE` mints the handle; `$p` passes it on.
3050 FUNC DRAIN($q: PIPE) {
3051 WITH_IO [stdin=$q] READ_LINE $line
3052 RETURN $line
3053 }
3054
3055 LET $p: PIPE
3056 WITH_IO [stdout=$p] ECHO "payload"
3057
3058 LET $got: STRING = DRAIN($p)
3059 ASSERT_EQ $got "payload"
3060 "#},
3061 },
3062 ],
3063 },
3064 CommandMeta {
3065 name: "RETURN",
3066 syntax: "RETURN [<expr>]",
3067 summary: "Return a value from a function, task, or inline block.",
3068 description: indoc! {r#"
3069 Ends the nearest enclosing boundary with a value: a function
3070 call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
3071 inline `LET` block. Bare `RETURN` with no expression yields
3072 `""`.
3073
3074 Falling off the end without RETURN yields "". RETURN with no
3075 enclosing boundary (including at top level) is an error; use
3076 EXIT or ECHO there.
3077 "#},
3078 args: &[],
3079 flags: &[],
3080 default_output: None,
3081 examples: &[Example {
3082 name: "return",
3083 fence_meta: None,
3084 code: indoc! {r#"
3085 FUNC PICK($flag: BOOL) {
3086 IF $flag {
3087 RETURN "yes"
3088 }
3089 RETURN "no"
3090 }
3091
3092 LET $res: STRING = PICK(true)
3093 ASSERT_EQ $res "yes"
3094
3095 # Fallthrough without RETURN yields its own value.
3096 LET $no: STRING = PICK(false)
3097 ASSERT_EQ $no "no"
3098 "#},
3099 }],
3100 },
3101 CommandMeta {
3102 name: "WHILE",
3103 syntax: "WHILE <bool-expr> { <commands> }",
3104 summary: "Loop while a condition holds.",
3105 description: indoc! {r#"
3106 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
3107 non-Bool is a type error).
3108
3109 Each iteration runs in a fresh scope; mutate outer state with $var = ...
3110 so the next check observes it. BREAK exits the loop; CONTINUE skips to
3111 the next check.
3112 "#},
3113 args: &[],
3114 flags: &[],
3115 default_output: None,
3116 examples: &[Example {
3117 name: "while loop",
3118 fence_meta: None,
3119 code: indoc! {r#"
3120 # The condition re-evaluates every iteration: three passes, then stop.
3121 LET $n: INT = 0
3122 WHILE $n < 3 {
3123 WRITE tick.txt "{{ $n }}"
3124 $n = $n + 1
3125 }
3126
3127 ASSERT_EQ $n 3
3128 LET $tick: STRING = READ tick.txt
3129 ASSERT_EQ $tick "2"
3130 "#},
3131 }],
3132 },
3133 CommandMeta {
3134 name: "BREAK",
3135 syntax: "BREAK",
3136 summary: "Exit the innermost loop.",
3137 description: indoc! {r#"
3138 Exits the innermost enclosing FOR or WHILE loop.
3139
3140 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
3141 "#},
3142 args: &[],
3143 flags: &[],
3144 default_output: None,
3145 examples: &[Example {
3146 name: "break",
3147 fence_meta: None,
3148 code: indoc! {r#"
3149 # BREAK leaves after the first pass: only "a" is written.
3150 FOR $x: STRING IN ["a", "b"] {
3151 WRITE picked.txt "{{ $x }}"
3152 BREAK
3153 }
3154
3155 LET $body: STRING = READ picked.txt
3156 ASSERT_EQ $body "a"
3157 "#},
3158 }],
3159 },
3160 CommandMeta {
3161 name: "CONTINUE",
3162 syntax: "CONTINUE",
3163 summary: "Skip to the next loop iteration.",
3164 description: indoc! {r#"
3165 Skips the rest of the innermost enclosing FOR or WHILE body and starts
3166 the next iteration.
3167
3168 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
3169 "#},
3170 args: &[],
3171 flags: &[],
3172 default_output: None,
3173 examples: &[Example {
3174 name: "continue",
3175 fence_meta: None,
3176 code: indoc! {r#"
3177 # CONTINUE skips the write on "a": only "b" lands.
3178 FOR $x: STRING IN ["a", "b"] {
3179 IF $x == "a" {
3180 CONTINUE
3181 }
3182 WRITE picked.txt "{{ $x }}"
3183 }
3184
3185 LET $body: STRING = READ picked.txt
3186 ASSERT_EQ $body "b"
3187 "#},
3188 }],
3189 },
3190 CommandMeta {
3191 name: KEYWORD_IMPORT,
3192 syntax: "IMPORT [<module>, ...] | IMPORT <module>",
3193 summary: "Bring module functions into bare-call scope.",
3194 description: indoc! {r#"
3195 Every function call names its module (`STD::GLOB(...)`,
3196 `MOCK::READ_CSV(...)`) unless the module is imported:
3197 `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
3198 bare. Calls resolve at parse time against `SCRIPT`
3199 definitions first, then imported modules; unknown modules,
3200 unknown functions, and unimported bare calls are parse
3201 errors, never runtime surprises.
3202
3203 IMPORT is a lowering directive, not a step: it applies from
3204 its line to the enclosing block exit, then reverts, exactly
3205 like `LET` scoping but with no runtime footprint. Guards do
3206 not apply to it. Two imported modules exporting one name is
3207 an ambiguity error: qualify the call instead.
3208
3209 `EXPORT` is reserved for future script-module support and
3210 cannot be used yet.
3211 "#},
3212 args: &[],
3213 flags: &[],
3214 default_output: None,
3215 examples: &[Example {
3216 name: "import",
3217 fence_meta: None,
3218 code: indoc! {r#"
3219 # Calls name their module (STD::GLOB); IMPORT [STD] drops the prefix.
3220 WRITE a.txt "hi \{{ env:WHO }}!"
3221 IMPORT [STD]
3222 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
3223 ASSERT_CONTAINS stdout "hi World!"
3224 "#},
3225 }],
3226 },
3227 ]
3228}
3229
3230impl fmt::Display for StepKind {
3233 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
3234 match self {
3235 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
3236 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
3237 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
3238 StepKind::Env { key, value } => {
3239 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
3240 }
3241 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
3242 StepKind::RunExec { argv } => {
3243 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
3244 write!(f, "RUN [{}]", parts.join(", "))
3245 }
3246 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
3247 StepKind::Copy {
3248 from_workspace,
3249 from_host,
3250 to_host,
3251 from,
3252 to,
3253 } => {
3254 let mut head = String::from("COPY");
3255 if let Some(target) = from_workspace {
3256 head.push_str(&format!(" --from-workspace {target}"));
3257 }
3258 if *from_host {
3259 head.push_str(" --from-host");
3260 }
3261 if *to_host {
3262 head.push_str(" --to-host");
3263 }
3264 write!(
3265 f,
3266 "{head} {} {}",
3267 fmt_value(from, quote_arg),
3268 fmt_value(to, quote_arg)
3269 )
3270 }
3271 StepKind::Symlink {
3272 from_workspace,
3273 from,
3274 to,
3275 } => {
3276 if let Some(target) = from_workspace {
3277 write!(
3278 f,
3279 "SYMLINK --from-workspace {} {} {}",
3280 target,
3281 fmt_value(from, quote_arg),
3282 fmt_value(to, quote_arg)
3283 )
3284 } else {
3285 write!(
3286 f,
3287 "SYMLINK {} {}",
3288 fmt_value(from, quote_arg),
3289 fmt_value(to, quote_arg)
3290 )
3291 }
3292 }
3293 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
3294 StepKind::Install { from, to } => write!(
3295 f,
3296 "INSTALL {} {}",
3297 fmt_value(from, quote_arg),
3298 fmt_value(to, quote_arg)
3299 ),
3300 StepKind::Archive { src, dst, raw } => {
3301 if *raw {
3302 write!(
3303 f,
3304 "ARCHIVE --raw {} {}",
3305 fmt_value(src, quote_arg),
3306 fmt_value(dst, quote_arg)
3307 )
3308 } else {
3309 write!(
3310 f,
3311 "ARCHIVE {} {}",
3312 fmt_value(src, quote_arg),
3313 fmt_value(dst, quote_arg)
3314 )
3315 }
3316 }
3317 StepKind::Unarchive {
3318 src,
3319 dst,
3320 max_bytes,
3321 } => {
3322 write!(f, "UNARCHIVE")?;
3323 if let Some(n) = max_bytes {
3324 write!(f, " --max-bytes {}", fmt_value(n, quote_arg))?;
3325 }
3326 write!(
3327 f,
3328 " {} {}",
3329 fmt_value(src, quote_arg),
3330 fmt_value(dst, quote_arg)
3331 )
3332 }
3333 StepKind::Ls(a) => {
3334 write!(f, "LS")?;
3335 if let Some(x) = a {
3336 write!(f, " {}", fmt_value(x, quote_arg))?;
3337 }
3338 Ok(())
3339 }
3340 StepKind::Cwd => write!(f, "CWD"),
3341 StepKind::Read(a) => {
3342 write!(f, "READ")?;
3343 if let Some(x) = a {
3344 write!(f, " {}", fmt_value(x, quote_arg))?;
3345 }
3346 Ok(())
3347 }
3348 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
3349 StepKind::Prompt {
3350 var,
3351 message,
3352 default,
3353 } => {
3354 write!(f, "PROMPT ${} {}", var, fmt_value(message, quote_arg))?;
3355 if let Some(d) = default {
3356 write!(f, " {}", fmt_value(d, quote_arg))?;
3357 }
3358 Ok(())
3359 }
3360 StepKind::Write { path, contents } => {
3361 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
3362 if let Some(b) = contents {
3363 write!(f, " {}", fmt_value(b, quote_msg))?;
3364 }
3365 Ok(())
3366 }
3367 StepKind::Append { path, contents } => {
3368 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
3369 if let Some(b) = contents {
3370 write!(f, " {}", fmt_value(b, quote_msg))?;
3371 }
3372 Ok(())
3373 }
3374 StepKind::Remove { path, recursive } => {
3375 if *recursive {
3376 write!(f, "REMOVE --recursive {}", fmt_value(path, quote_arg))
3377 } else {
3378 write!(f, "REMOVE {}", fmt_value(path, quote_arg))
3379 }
3380 }
3381 StepKind::Expand { path, overrides } => {
3382 write!(f, "EXPAND")?;
3383 if let Some(p) = path {
3384 write!(f, " {}", fmt_value(p, quote_arg))?;
3385 }
3386 for (k, v) in overrides {
3387 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
3388 }
3389 Ok(())
3390 }
3391 StepKind::AssertEq {
3392 hash,
3393 actual,
3394 expected,
3395 } => {
3396 if let Some(d) = hash {
3397 write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
3398 } else {
3399 write!(
3400 f,
3401 "ASSERT_EQ {} {}",
3402 fmt_assert_target(actual),
3403 fmt_value(
3404 expected
3405 .as_ref()
3406 .expect("Display of ASSERT_EQ without --hash needs expected"),
3407 quote_msg
3408 )
3409 )?;
3410 }
3411 Ok(())
3412 }
3413 StepKind::AssertContains { haystack, needle } => write!(
3414 f,
3415 "ASSERT_CONTAINS {} {}",
3416 fmt_assert_target(haystack),
3417 fmt_value(needle, quote_msg)
3418 ),
3419 StepKind::WithIo { bindings, cmd } => {
3420 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3421 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
3422 }
3423 StepKind::WithIoBlock { bindings } => {
3424 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
3425 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
3426 }
3427 StepKind::CopyGit {
3428 rev,
3429 from,
3430 to,
3431 include_dirty,
3432 } => {
3433 if *include_dirty {
3434 write!(
3435 f,
3436 "COPY_GIT --include-dirty {} {} {}",
3437 fmt_value(rev, quote_arg),
3438 fmt_value(from, quote_arg),
3439 fmt_value(to, quote_arg)
3440 )
3441 } else {
3442 write!(
3443 f,
3444 "COPY_GIT {} {} {}",
3445 fmt_value(rev, quote_arg),
3446 fmt_value(from, quote_arg),
3447 fmt_value(to, quote_arg)
3448 )
3449 }
3450 }
3451 StepKind::HashSha256 { path } => {
3452 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
3453 }
3454 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
3455 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
3456 StepKind::ListAppend { list, item } => {
3457 write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
3458 }
3459 StepKind::For {
3460 key_var,
3461 key_type,
3462 var,
3463 var_type,
3464 in_expr,
3465 body,
3466 } => {
3467 match key_var {
3468 Some(k) => {
3469 let kt = key_type.as_deref().unwrap_or("STRING");
3470 write!(
3471 f,
3472 "FOR ${}: {}, ${}: {} IN {} {{",
3473 k, kt, var, var_type, in_expr
3474 )?
3475 }
3476 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
3477 }
3478 for s in body {
3479 write!(f, "\n {}", s)?;
3480 }
3481 write!(f, "\n}}")
3482 }
3483 StepKind::If {
3484 cond,
3485 then_body,
3486 else_ifs,
3487 else_body,
3488 } => {
3489 write!(f, "IF {} {{", cond)?;
3490 for s in then_body {
3491 write!(f, "\n {}", s)?;
3492 }
3493 write!(f, " }}")?;
3494 for (c, b) in else_ifs {
3495 write!(f, " ELSE IF {} {{", c)?;
3496 for s in b {
3497 write!(f, "\n {}", s)?;
3498 }
3499 write!(f, " }}")?;
3500 }
3501 if let Some(b) = else_body {
3502 write!(f, " ELSE {{")?;
3503 for s in b {
3504 write!(f, "\n {}", s)?;
3505 }
3506 write!(f, " }}")?;
3507 }
3508 Ok(())
3509 }
3510 StepKind::Assign {
3511 var,
3512 decl_type,
3513 expr,
3514 } => {
3515 if matches!(expr, Expr::FreshPipe) {
3517 write!(f, "LET ${}: {}", var, decl_type)
3518 } else {
3519 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
3520 }
3521 }
3522 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
3523 StepKind::AssignCapture {
3524 var,
3525 decl_type,
3526 cmd,
3527 } => {
3528 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
3529 }
3530 StepKind::AsyncBlock { body } => {
3531 write!(f, "ASYNC {{")?;
3532 for s in body {
3533 write!(f, "\n {}", s)?;
3534 }
3535 write!(f, "\n}}")
3536 }
3537 StepKind::AssignAsync {
3538 var,
3539 decl_type,
3540 body,
3541 } => {
3542 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
3543 for s in body {
3544 write!(f, "\n {}", s)?;
3545 }
3546 write!(f, "\n}}")
3547 }
3548 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
3549 StepKind::AwaitCapture {
3550 out_var,
3551 out_type,
3552 task_var,
3553 } => {
3554 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
3555 }
3556 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
3557 StepKind::Timeout { duration, body } => {
3558 let budget = fmt_raw_arg(duration);
3559 if body.len() == 1 {
3560 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
3561 } else {
3562 write!(f, "TIMEOUT {} {{", budget)?;
3563 for s in body {
3564 write!(f, "\n {}", s)?;
3565 }
3566 write!(f, "\n}}")
3567 }
3568 }
3569 StepKind::RemoteBlock {
3570 target,
3571 vars,
3572 env,
3573 body,
3574 } => {
3575 let mut entries: Vec<String> = vars.iter().map(|v| format!("${v}")).collect();
3576 entries.extend(env.iter().map(|e| format!("env:{e}")));
3577 if entries.is_empty() {
3578 write!(f, "REMOTE {target} {{")?;
3579 } else {
3580 write!(f, "REMOTE {target} [{}] {{", entries.join(", "))?;
3581 }
3582 for s in body {
3583 write!(f, "\n {}", s)?;
3584 }
3585 write!(f, "\n}}")
3586 }
3587 StepKind::FuncDef { name, params, body } => {
3588 let ps: Vec<String> = params
3589 .iter()
3590 .map(|(p, t)| format!("${}: {}", p, t))
3591 .collect();
3592 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
3593 for s in body {
3594 write!(f, "\n {}", s)?;
3595 }
3596 write!(f, "\n}}")
3597 }
3598 StepKind::Call { name, args } => {
3599 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
3600 write!(f, "{}({})", name, ps.join(", "))
3601 }
3602 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
3603 StepKind::TypeAlias { name, target } => write!(f, "TYPE {} = {}", name, target),
3604 StepKind::While { cond, body } => {
3605 write!(f, "WHILE {} {{", cond)?;
3606 for s in body {
3607 write!(f, "\n {}", s)?;
3608 }
3609 write!(f, "\n}}")
3610 }
3611 StepKind::Break => write!(f, "BREAK"),
3612 StepKind::Continue => write!(f, "CONTINUE"),
3613 }
3614 }
3615}
3616
3617#[cfg(test)]
3618mod tests {
3619 use super::*;
3620 use crate::command::{
3621 format_byte_count, format_duration, parse_byte_count, parse_duration,
3622 strip_digit_separators,
3623 };
3624 use crate::parser::parse_script;
3625
3626 fn parse_err(script: &str) -> String {
3627 parse_script(script, lower_command)
3628 .expect_err("script must fail to parse")
3629 .to_string()
3630 }
3631
3632 #[test]
3633 fn malformed_with_io_binding_names_the_bad_binding() {
3634 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
3635 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
3636 assert!(!err.contains("unknown command"), "{err}");
3637 assert!(err.contains("stdout=discard"), "{err}");
3638 assert!(err.contains("[stdout=$p]"), "{err}");
3639 }
3640
3641 #[test]
3642 fn connect_is_unknown_command() {
3643 let err = parse_err("CONNECT 127.0.0.1:8080\n");
3646 assert!(err.contains("unknown command"), "{err}");
3647 assert!(err.contains("CONNECT"), "{err}");
3648 }
3649
3650 #[test]
3651 fn listen_is_unknown_command() {
3652 let err = parse_err("LISTEN 127.0.0.1:8080\n");
3655 assert!(err.contains("unknown command"), "{err}");
3656 assert!(err.contains("LISTEN"), "{err}");
3657 }
3658
3659 #[test]
3660 fn await_without_task_variable_points_at_syntax() {
3661 let err = parse_err("AWAIT ECHO \"test\"\n");
3662 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
3663 assert!(!err.contains("unknown command"), "{err}");
3664 assert!(err.contains("AWAIT $t"), "{err}");
3665 assert!(err.contains("ECHO"), "{err}");
3666 }
3667
3668 #[test]
3669 fn bare_let_without_type_points_at_typed_syntax() {
3670 let err = parse_err("LET $x = 1\n");
3671 assert!(err.contains("invalid syntax for command LET"), "{err}");
3672 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
3673 }
3674
3675 #[test]
3676 fn workspace_accepts_all_four_targets_uppercase_only() {
3677 for (spelling, target) in [
3680 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3681 ("LOCAL", WorkspaceTarget::Local),
3682 ("CACHE", WorkspaceTarget::Cache { local: false }),
3683 ("SYSTEM", WorkspaceTarget::System),
3684 ] {
3685 let steps =
3686 parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
3687 assert_eq!(steps.len(), 1);
3688 assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
3689 assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
3690 }
3691 for spelling in ["snapshot", "local", "cache", "system"] {
3692 let err = parse_err(&format!("WORKSPACE {spelling}\n"));
3693 assert!(
3694 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3695 "{spelling}: {err}"
3696 );
3697 }
3698 let err = parse_err("WORKSPACE REMOTE\n");
3699 assert!(
3700 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
3701 "{err}"
3702 );
3703
3704 let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
3707 assert_eq!(
3708 steps[0].kind,
3709 StepKind::Workspace(WorkspaceTarget::Cache { local: true })
3710 );
3711 assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
3712 for bad in [
3713 "WORKSPACE SNAPSHOT --local\n",
3714 "WORKSPACE LOCAL --local\n",
3715 "WORKSPACE SYSTEM --local\n",
3716 ] {
3717 let err = parse_err(bad);
3718 assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
3719 }
3720 }
3721
3722 #[test]
3723 fn archive_unarchive_parse_and_round_trip() {
3724 let steps = parse_script("ARCHIVE bundle bundle.tar.gz\n", lower_command).expect("parses");
3725 match &steps[0].kind {
3726 StepKind::Archive { src, dst, raw } => {
3727 assert_eq!(src.as_str(), "bundle");
3728 assert_eq!(dst.as_str(), "bundle.tar.gz");
3729 assert!(!raw);
3730 }
3731 other => panic!("expected Archive, got {other:?}"),
3732 }
3733 assert_eq!(
3734 steps[0].kind.to_string(),
3735 "ARCHIVE bundle \"bundle.tar.gz\""
3736 );
3737
3738 let steps =
3739 parse_script("ARCHIVE --raw tool.bin tool.tar\n", lower_command).expect("parses");
3740 assert!(
3741 matches!(&steps[0].kind, StepKind::Archive { raw: true, .. }),
3742 "unexpected lowering: {:?}",
3743 steps[0].kind
3744 );
3745 assert_eq!(
3746 steps[0].kind.to_string(),
3747 "ARCHIVE --raw \"tool.bin\" \"tool.tar\""
3748 );
3749
3750 let steps =
3751 parse_script("UNARCHIVE pack.tar.gz unpacked\n", lower_command).expect("parses");
3752 match &steps[0].kind {
3753 StepKind::Unarchive {
3754 src,
3755 dst,
3756 max_bytes,
3757 } => {
3758 assert_eq!(src.as_str(), "pack.tar.gz");
3759 assert_eq!(dst.as_str(), "unpacked");
3760 assert_eq!(max_bytes, &None);
3761 }
3762 other => panic!("expected Unarchive, got {other:?}"),
3763 }
3764 assert_eq!(
3765 steps[0].kind.to_string(),
3766 "UNARCHIVE \"pack.tar.gz\" unpacked"
3767 );
3768
3769 let steps = parse_script(
3770 "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3771 lower_command,
3772 )
3773 .expect("parses");
3774 match &steps[0].kind {
3775 StepKind::Unarchive {
3776 src,
3777 dst,
3778 max_bytes,
3779 } => {
3780 assert_eq!(src.as_str(), "pack.tar.gz");
3781 assert_eq!(dst.as_str(), "unpacked");
3782 assert_eq!(max_bytes.as_ref().map(|a| a.as_str()), Some("100_000"));
3783 }
3784 other => panic!("expected Unarchive, got {other:?}"),
3785 }
3786 assert_eq!(
3787 steps[0].kind.to_string(),
3788 "UNARCHIVE --max-bytes \"100_000\" \"pack.tar.gz\" unpacked"
3789 );
3790
3791 for script in [
3792 "ARCHIVE bundle bundle.tar.gz\n",
3793 "ARCHIVE --raw tool.bin tool.tar\n",
3794 "UNARCHIVE pack.tar.gz unpacked\n",
3795 "UNARCHIVE pack.tar.gz unpacked --max-bytes 100_000\n",
3796 ] {
3797 let first = parse_script(script, lower_command).expect("parses");
3798 let rendered = first[0].kind.to_string() + "\n";
3799 let second = parse_script(&rendered, lower_command).expect("reparses");
3800 assert_eq!(
3803 second[0].kind.to_string(),
3804 first[0].kind.to_string(),
3805 "Display must fixpoint for {script:?}"
3806 );
3807 }
3808
3809 for bad in ["ARCHIVE only-src\n", "ARCHIVE\n", "UNARCHIVE only-src\n"] {
3810 let err = parse_err(bad);
3811 assert!(
3812 err.contains("ARCHIVE") || err.contains("UNARCHIVE"),
3813 "{bad}: {err}"
3814 );
3815 }
3816 }
3817
3818 #[test]
3819 fn copy_from_workspace_selects_source_root() {
3820 for (spelling, target) in [
3821 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3822 ("LOCAL", WorkspaceTarget::Local),
3823 ("CACHE", WorkspaceTarget::Cache { local: false }),
3824 ("SYSTEM", WorkspaceTarget::System),
3825 ] {
3826 let steps = parse_script(
3827 &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
3828 lower_command,
3829 )
3830 .expect("parses");
3831 assert!(
3832 matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
3833 "unexpected lowering for {spelling}: {:?}",
3834 steps[0].kind
3835 );
3836 }
3837 for script in [
3840 "COPY --from-workspace=CACHE a.txt b.txt\n",
3841 "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
3842 ] {
3843 let steps = parse_script(script, lower_command).expect("parses");
3844 assert!(
3845 matches!(
3846 &steps[0].kind,
3847 StepKind::Copy {
3848 from_workspace: Some(WorkspaceTarget::Cache { local: false }),
3849 ..
3850 }
3851 ),
3852 "unexpected lowering for {script:?}: {:?}",
3853 steps[0].kind
3854 );
3855 }
3856 let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
3858 assert!(
3859 matches!(
3860 &steps[0].kind,
3861 StepKind::Copy {
3862 from_workspace: None,
3863 ..
3864 }
3865 ),
3866 "unexpected lowering: {:?}",
3867 steps[0].kind
3868 );
3869 for bad in ["REMOTE", "local"] {
3871 let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
3872 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3873 }
3874 }
3875
3876 #[test]
3877 fn symlink_from_workspace_selects_source_root() {
3878 for (spelling, target) in [
3879 ("SNAPSHOT", WorkspaceTarget::Snapshot),
3880 ("LOCAL", WorkspaceTarget::Local),
3881 ("CACHE", WorkspaceTarget::Cache { local: false }),
3882 ("SYSTEM", WorkspaceTarget::System),
3883 ] {
3884 let steps = parse_script(
3885 &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
3886 lower_command,
3887 )
3888 .expect("parses");
3889 assert!(
3890 matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
3891 "unexpected lowering for {spelling}: {:?}",
3892 steps[0].kind
3893 );
3894 let roundtrip = steps[0].kind.to_string();
3895 assert!(
3896 roundtrip.contains("--from-workspace"),
3897 "display should round-trip the flag: {roundtrip}"
3898 );
3899 }
3900 let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
3901 assert!(
3902 matches!(
3903 &steps[0].kind,
3904 StepKind::Symlink {
3905 from_workspace: None,
3906 ..
3907 }
3908 ),
3909 "unexpected lowering: {:?}",
3910 steps[0].kind
3911 );
3912 for bad in ["REMOTE", "local"] {
3913 let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
3914 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
3915 }
3916 let steps = parse_script(
3918 "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
3919 lower_command,
3920 )
3921 .expect("parses");
3922 assert!(
3923 matches!(
3924 &steps[0].kind,
3925 StepKind::Symlink {
3926 from_workspace: Some(WorkspaceTarget::Local),
3927 ..
3928 }
3929 ),
3930 "unexpected lowering: {:?}",
3931 steps[0].kind
3932 );
3933 }
3934
3935 #[test]
3936 fn dash_dash_equals_tokens_bypass_assignment() {
3937 let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
3940 let StepKind::Env { key, value } = &steps[0].kind else {
3941 panic!("expected Env, got {:?}", steps[0].kind);
3942 };
3943 assert_eq!(key, "--foo");
3944 assert_eq!(value.as_str(), "bar");
3945
3946 let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
3947 let StepKind::Run(cmd) = &steps[0].kind else {
3948 panic!("expected Run, got {:?}", steps[0].kind);
3949 };
3950 assert!(
3951 cmd.as_str().contains("--foo=bar"),
3952 "unexpected RUN lowering: {cmd:?}"
3953 );
3954
3955 let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
3956 let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
3957 .expect("parses");
3958 let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
3959 panic!("expected AssertEq, got {:?}", steps[0].kind);
3960 };
3961 assert_eq!(hash.as_deref(), Some(digest));
3962
3963 let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
3965 let StepKind::Expand { path, overrides } = &steps[0].kind else {
3966 panic!("expected Expand, got {:?}", steps[0].kind);
3967 };
3968 assert!(path.is_none());
3969 assert_eq!(overrides.len(), 1);
3970 assert_eq!(overrides[0].0.as_str(), "--k");
3971 }
3972
3973 #[test]
3974 fn space_before_paren_is_not_a_call() {
3975 let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
3978 assert_eq!(steps.len(), 1);
3979 assert!(
3980 !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
3981 "space before paren must not route to a call: {:?}",
3982 steps[0].kind
3983 );
3984 }
3985
3986 #[test]
3987 fn unknown_type_tag_parses_as_custom() {
3988 let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
3992 let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
3993 panic!("expected Assign, got {:?}", steps[0].kind);
3994 };
3995 assert_eq!(decl_type, "FOO");
3996 }
3997
3998 #[test]
3999 fn bare_for_without_types_is_rejected() {
4000 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
4001 assert!(err.contains("FOR requires explicit types"), "{err}");
4002 }
4003
4004 #[test]
4005 fn mutate_statement_parses_without_keyword() {
4006 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
4007 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
4008 }
4009
4010 #[test]
4011 fn set_keyword_is_rejected_with_mutation_hint() {
4012 let err = parse_err("SET $y = 2\n");
4013 assert!(err.contains("not a keyword"), "{err}");
4014 assert!(err.contains("$var = <expr>"), "{err}");
4015 }
4016
4017 #[test]
4018 fn structural_fallthrough_commits_per_keyword() {
4019 for (script, cmd) in [
4020 ("CANCEL foo\n", "CANCEL"),
4021 ("TIMEOUT foo\n", "TIMEOUT"),
4022 ("FOR foo\n", "FOR"),
4023 ("IF foo\n", "IF"),
4024 ("LET foo\n", "LET"),
4025 ("ASYNC\n", "ASYNC"),
4029 ("ELSE foo\n", "ELSE"),
4030 ] {
4031 let err = parse_err(script);
4032 assert!(
4033 err.contains(&format!("invalid syntax for command {cmd}")),
4034 "{cmd}: {err}"
4035 );
4036 assert!(!err.contains("unknown command"), "{cmd}: {err}");
4037 }
4038 }
4039
4040 #[test]
4041 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
4042 let err = parse_err("SLEEP 1s 2s\n");
4043 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
4044 assert!(!err.contains("unknown command"), "{err}");
4045 }
4046
4047 #[test]
4048 fn list_append_lowers_variable_and_item() {
4049 let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
4050 let StepKind::ListAppend { list, item } = &steps[0].kind else {
4051 panic!("expected ListAppend, got {:?}", steps[0].kind);
4052 };
4053 assert_eq!(list, "items");
4054 assert!(matches!(item, Arg::String(s, _) if s == "hi"));
4055 assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
4056 }
4057
4058 #[test]
4059 fn list_append_rejects_wrong_arity() {
4060 for script in [
4061 "LIST_APPEND\n",
4062 "LIST_APPEND $items\n",
4063 "LIST_APPEND $items \"a\" \"b\"\n",
4064 ] {
4065 let err = parse_err(script);
4066 assert!(
4067 err.contains("invalid syntax for command LIST_APPEND"),
4068 "{script}: {err}"
4069 );
4070 assert!(!err.contains("unknown command"), "{script}: {err}");
4071 }
4072 }
4073
4074 #[test]
4075 fn list_append_rejects_non_variable_target() {
4076 let err = parse_err("LIST_APPEND items \"a\"\n");
4077 assert!(
4078 err.contains("invalid syntax for command LIST_APPEND"),
4079 "{err}"
4080 );
4081 assert!(!err.contains("unknown command"), "{err}");
4082 }
4083
4084 #[test]
4085 fn read_line_rejects_bare_word_target() {
4086 let err = parse_err("READ_LINE reply\n");
4090 assert!(err.contains("READ_LINE requires a $variable"), "{err}");
4091 assert!(!err.contains("unknown command"), "{err}");
4092 }
4093
4094 #[test]
4095 fn genuinely_unknown_command_keeps_bare_message() {
4096 let err = parse_err("FROBNICATE hi\n");
4097 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
4098 assert!(!err.contains("did you mean"), "{err}");
4099 }
4100
4101 #[test]
4102 fn lowercase_command_suggests_uppercase() {
4103 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
4107 .expect_err("must fail")
4108 .to_string();
4109 assert!(err.contains("unknown command: echo"), "{err}");
4110 assert!(err.contains("did you mean `ECHO`"), "{err}");
4111 }
4112
4113 #[test]
4114 fn func_def_requires_typed_uppercase_name() {
4115 let steps = parse_script(
4116 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
4117 lower_command,
4118 )
4119 .expect("func def parses");
4120 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
4121 panic!("expected FuncDef, got {:?}", steps[0].kind);
4122 };
4123 assert_eq!(name, "GREET");
4124 assert_eq!(
4125 params,
4126 &vec![("name".to_string(), "STRING".to_string())],
4127 "{params:?}"
4128 );
4129 assert!(matches!(body[0].kind, StepKind::Return { .. }));
4130 }
4131
4132 #[test]
4133 fn lowercase_func_name_is_rejected() {
4134 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
4135 assert!(err.contains("FUNC"), "{err}");
4136 }
4137
4138 #[test]
4139 fn call_and_while_lower_correctly() {
4140 let steps = parse_script(
4141 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nGREET(\"ada\")\n",
4142 lower_command,
4143 )
4144 .expect("call parses");
4145 assert!(
4146 matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
4147 "{:?}",
4148 steps[1].kind
4149 );
4150 let steps = parse_script(
4151 "FUNC GREET($a: STRING, $b: STRING) {\n RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
4152 lower_command,
4153 )
4154 .expect("spaced call parses");
4155 assert!(
4156 matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
4157 "{:?}",
4158 steps[1].kind
4159 );
4160 let steps = parse_script(
4161 indoc! {r#"
4162 WHILE !$done {
4163 BREAK
4164 }
4165 "#},
4166 lower_command,
4167 )
4168 .expect("while parses");
4169 let StepKind::While { body, .. } = &steps[0].kind else {
4170 panic!("expected While, got {:?}", steps[0].kind);
4171 };
4172 assert!(matches!(body[0].kind, StepKind::Break));
4173 }
4174
4175 #[test]
4176 fn let_capture_call_and_async_call_lower() {
4177 let steps = parse_script(
4180 indoc! {r#"
4181 FUNC GREET($name: STRING) {
4182 RETURN $name
4183 }
4184 LET $r: STRING = GREET("ada")
4185 "#},
4186 lower_command,
4187 )
4188 .expect("capture call parses");
4189 let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
4190 panic!("expected Assign, got {:?}", steps[1].kind);
4191 };
4192 assert_eq!(var, "r");
4193 assert!(
4194 matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
4195 "{expr:?}"
4196 );
4197 let steps = parse_script(
4198 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
4199 lower_command,
4200 )
4201 .expect("async call parses");
4202 assert!(
4203 matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
4204 "{:?}",
4205 steps[1].kind
4206 );
4207 }
4208
4209 #[test]
4210 fn multiline_call_args_span_lines() {
4211 let steps = parse_script(
4215 "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",
4216 lower_command,
4217 )
4218 .expect("multiline call parses");
4219 let StepKind::Assign { expr, .. } = &steps[1].kind else {
4220 panic!("expected Assign, got {:?}", steps[1].kind);
4221 };
4222 let Expr::Call { name, args } = expr else {
4223 panic!("expected Call expr, got {expr:?}");
4224 };
4225 assert_eq!(name, "SCRIPT::SERVE");
4226 assert_eq!(args.len(), 4);
4227 assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
4228 let rendered = steps[1].to_string();
4230 assert!(!rendered.contains('\n'), "{rendered}");
4231 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";
4232 let again = parse_script(script, lower_command).expect("reparse ok");
4233 assert_eq!(again, steps);
4234 }
4235
4236 #[test]
4237 fn multiline_bare_call_and_list_span_lines() {
4238 let steps = parse_script(
4239 indoc! {r#"
4240 FUNC GREET($a: STRING) {
4241 RETURN $a
4242 }
4243 GREET(
4244 "ada"
4245 )
4246 "#},
4247 lower_command,
4248 )
4249 .expect("multiline bare call parses");
4250 let StepKind::Call { name, args } = &steps[1].kind else {
4251 panic!("expected Call, got {:?}", steps[1].kind);
4252 };
4253 assert_eq!(name, "SCRIPT::GREET");
4254 assert_eq!(args.len(), 1);
4255 let steps = parse_script(
4256 indoc! {r#"
4257 LET $l: LIST = [
4258 "a",
4259 "b"
4260 ]
4261 "#},
4262 lower_command,
4263 )
4264 .expect("multiline list parses");
4265 let StepKind::Assign { expr, .. } = &steps[0].kind else {
4266 panic!("expected Assign, got {:?}", steps[0].kind);
4267 };
4268 assert!(
4269 matches!(expr, Expr::List(items) if items.len() == 2),
4270 "{expr:?}"
4271 );
4272 }
4273
4274 #[test]
4275 fn parse_duration_units() {
4276 use std::time::Duration;
4277 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
4278 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
4279 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
4280 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
4281 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
4282 assert_eq!(parse_duration("30 s").unwrap(), Duration::from_secs(30));
4284 assert_eq!(parse_duration("2 m").unwrap(), Duration::from_secs(120));
4285 }
4286
4287 #[test]
4288 fn parse_duration_rejects_garbage() {
4289 assert!(parse_duration("").is_err());
4290 assert!(parse_duration("banana").is_err());
4291 assert!(parse_duration("10x").is_err());
4292 assert!(parse_duration("0s").is_err());
4293 assert!(parse_duration("0").is_err());
4294 assert!(parse_duration("-5s").is_err());
4295 assert!(parse_duration("30 s s").is_err());
4297 assert!(parse_duration("3 0s").is_err());
4298 }
4299
4300 #[test]
4301 fn parse_byte_count_units() {
4302 assert_eq!(parse_byte_count("500").unwrap(), 500);
4303 assert_eq!(parse_byte_count("500B").unwrap(), 500);
4304 assert_eq!(parse_byte_count("10KB").unwrap(), 10_000);
4305 assert_eq!(parse_byte_count("2MB").unwrap(), 2_000_000);
4306 assert_eq!(parse_byte_count("1GB").unwrap(), 1_000_000_000);
4307 assert_eq!(parse_byte_count("1TB").unwrap(), 1_000_000_000_000);
4308 assert_eq!(parse_byte_count("10KIB").unwrap(), 10 * 1_024);
4309 assert_eq!(parse_byte_count("2MIB").unwrap(), 2 * 1_024 * 1_024);
4310 assert_eq!(parse_byte_count("1GIB").unwrap(), 1_024 * 1_024 * 1_024);
4311 assert_eq!(
4312 parse_byte_count("1TIB").unwrap(),
4313 1_024 * 1_024 * 1_024 * 1_024
4314 );
4315 assert_eq!(parse_byte_count("1gb").unwrap(), 1_000_000_000);
4317 assert_eq!(parse_byte_count("1Gb").unwrap(), 1_000_000_000);
4318 assert_eq!(parse_byte_count("1gib").unwrap(), 1_024 * 1_024 * 1_024);
4319 assert_eq!(parse_byte_count("1Gib").unwrap(), 1_024 * 1_024 * 1_024);
4320 assert_eq!(parse_byte_count("1_000").unwrap(), 1_000);
4322 assert_eq!(
4323 parse_byte_count("1_000_000_000_000").unwrap(),
4324 1_000_000_000_000
4325 );
4326 assert_eq!(parse_byte_count("1_0KB").unwrap(), 10_000);
4327 assert_eq!(
4328 parse_byte_count("1_0GiB").unwrap(),
4329 10 * 1_024 * 1_024 * 1_024
4330 );
4331 assert_eq!(parse_byte_count("1 GB").unwrap(), 1_000_000_000);
4333 assert_eq!(parse_byte_count("10 KiB").unwrap(), 10_240);
4334 assert_eq!(parse_byte_count(" 2MB ").unwrap(), 2_000_000);
4335 assert_eq!(parse_byte_count("+1 GB").unwrap(), 1_000_000_000);
4337 assert!(parse_byte_count("-1 GB").is_err());
4338 }
4339
4340 #[test]
4341 fn parse_byte_count_rejects_garbage() {
4342 for bad in [
4343 "",
4344 "banana",
4345 "10x",
4346 "1G",
4347 "1K",
4348 "1.5GB",
4349 "-5",
4350 "-5GB",
4351 "0",
4352 "0GB",
4353 "_100",
4354 "100_",
4355 "1__000",
4356 "_",
4357 "1_GB",
4358 "1 G B",
4360 "1 2",
4361 "1 0",
4362 "1000000000000000000000000",
4364 "1000000000TB",
4365 ] {
4366 assert!(parse_byte_count(bad).is_err(), "{bad:?} must fail");
4367 }
4368 }
4369
4370 #[test]
4371 fn format_byte_count_round_trips() {
4372 for text in [
4373 "500", "500B", "10KB", "2MB", "1GB", "1TB", "10KIB", "2MIB", "1GIB", "1TIB", "1500B",
4374 ] {
4375 let parsed = parse_byte_count(text).unwrap();
4376 let rendered = format_byte_count(parsed);
4377 assert_eq!(
4378 parse_byte_count(&rendered).unwrap(),
4379 parsed,
4380 "round-trip failed for {text}"
4381 );
4382 }
4383 assert_eq!(format_byte_count(1_024 * 1_024 * 1_024), "1GiB");
4384 assert_eq!(format_byte_count(1_000_000_000), "1GB");
4385 }
4386
4387 #[test]
4388 fn strip_digit_separators_passes_signs_through() {
4389 assert_eq!(strip_digit_separators("-1_000"), "-1000");
4393 assert_eq!(strip_digit_separators("+1_000_000"), "+1000000");
4394 assert_eq!(strip_digit_separators("-1_000.5"), "-1000.5");
4395 assert_eq!(strip_digit_separators("1_000"), "1000");
4396 for untouched in ["+", "-", "", "1__0", "abc", "+-5", "--5"] {
4397 assert_eq!(strip_digit_separators(untouched), untouched);
4398 }
4399 assert!(parse_byte_count("-1_000").is_err());
4402 assert_eq!(parse_byte_count("+1GB").unwrap(), 1_000_000_000);
4403 }
4404
4405 #[test]
4406 fn format_byte_count_is_lossless() {
4407 for n in [
4412 1,
4413 500,
4414 999,
4415 1_000,
4416 1_024,
4417 1_500,
4418 1_000_000,
4419 1_048_576,
4420 1_000_000_000,
4421 1_073_741_824,
4422 1_000_000_000_000,
4423 1_099_511_627_776,
4424 u64::MAX,
4425 ] {
4426 assert_eq!(
4427 parse_byte_count(&format_byte_count(n)).unwrap(),
4428 n,
4429 "lossless round-trip failed for {n}"
4430 );
4431 }
4432 }
4433
4434 #[test]
4435 fn format_duration_round_trips() {
4436 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
4437 let parsed = parse_duration(text).unwrap();
4438 let rendered = format_duration(&parsed);
4439 assert_eq!(
4440 parse_duration(&rendered).unwrap(),
4441 parsed,
4442 "round-trip failed for {text}"
4443 );
4444 }
4445 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
4446 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
4447 }
4448
4449 #[test]
4450 fn structural_metadata_covers_all_structural_kinds() {
4451 use crate::ast::Value;
4452
4453 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
4457 match kind {
4458 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
4459 StepKind::For { .. } => Some("FOR"),
4460 StepKind::If { .. } => Some("IF"),
4461 StepKind::Assign { .. } => Some("LET"),
4462 StepKind::Set { .. } => Some("MUTATION"),
4463 StepKind::AssignCapture { .. } => Some("LET"),
4464 StepKind::AwaitCapture { .. } => Some("AWAIT"),
4465 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
4466 StepKind::Await { .. } => Some("AWAIT"),
4467 StepKind::Cancel { .. } => Some("CANCEL"),
4468 StepKind::Timeout { .. } => Some("TIMEOUT"),
4469 StepKind::RemoteBlock { .. } => Some("REMOTE"),
4470 StepKind::FuncDef { .. } => Some("FUNC"),
4471 StepKind::Call { .. } => None,
4474 StepKind::Return { .. } => Some("RETURN"),
4475 StepKind::While { .. } => Some("WHILE"),
4476 StepKind::Break => Some("BREAK"),
4477 StepKind::Continue => Some("CONTINUE"),
4478 StepKind::TypeAlias { .. } => Some("LET"),
4482 StepKind::RunExec { .. } => None,
4483 StepKind::Workdir(_)
4484 | StepKind::Workspace(_)
4485 | StepKind::Env { .. }
4486 | StepKind::InheritEnv { .. }
4487 | StepKind::Run(_)
4488 | StepKind::Echo(_)
4489 | StepKind::Copy { .. }
4490 | StepKind::Symlink { .. }
4491 | StepKind::Mkdir(_)
4492 | StepKind::Archive { .. }
4493 | StepKind::Unarchive { .. }
4494 | StepKind::Install { .. }
4495 | StepKind::Ls(_)
4496 | StepKind::Cwd
4497 | StepKind::Read(_)
4498 | StepKind::ReadLine { .. }
4499 | StepKind::Prompt { .. }
4500 | StepKind::Write { .. }
4501 | StepKind::Append { .. }
4502 | StepKind::Remove { .. }
4503 | StepKind::Expand { .. }
4504 | StepKind::AssertEq { .. }
4505 | StepKind::AssertContains { .. }
4506 | StepKind::CopyGit { .. }
4507 | StepKind::HashSha256 { .. }
4508 | StepKind::Exit(_)
4509 | StepKind::Sleep { .. }
4510 | StepKind::ListAppend { .. } => None,
4511 }
4512 }
4513
4514 let dummies: Vec<StepKind> = vec![
4517 StepKind::WithIo {
4518 bindings: Vec::new(),
4519 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4520 "x".to_string(),
4521 false,
4522 ))),
4523 },
4524 StepKind::For {
4525 key_var: None,
4526 key_type: None,
4527 var: "i".to_string(),
4528 var_type: "STRING".to_string(),
4529 in_expr: Expr::Literal(Value::bool(true)),
4530 body: Vec::new(),
4531 },
4532 StepKind::If {
4533 cond: Box::new(Expr::Literal(Value::bool(true))),
4534 then_body: Vec::new(),
4535 else_ifs: Vec::new(),
4536 else_body: None,
4537 },
4538 StepKind::Assign {
4539 var: "v".to_string(),
4540 decl_type: "BOOL".to_string(),
4541 expr: Expr::Literal(Value::bool(true)),
4542 },
4543 StepKind::Set {
4544 var: "v".to_string(),
4545 expr: Expr::Literal(Value::bool(true)),
4546 },
4547 StepKind::AssignCapture {
4548 var: "v".to_string(),
4549 decl_type: "STRING".to_string(),
4550 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
4551 "x".to_string(),
4552 false,
4553 ))),
4554 },
4555 StepKind::AwaitCapture {
4556 out_var: "o".to_string(),
4557 out_type: "STRING".to_string(),
4558 task_var: "t".to_string(),
4559 },
4560 StepKind::AsyncBlock { body: Vec::new() },
4561 StepKind::AssignAsync {
4562 var: "t".to_string(),
4563 decl_type: "HANDLE".to_string(),
4564 body: Vec::new(),
4565 },
4566 StepKind::Await {
4567 var: "t".to_string(),
4568 },
4569 StepKind::Cancel {
4570 var: "t".to_string(),
4571 },
4572 StepKind::Timeout {
4573 duration: Arg::String("1s".to_string(), false),
4574 body: Vec::new(),
4575 },
4576 StepKind::RemoteBlock {
4577 target: "prod".to_string(),
4578 vars: Vec::new(),
4579 env: Vec::new(),
4580 body: Vec::new(),
4581 },
4582 StepKind::FuncDef {
4583 name: "F".to_string(),
4584 params: Vec::new(),
4585 body: Vec::new(),
4586 },
4587 StepKind::Call {
4588 name: "F".to_string(),
4589 args: Vec::new(),
4590 },
4591 StepKind::Return {
4592 expr: Box::new(Expr::Literal(Value::bool(true))),
4593 },
4594 StepKind::While {
4595 cond: Box::new(Expr::Literal(Value::bool(true))),
4596 body: Vec::new(),
4597 },
4598 StepKind::Break,
4599 StepKind::Continue,
4600 StepKind::TypeAlias {
4601 name: "PERSON".to_string(),
4602 target: "MAP<name: STRING>".to_string(),
4603 },
4604 ];
4605 let registry = all_structural_metadata();
4606 for kind in &dummies {
4607 let Some(name) = metadata_name(kind) else {
4609 continue;
4610 };
4611 assert!(
4612 registry.iter().any(|meta| meta.name == name),
4613 "no structural metadata entry for {}",
4614 name
4615 );
4616 }
4617 }
4618}