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
28fn join_value(args: Vec<Arg>, cmd_name: &str) -> ParseResult<Arg> {
39 if args.is_empty() {
40 return Err(ParseError::validation(
41 cmd_name,
42 format!("{cmd_name} requires at least one argument"),
43 &SpanContext::line_only(0),
44 ));
45 }
46 if args.len() == 1 {
47 return Ok(args.into_iter().next().unwrap());
48 }
49 if args.iter().all(|a| matches!(a, Arg::String(..))) {
50 return Ok(Arg::String(
51 args.iter()
52 .map(|a| a.as_str())
53 .collect::<Vec<_>>()
54 .join(" "),
55 false,
56 ));
57 }
58 let mut parts = Vec::new();
59 for (index, arg) in args.into_iter().enumerate() {
60 if index > 0 {
61 parts.push(ArgPart::Text(" ".to_string(), false));
62 }
63 match arg {
64 Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
65 Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
66 Arg::Parts(inner) => parts.extend(inner),
67 }
68 }
69 Ok(Arg::Parts(parts))
70}
71
72pub fn lower_env_assignment(args: Vec<Arg>) -> ParseResult<StepKind> {
76 let arg = args.into_iter().next().ok_or_else(|| {
77 ParseError::validation(
78 "ENV",
79 "ENV requires KEY=value".to_string(),
80 &SpanContext::line_only(0),
81 )
82 })?;
83 let Some((key, value)) = split_assignment(arg.as_str())
84 .map_err(|e| ParseError::validation("ENV", e.to_string(), &SpanContext::line_only(0)))?
85 else {
86 return Err(ParseError::validation(
87 "ENV",
88 "ENV requires KEY=value format".to_string(),
89 &SpanContext::line_only(0),
90 ));
91 };
92 Ok(StepKind::Env { key, value })
93}
94
95pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
100 Arg::String(format!("{key}={}", value.render()), false)
101}
102
103fn fmt_assert_target(target: &AssertTarget) -> String {
106 match target {
107 AssertTarget::Value(arg) => fmt_value(arg, quote_msg),
108 _ => target.render(),
109 }
110}
111
112fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
117 match arg {
118 Arg::Expr(_) => arg.render(),
119 Arg::String(text, _) => quote(text),
120 Arg::Parts(_) => {
121 let rendered = arg.render();
122 if rendered.contains(';')
123 || rendered.contains('}')
124 || rendered.contains('\n')
125 || rendered.contains('\r')
126 {
127 quote(&rendered)
128 } else {
129 rendered
130 }
131 }
132 }
133}
134
135fn quote_arg(s: &str) -> String {
136 let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
137 && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
138 && !crate::Command::is_statement_keyword(s);
139 if is_safe && !s.is_empty() {
140 s.to_string()
141 } else {
142 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
143 }
144}
145
146fn quote_msg(s: &str) -> String {
147 let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
148 && !s.starts_with(|c: char| c.is_ascii_digit())
149 && !crate::Command::is_statement_keyword(s);
150 if safe && !s.is_empty() {
151 s.to_string()
152 } else {
153 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
154 }
155}
156
157fn quote_run(s: &str) -> String {
158 if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
159 return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
160 }
161 s.split(' ')
162 .map(|w| {
163 if w.starts_with(|c: char| c.is_ascii_digit())
164 || w.starts_with(['/', '.', '-', ':', '='])
165 {
166 format!("\"{}\"", w.replace('\\', "\\\\").replace('"', "\\\""))
167 } else {
168 w.to_string()
169 }
170 })
171 .collect::<Vec<_>>()
172 .join(" ")
173}
174
175fn fmt_exec_arg(arg: &Arg) -> String {
181 match arg {
182 Arg::String(text, _) => {
183 format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
184 }
185 Arg::Expr(_) => arg.render(),
186 Arg::Parts(_) => {
187 let rendered = arg.render();
188 if rendered.contains(';')
189 || rendered.contains('}')
190 || rendered.contains('\n')
191 || rendered.contains('\r')
192 {
193 format!(
194 "\"{}\"",
195 rendered.replace('\\', "\\\\").replace('"', "\\\"")
196 )
197 } else {
198 rendered
199 }
200 }
201 }
202}
203
204fn fmt_raw_arg(arg: &Arg) -> String {
208 match arg {
209 Arg::String(s, true) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
210 _ => arg.render(),
211 }
212}
213
214fn fmt_io(b: &IoBinding) -> String {
215 let s = match b.stream {
216 IoStream::Stdin => "stdin",
217 IoStream::Stdout => "stdout",
218 IoStream::Stderr => "stderr",
219 };
220 match &b.pipe {
221 Some(PipeTarget::Var(v)) => format!("{}=${}", s, v),
222 None => s.to_string(),
223 }
224}
225
226pub(crate) fn is_known_command(name: &str) -> bool {
233 if name == "ELSE" {
234 return true;
235 }
236 all_metadata().iter().any(|meta| meta.name == name)
237}
238
239pub(crate) fn invalid_syntax_error(name: &str, raw_args: &[Arg]) -> ParseError {
240 let received = raw_args
241 .iter()
242 .map(Arg::render)
243 .collect::<Vec<_>>()
244 .join(" ");
245 let got = if received.is_empty() {
246 "nothing".to_string()
247 } else {
248 format!("`{received}`")
249 };
250 let found = if received.is_empty() {
251 None
252 } else {
253 Some(received.clone())
254 };
255 let expected = all_metadata()
256 .iter()
257 .find(|meta| meta.name == name)
258 .map(|meta| vec![meta.syntax.to_string()])
259 .unwrap_or_default();
260 let ctx = SpanContext::line_only(0);
261 match structural_hint(name, &received) {
262 Some(hint) => ParseError::invalid_syntax(
263 name,
264 format!("invalid syntax for command {name}: {hint}"),
265 found,
266 expected,
267 Some(hint),
268 &ctx,
269 ),
270 None => ParseError::invalid_syntax(
271 name,
272 format!("invalid syntax for command {name}: got {got}."),
273 found,
274 expected,
275 None,
276 &ctx,
277 ),
278 }
279}
280
281fn unknown_command_error(name: &str, raw_args: &[Arg]) -> ParseError {
282 let received = raw_args
283 .iter()
284 .map(Arg::render)
285 .collect::<Vec<_>>()
286 .join(" ");
287 let hint = structural_hint(name, &received).or_else(|| case_hint(name));
288 let ctx = SpanContext::line_only(0);
289 match hint {
290 Some(hint) => ParseError::unknown_command(
291 name,
292 format!("unknown command: {name}\n{hint}"),
293 Some(hint),
294 &ctx,
295 ),
296 None => ParseError::unknown_command(name, format!("unknown command: {name}"), None, &ctx),
297 }
298}
299
300pub(crate) fn classify(name: &str, raw_args: &[Arg]) -> ParseError {
306 if is_known_command(name) {
307 invalid_syntax_error(name, raw_args)
308 } else {
309 unknown_command_error(name, raw_args)
310 }
311}
312
313fn structural_hint(name: &str, received: &str) -> Option<String> {
314 let got = if received.is_empty() {
315 "nothing".to_string()
316 } else {
317 format!("`{received}`")
318 };
319 match name {
320 "WITH_IO" => Some(with_io_hint(&got, received)),
321 "AWAIT" => Some(format!(
322 "AWAIT waits for a background task variable, e.g. `LET $t: HANDLE = ASYNC ECHO hi` then `AWAIT $t`; got {got}."
323 )),
324 "CANCEL" => Some(format!(
325 "CANCEL stops a background task variable, e.g. `CANCEL $t` (from `LET $t: HANDLE = ASYNC ...`); got {got}."
326 )),
327 "ASYNC" => Some(format!(
328 "ASYNC runs a command in the background, e.g. `ASYNC RUN ...`, `ASYNC {{ ... }}`, or `LET $t: HANDLE = ASYNC ...`; got {got}."
329 )),
330 "FOR" => Some(format!(
331 "FOR loops need `FOR $item: TYPE IN <expr> {{ ... }}` (or `FOR $key: STRING, $value: TYPE IN <expr> {{ ... }}`); got {got}."
332 )),
333 "IF" => Some(format!(
334 "IF needs a condition and a block, e.g. `IF true {{ ECHO yes }}`; got {got}."
335 )),
336 "ELSE" => Some(format!(
337 "ELSE must directly follow an `IF ... {{ ... }}` block, e.g. `IF true {{ ECHO yes }} ELSE {{ ECHO no }}`; got {got}."
338 )),
339 "LET" => Some(format!(
340 "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}."
341 )),
342 "SET" => Some(
343 "`SET` is not a keyword; mutate a declared variable with `$var = <expr>`, e.g. `$count = 2`.".to_string(),
344 ),
345 "TIMEOUT" => Some(format!(
346 "TIMEOUT needs a duration and a command or block, e.g. `TIMEOUT 30s RUN ...`; got {got}."
347 )),
348 "FUNC" => Some(format!(
349 "FUNC defines a function, e.g. `FUNC GREET($name: STRING) {{ RETURN $name }}`; got {got}."
350 )),
351 "RETURN" => Some(format!(
352 "RETURN ends the nearest function, ASYNC task, or inline LET block with a value, e.g. `RETURN $x`; got {got}."
353 )),
354 "WHILE" => Some(format!(
355 "WHILE needs a Bool condition and a block, e.g. `WHILE !$done {{ ... }}`; got {got}."
356 )),
357 "BREAK" => Some(
358 "`BREAK` exits the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
359 ),
360 "CONTINUE" => Some(
361 "`CONTINUE` skips to the next iteration of the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
362 ),
363 "INHERIT_ENV" => Some(format!(
364 "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME, PATH]`; got {got}."
365 )),
366 name if name == KEYWORD_IMPORT => Some(format!(
367 "IMPORT brings module functions into bare-call scope, e.g. `IMPORT [STD]` or `IMPORT [STD, MOCK]`; got {got}."
368 )),
369 name if name == KEYWORD_EXPORT => Some(
370 "`EXPORT` is reserved for future script-module support and cannot be used yet."
371 .to_string(),
372 ),
373 _ => None,
374 }
375}
376
377fn with_io_hint(got: &str, received: &str) -> String {
380 const SYNTAX: &str =
381 "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
382 const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=$p]`, `[stdin=$p]`)";
383 if let Some(after_open) = received.strip_prefix('[') {
384 match after_open.split_once(']') {
385 None => {
386 return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
387 }
388 Some((bindings, _)) => {
389 for part in bindings.split(',') {
390 let part = part.trim();
391 if part.is_empty() {
392 continue;
393 }
394 let (stream, binding) = match part.split_once('=') {
395 Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
396 None => (part, None),
397 };
398 if !matches!(stream, "stdin" | "stdout" | "stderr") {
399 return format!(
400 "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
401 );
402 }
403 let valid = match binding {
404 None => true,
405 Some(value) => value.strip_prefix('$').is_some_and(|var| {
406 !var.trim().is_empty()
407 && var.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
408 }),
409 };
410 if !valid {
411 return format!(
412 "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
413 );
414 }
415 }
416 }
417 }
418 }
419 format!("{SYNTAX}; got {got}. {BINDINGS}.")
420}
421
422fn case_hint(name: &str) -> Option<String> {
424 let upper = name.to_ascii_uppercase();
425 if upper != name
426 && all_metadata()
427 .iter()
428 .any(|meta| meta.name == upper.as_str())
429 {
430 return Some(format!("did you mean `{upper}`? commands are uppercase."));
431 }
432 None
433}
434
435macro_rules! declare_commands {
436 (
437 structural [
438 $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
439 ]
440
441 $(
442 $cmd_ident:ident => [
443 name: $name:expr,
444 variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
445 syntax: $syntax:expr,
446 summary: $summary:expr,
447 description: $desc:expr,
448 args: $args:expr,
449 flags: $flags:expr,
450 default_output: $out:expr,
451 examples: $examples:expr,
452 lower: $lower:expr,
453 ]
454 ),* $(,)?
455 ) => {
456 #[derive(Debug, Clone, PartialEq)]
457 pub enum StepKind {
458 $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
459 $( $sname $( { $( $sfname : $sftype ),* } )?, )*
460 }
461
462 pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> ParseResult<StepKind> {
463 match name {
464 $(
465 s if s == $name => {
466 let meta = CommandMeta {
467 name: $name, syntax: $syntax, summary: $summary,
468 description: $desc, args: $args, flags: $flags,
469 default_output: $out, examples: $examples,
470 };
471 let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
472 crate::command::validate_positionals_against_meta(
473 s,
474 &meta.args,
475 &positional,
476 )?;
477 let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> ParseResult<StepKind> = $lower;
478 lower_fn(flags, positional)
479 }
480 )*
481 _ => {
482 Err(classify(name, &raw_args))
483 }
484 }
485 }
486
487 pub fn all_metadata() -> Vec<CommandMeta> {
488 let mut out = vec![
489 $( CommandMeta {
490 name: $name, syntax: $syntax, summary: $summary,
491 description: $desc, args: $args, flags: $flags,
492 default_output: $out, examples: $examples,
493 }, )*
494 ];
495 out.extend(all_structural_metadata());
499 out
500 }
501 };
502}
503
504#[derive(Debug, Clone, PartialEq)]
513pub enum AssertTarget {
514 Value(Arg),
515 Stdout,
516 Stderr,
517}
518
519impl AssertTarget {
520 pub fn render(&self) -> String {
521 match self {
522 AssertTarget::Value(arg) => arg.render(),
523 AssertTarget::Stdout => "stdout".to_string(),
524 AssertTarget::Stderr => "stderr".to_string(),
525 }
526 }
527}
528
529fn lower_assert_target(arg: Arg) -> ParseResult<AssertTarget> {
538 match arg {
539 Arg::Expr(_) => Ok(AssertTarget::Value(arg)),
540 Arg::String(text, quoted) if !quoted => match text.as_str() {
541 "stdout" => Ok(AssertTarget::Stdout),
542 "stderr" => Ok(AssertTarget::Stderr),
543 _ => Ok(AssertTarget::Value(lower_assert_operand(Arg::String(
544 text, false,
545 )))),
546 },
547 other => Ok(AssertTarget::Value(lower_assert_operand(other))),
548 }
549}
550
551fn lower_assert_operand(arg: Arg) -> Arg {
560 match arg {
561 Arg::String(text, false) => {
562 if let Ok(i) = text.parse::<i64>() {
563 Arg::Expr(Expr::Literal(Value::int(i)))
564 } else if text.contains('.') && text.parse::<f64>().is_ok() {
565 Arg::Expr(Expr::Literal(Value::float(
566 text.parse::<f64>().unwrap_or(f64::NAN),
567 )))
568 } else if text == "true" {
569 Arg::Expr(Expr::Literal(Value::bool(true)))
570 } else if text == "false" {
571 Arg::Expr(Expr::Literal(Value::bool(false)))
572 } else {
573 Arg::String(text, false)
574 }
575 }
576 other => other,
577 }
578}
579
580declare_commands! {
581 structural [
582 WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
583 WithIoBlock { bindings: Vec<IoBinding> },
584 For { key_var: Option<String>, key_type: Option<String>, var: String, var_type: String, in_expr: Expr, body: Vec<Step> },
585 If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
586 Assign { var: String, decl_type: String, expr: Expr },
587 Set { var: String, expr: Expr },
588 AssignCapture { var: String, decl_type: String, cmd: Box<StepKind> },
589 AwaitCapture { out_var: String, out_type: String, task_var: String },
590 AsyncBlock { body: Vec<Step> },
591 AssignAsync { var: String, decl_type: String, body: Vec<Step> },
592 Await { var: String },
593 Cancel { var: String },
594 Timeout { duration: Arg, body: Vec<Step> },
595 RunExec { argv: Vec<Arg> },
596 FuncDef { name: String, params: Vec<(String, String)>, body: Vec<Step> },
597 Call { name: String, args: Vec<Expr> },
598 Return { expr: Box<Expr> },
599 While { cond: Box<Expr>, body: Vec<Step> },
600 Break,
601 Continue,
602 ]
603
604 Workdir => [
605 name: "WORKDIR",
606 variant: Workdir(Arg),
607 syntax: "WORKDIR <path>",
608 summary: "Change the working directory.",
609 description: indoc! {r#"
610 Sets the current working directory.
611
612 Relative paths resolve against the current directory; `/` resets to
613 the workspace root. Paths cannot escape the workspace.
614 "#},
615 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
616 flags: &[],
617 default_output: None,
618 examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
619 WORKDIR project/src
620 WRITE generated.txt generated-under-workdir
621 LET $body: STRING = READ generated.txt
622 ASSERT_EQ $body "generated-under-workdir"
623 "#} }, Example { name: "workdir in a scoped block", fence_meta: None, code: indoc! {r#"
624 MKDIR project
625
626 [bool:true] {
627 WORKDIR project
628 WRITE inner.txt inner
629 }
630
631 LET $body: STRING = READ project/inner.txt
632 ASSERT_EQ $body "inner"
633 "#} } ],
634 lower: |_flags, args| {
635 let path = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKDIR", "WORKDIR requires a path".to_string(), &SpanContext::line_only(0)))?;
636 Ok(StepKind::Workdir(path))
637 },
638 ],
639
640 Workspace => [
641 name: "WORKSPACE",
642 variant: Workspace(WorkspaceTarget),
643 syntax: "WORKSPACE (SNAPSHOT|LOCAL|CACHE|SYSTEM) [--local]",
644 summary: "Switch workspace roots.",
645 description: indoc! {r#"
646 Switches the workspace root. The selection reverts at scope
647 exit like `WORKDIR`.
648
649 - `SNAPSHOT`: the materialized build snapshot (the default).
650 - `LOCAL`: the local workspace directory.
651 - `CACHE`: a persistent per-project directory shared across
652 runs, never evicted. It lives under the OS user cache
653 (`OXDOCK_CACHE_DIR` pins an exact directory);
654 `WORKSPACE CACHE --local` keeps it in
655 `<project>/.cache/workspace` instead.
656 - `SYSTEM`: full filesystem access. Scripts using it are not
657 hermetic.
658 "#},
659 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 } ],
660 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)" } ],
661 default_output: None,
662 examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"WORKSPACE LOCAL"#} }, Example { name: "workspace cache in a scoped block", fence_meta: None, code: indoc! {r#"
663 [bool:true] {
664 WORKSPACE CACHE
665 WRITE cached.txt cached-content
666 }
667
668 COPY --from-workspace CACHE cached.txt restored.txt
669 LET $body: STRING = READ restored.txt
670 ASSERT_EQ $body "cached-content"
671 "#} } ],
672 lower: |flags, args| {
673 let local = flags.iter().any(|(k, _)| k == "local");
674 let target = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKSPACE", "WORKSPACE requires a target".to_string(), &SpanContext::line_only(0)))?;
675 match target.as_str() {
676 "SNAPSHOT" | "LOCAL" | "SYSTEM" if local => Err(ParseError::validation("WORKSPACE", "WORKSPACE --local requires CACHE".to_string(), &SpanContext::line_only(0))),
677 "SNAPSHOT" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
678 "LOCAL" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
679 "CACHE" => Ok(StepKind::Workspace(WorkspaceTarget::Cache { local })),
680 "SYSTEM" => Ok(StepKind::Workspace(WorkspaceTarget::System)),
681 other => Err(ParseError::validation("WORKSPACE", format!("unknown workspace target: {other}"), &SpanContext::line_only(0))),
682 }
683 },
684 ],
685
686 Env => [
687 name: "ENV",
688 variant: Env { key: String, value: Arg },
689 syntax: "ENV KEY=value",
690 summary: "Set an environment variable.",
691 description: indoc! {r#"
692 Inserts or updates an env var.
693
694 The value uses the unified string-value rules shared by every command:
695 `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
696 evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
697 words join with single spaces, and the first `=` splits key from value
698 (`KEY=a=b` stores `a=b`).
699
700 A `$var` inside larger text stays literal — write `{{ $var }}` to
701 interpolate there.
702 "#},
703 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 } ],
704 flags: &[],
705 default_output: None,
706 examples: &[
707 Example { name: "set env", fence_meta: None, code: indoc! {r#"ENV APP_MODE=production"#} },
708 Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
709 # Quotes keep the space: SET_FORTH stores `outer scope`.
710 ENV SET_FORTH="outer scope"
711 WRITE out.txt "{{ env:SET_FORTH }}"
712
713 LET $body: STRING = READ out.txt
714 ASSERT_EQ $body "outer scope"
715 "#} },
716 Example { name: "variable value", fence_meta: None, code: indoc! {r#"
717 # A lone $var evaluates, like ECHO $var.
718 LET $who: STRING = "Alice"
719 ENV GREETING=$who
720 WRITE out.txt "{{ env:GREETING }}"
721
722 LET $body: STRING = READ out.txt
723 ASSERT_EQ $body "Alice"
724 "#} },
725 Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
726 # A bare variable, a quoted literal, and a template all
727 # store plain strings through the same value rules.
728 LET $x: STRING = "Ada"
729 ENV A=$x
730 ENV B="hello world"
731 ENV C="{{ $x }} concatenated"
732 WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
733
734 LET $body: STRING = READ check.txt
735 ASSERT_EQ $body "Ada|hello world|Ada concatenated"
736 "#} },
737 Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
738 # ENV inside a braced block reverts when the block exits
739 ENV MODE=production
740
741 [bool:true] {
742 ENV MODE=staging
743 WRITE inner.txt "{{ env:MODE }}"
744 }
745
746 WRITE outer.txt "{{ env:MODE }}"
747
748 LET $inner_body: STRING = READ inner.txt
749 ASSERT_EQ $inner_body "staging"
750
751 LET $outer_body: STRING = READ outer.txt
752 ASSERT_EQ $outer_body "production"
753 "#} },
754 ],
755 lower: |_flags, args| lower_env_assignment(args),
756 ],
757
758 InheritEnv => [
759 name: "INHERIT_ENV",
760 variant: InheritEnv { keys: Vec<String> },
761 syntax: "INHERIT_ENV [<key>, ...]",
762 summary: "Inherit env vars from host.",
763 description: indoc! {r#"
764 Declares which host environment variables to inherit into the script.
765
766 Must appear before any other commands and at most once. Without this
767 directive, the script starts with an empty environment.
768 "#},
769 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 } ],
770 flags: &[],
771 default_output: None,
772 examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"INHERIT_ENV [PATH, HOME]"#} } ],
773 lower: |_flags, args| {
774 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
775 Ok(StepKind::InheritEnv { keys })
776 },
777 ],
778
779 Echo => [
780 name: "ECHO",
781 variant: Echo(Arg),
782 syntax: "ECHO <message>",
783 summary: "Print to stdout.",
784 description: "Outputs message to stdout.",
785 args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
786 flags: &[],
787 default_output: Some(Stream::Stdout),
788 examples: &[
789 Example { name: "echo", fence_meta: None, code: indoc! {r#"ECHO build-complete"#} },
790 Example { name: "variables", fence_meta: None, code: indoc! {r#"
791 # A lone $x evaluates; {{ }} interpolates inside text.
792 LET $x: STRING = "World"
793 ECHO {{ $x }}
794 ECHO $x
795 ASSERT_CONTAINS stdout "World"
796 "#} },
797 ],
798 lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
799 ],
800
801 Run => [
802 name: "RUN",
803 variant: Run(Arg),
804 syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
805 summary: "Execute shell command or direct executable.",
806 description: indoc! {r#"
807 Shell form (`RUN <command...>`) runs the joined command string in the
808 system shell (`$SHELL -c` / `COMSPEC /C`).
809
810 Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
811 with no shell, so there is no shell expansion, globbing, redirection,
812 or pipes; use it for portable commands.
813
814 Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
815 both forms.
816 "#},
817 args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
818 flags: &[],
819 default_output: None,
820 examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"RUN echo hello"#} }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"RUN ["cargo", "--version"]"#} } ],
821 lower: |_flags, args| match args.as_slice() {
822 [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
823 Err(ParseError::validation("RUN", "RUN requires at least one argument".to_string(), &SpanContext::line_only(0)))
824 }
825 [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
826 argv: elems.iter().cloned().map(Arg::Expr).collect(),
827 }),
828 _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
829 },
830 ],
831
832 Copy => [
833 name: "COPY",
834 variant: Copy { from_workspace: Option<WorkspaceTarget>, from: Arg, to: Arg },
835 syntax: "COPY [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
836 summary: "Copy file into workspace.",
837 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.",
838 args: &[
839 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
840 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
841 ],
842 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" } ],
843 default_output: None,
844 examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
845 WRITE src.txt content
846 COPY src.txt dst.txt
847 LET $body: STRING = READ dst.txt
848 ASSERT_EQ $body "content"
849 "#} }, Example { name: "copy from workspace", fence_meta: Some("roots:unified"), code: indoc! {r#"
850 WRITE ws-src.txt ws-content
851 COPY --from-workspace LOCAL ws-src.txt ws-copy.txt
852 LET $body: STRING = READ ws-copy.txt
853 ASSERT_EQ $body "ws-content"
854 "#} } ],
855 lower: |flags, args| {
856 let from_workspace = flags
857 .iter()
858 .find(|(k, _)| k == "from_workspace")
859 .map(|(_, v)| match v.as_str() {
860 "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
861 "LOCAL" => Ok(WorkspaceTarget::Local),
862 "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
863 "SYSTEM" => Ok(WorkspaceTarget::System),
864 other => Err(ParseError::validation("COPY", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
865 })
866 .transpose()?;
867 let mut it = args.into_iter();
868 let from = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a source".to_string(), &SpanContext::line_only(0)))?;
869 let to = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a destination".to_string(), &SpanContext::line_only(0)))?;
870 Ok(StepKind::Copy { from_workspace, from, to })
871 },
872 ],
873
874 CopyGit => [
875 name: "COPY_GIT",
876 variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
877 syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
878 summary: "Copy from git revision.",
879 description: "Checkout and copy.",
880 args: &[
881 ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
882 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
883 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
884 ],
885 flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
886 default_output: None,
887 examples: &[ Example { name: "git copy", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
888 lower: |flags, args| {
889 let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
890 let mut it = args.into_iter();
891 let rev = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a revision".to_string(), &SpanContext::line_only(0)))?;
892 let from = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a source".to_string(), &SpanContext::line_only(0)))?;
893 let to = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a destination".to_string(), &SpanContext::line_only(0)))?;
894 Ok(StepKind::CopyGit { rev, from, to, include_dirty })
895 },
896 ],
897
898 Symlink => [
899 name: "SYMLINK",
900 variant: Symlink { from_workspace: Option<WorkspaceTarget>, from: Arg, to: Arg },
901 syntax: "SYMLINK [--from-workspace SNAPSHOT|LOCAL|CACHE|SYSTEM] <from> <to>",
902 summary: "Create symlink.",
903 description: "Creates symlink. A directory destination (existing, or a trailing-slash spell) receives the link under the source basename.",
904 args: &[
905 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
906 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
907 ],
908 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" } ],
909 default_output: None,
910 examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
911 WRITE original.txt content
912 SYMLINK original.txt link.txt
913 LET $body: STRING = READ link.txt
914 ASSERT_EQ $body "content"
915 "#} }, Example { name: "symlink from workspace", fence_meta: Some("roots:unified"), code: indoc! {r#"
916 WRITE ws-src.txt ws-content
917 SYMLINK --from-workspace LOCAL ws-src.txt ws-link.txt
918 LET $body: STRING = READ ws-link.txt
919 ASSERT_EQ $body "ws-content"
920 "#} } ],
921 lower: |flags, args| {
922 let from_workspace = flags
923 .iter()
924 .find(|(k, _)| k == "from_workspace")
925 .map(|(_, v)| match v.as_str() {
926 "SNAPSHOT" => Ok(WorkspaceTarget::Snapshot),
927 "LOCAL" => Ok(WorkspaceTarget::Local),
928 "CACHE" => Ok(WorkspaceTarget::Cache { local: false }),
929 "SYSTEM" => Ok(WorkspaceTarget::System),
930 other => Err(ParseError::validation("SYMLINK", format!("unknown workspace source: {other}"), &SpanContext::line_only(0))),
931 })
932 .transpose()?;
933 let mut it = args.into_iter();
934 let from = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a source".to_string(), &SpanContext::line_only(0)))?;
935 let to = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a target".to_string(), &SpanContext::line_only(0)))?;
936 Ok(StepKind::Symlink { from_workspace, from, to })
937 },
938 ],
939
940 Mkdir => [
941 name: "MKDIR",
942 variant: Mkdir(Arg),
943 syntax: "MKDIR <path>",
944 summary: "Create directory.",
945 description: "Creates dir with parents.",
946 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
947 flags: &[],
948 default_output: None,
949 examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"MKDIR deeply/nested/tree"#} } ],
950 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)))?)),
951 ],
952
953 Ls => [
954 name: "LS",
955 variant: Ls(Option<Arg>),
956 syntax: "LS [<path>]",
957 summary: "List directory.",
958 description: "Lists entries.",
959 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
960 flags: &[],
961 default_output: Some(Stream::Stdout),
962 examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
963 MKDIR inventory
964 WRITE inventory/a.txt a
965 LS inventory
966 "#} } ],
967 lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
968 ],
969
970 Cwd => [
971 name: "CWD",
972 variant: Cwd,
973 syntax: "CWD",
974 summary: "Print working directory.",
975 description: "Outputs cwd.",
976 args: &[],
977 flags: &[],
978 default_output: Some(Stream::Stdout),
979 examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"CWD"#} } ],
980 lower: |_flags, _args| Ok(StepKind::Cwd),
981 ],
982
983 Read => [
984 name: "READ",
985 variant: Read(Option<Arg>),
986 syntax: "READ [<path>]",
987 summary: "Read file to stdout.",
988 description: "Outputs file contents.",
989 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
990 flags: &[],
991 default_output: Some(Stream::Stdout),
992 examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
993 WRITE note.txt "hello"
994 READ note.txt
995 "#} } ],
996 lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
997 ],
998
999 ReadLine => [
1000 name: "READ_LINE",
1001 variant: ReadLine { var: String },
1002 syntax: "READ_LINE $var",
1003 summary: "Read one line from stdin into a variable.",
1004 description: indoc! {r#"
1005 Reads bytes until newline without waiting for EOF, leaving the pipe open.
1006
1007 Trailing newline is stripped (shell-read parity). On premature EOF
1008 assigns accumulated bytes and returns.
1009 "#},
1010 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 } ],
1011 flags: &[],
1012 default_output: None,
1013 examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
1014 LET $lines: PIPE
1015 WITH_IO [stdout=$lines] ECHO "first"
1016 WITH_IO [stdin=$lines] READ_LINE $reply
1017 "#} } ],
1018 lower: |_flags, args| {
1019 let arg = args.into_iter().next().ok_or_else(|| ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))?;
1020 let var = match arg {
1021 Arg::Expr(Expr::Var(name)) => name,
1022 Arg::String(s, _) if s.starts_with('$') || s.contains("{{") => s.trim_start_matches('$').to_string(),
1025 other => return Err(ParseError::validation("READ_LINE", format!("READ_LINE requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1026 };
1027 if var.is_empty() {
1028 return Err(ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))
1029 }
1030 Ok(StepKind::ReadLine { var })
1031 },
1032 ],
1033
1034 Write => [
1035 name: "WRITE",
1036 variant: Write { path: Arg, contents: Option<Arg> },
1037 syntax: "WRITE <path> [<contents>]",
1038 summary: "Write to file.",
1039 description: "Writes contents.",
1040 args: &[
1041 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1042 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1043 ],
1044 flags: &[],
1045 default_output: None,
1046 examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"WRITE output.txt hello-world"#} } ],
1047 lower: |_flags, args| {
1048 let mut it = args.into_iter();
1049 let path = it.next().ok_or_else(|| ParseError::validation("WRITE", "WRITE requires a path".to_string(), &SpanContext::line_only(0)))?;
1050 let remaining: Vec<Arg> = it.collect();
1051 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
1052 Ok(StepKind::Write { path, contents })
1053 },
1054 ],
1055
1056 Append => [
1057 name: "APPEND",
1058 variant: Append { path: Arg, contents: Option<Arg> },
1059 syntax: "APPEND <path> [<contents>]",
1060 summary: "Append to file.",
1061 description: "Appends contents.",
1062 args: &[
1063 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1064 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
1065 ],
1066 flags: &[],
1067 default_output: None,
1068 examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
1069 WRITE log.txt line1
1070 APPEND log.txt line2
1071 LET $all: STRING = READ log.txt
1072 ASSERT_EQ $all "line1line2"
1073 "#} } ],
1074 lower: |_flags, args| {
1075 let mut it = args.into_iter();
1076 let path = it.next().ok_or_else(|| ParseError::validation("APPEND", "APPEND requires a path".to_string(), &SpanContext::line_only(0)))?;
1077 let remaining: Vec<Arg> = it.collect();
1078 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
1079 Ok(StepKind::Append { path, contents })
1080 },
1081 ],
1082
1083 Expand => [
1084 name: "EXPAND",
1085 variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
1086 syntax: "EXPAND [<path>] [<KEY=val> ...]",
1087 summary: "Expand a template file (or stdin) to stdout.",
1088 description: indoc! {r#"
1089 A template is any text file — or piped stdin when no path is given —
1090 containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
1091 and prints the result to stdout.
1092
1093 Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
1094 command; `{{ env:NAME }}` reads an override, falling back to the
1095 environment; `{{ $var }}` reads a script variable (dotted paths allowed).
1096 A missing key is an error, never a silent empty.
1097
1098 Substitution runs in a single pass. EXPAND is not recursive and does not
1099 expand nested placeholders: a value that itself contains `{{ ... }}` is
1100 inserted verbatim and never expanded again.
1101
1102 A bare `$var` argument is a template path; `KEY=val` arguments are
1103 overrides whose values follow the unified string-value rules (same as
1104 `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1105 `{{ ... }}` interpolates).
1106
1107 NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1108 (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1109
1110 With no path, the template arrives on stdin through a pipe. When piping
1111 from a shell, single-quote the template (`echo '{{ $x }}'`): double
1112 quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1113 placeholder and errors.
1114 "#},
1115 args: &[
1116 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 },
1117 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 },
1118 ],
1119 flags: &[],
1120 default_output: Some(Stream::Stdout),
1121 examples: &[
1122 Example { name: "expand", fence_meta: None, code: indoc! {r#"
1123 ENV NAME="Alice"
1124 WRITE template.md "Hello {{ env:NAME }}!"
1125 EXPAND template.md
1126 ASSERT_CONTAINS stdout "Hello Alice!"
1127 "#} },
1128 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1129 # WRITE would interpolate {{ }} right away, so escape it:
1130 # the file must literally contain {{ env:NAME }} for EXPAND
1131 WRITE template.md "Hello \{{ env:NAME }}!"
1132 EXPAND template.md NAME="Alice Smith"
1133 ASSERT_CONTAINS stdout "Hello Alice Smith!"
1134 "#} },
1135 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1136 # same escaping: keep the placeholder literal until EXPAND;
1137 # a lone $who evaluates, like ECHO $who
1138 LET $who: STRING = "Bob"
1139 WRITE template.md "Hi \{{ env:WHO }}!"
1140 EXPAND template.md WHO=$who
1141 ASSERT_CONTAINS stdout "Hi Bob!"
1142 "#} },
1143 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1144 # a bare variable and a template-with-tail expand identically
1145 LET $x: STRING = "Ada"
1146 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1147 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1148 ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1149 "#} },
1150 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1151 # no path: the template arrives on stdin through a pipe
1152 LET $tpl: PIPE
1153 WITH_IO [stdout=$tpl] ECHO "Hello \{{ env:NAME }}!"
1154 WITH_IO [stdin=$tpl] EXPAND NAME=Alice
1155 ASSERT_CONTAINS stdout "Hello Alice!"
1156 "#} },
1157 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1158 # KEY=val overrides shadow env for that EXPAND only —
1159 # they never update the environment itself
1160 ENV NAME="Alice"
1161 WRITE template.md "Hi \{{ env:NAME }}!"
1162
1163 EXPAND template.md NAME="Bob"
1164 ASSERT_CONTAINS stdout "Hi Bob!"
1165
1166 EXPAND template.md
1167 ASSERT_CONTAINS stdout "Hi Alice!"
1168 "#} },
1169 ],
1170 lower: |_flags, args| {
1171 let mut path = None;
1172 let mut overrides = Vec::new();
1173 for arg in args {
1174 let text = arg.as_str();
1175 if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1176 overrides.push((key, value));
1177 } else if path.is_none() { path = Some(arg); }
1178 else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1179 }
1180 Ok(StepKind::Expand { path, overrides })
1181 },
1182 ],
1183
1184 AssertEq => [
1185 name: "ASSERT_EQ",
1186 variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1187 syntax: "ASSERT_EQ <actual> <expected> | ASSERT_EQ --hash <sha256> <actual>",
1188 summary: "Assert strict equality.",
1189 description: indoc! {r#"
1190 Compares two evaluated values with typed equality (no coercion:
1191 `INT(42)` never equals `STRING("42")`), aborting the pipeline
1192 with a step-numbered error showing expected vs actual otherwise.
1193
1194 Both sides are values: `$var`, literals, templates, and calls
1195 evaluate in memory and never touch disk. Read files explicitly
1196 first (`LET $text: STRING = READ "out.txt"`, then
1197 `ASSERT_EQ $text ...`).
1198 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1199 holding a `PIPE` observes its backend bytes. `--hash` compares
1200 the SHA-256 of a string, pipe, or captured-stdout actual
1201 instead of the raw bytes (`stderr` is unsupported).
1202 "#},
1203 args: &[
1204 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 },
1205 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1206 ],
1207 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1208 default_output: None,
1209 examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1210 LET $status: INT = 200
1211 ASSERT_EQ $status 200
1212 "#} },
1213 Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1214 WRITE payload.bin stable-content
1215 LET $body: STRING = READ payload.bin
1216 ASSERT_EQ $body "stable-content"
1217 "#} },
1218 Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1219 # --hash compares the SHA-256 digest instead of raw bytes
1220 WRITE payload.bin stable-content
1221 LET $body: STRING = READ payload.bin
1222 ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1223 "#} } ],
1224 lower: |flags, args| {
1225 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1226 let mut it = args.into_iter();
1227 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)))?)?;
1228 let remaining: Vec<Arg> = it
1229 .map(lower_assert_operand)
1230 .collect::<Vec<Arg>>();
1231 let expected = if remaining.is_empty() {
1234 if hash.is_some() {
1235 None
1236 } else {
1237 return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1238 }
1239 } else {
1240 Some(join_value(remaining, "ASSERT_EQ")?)
1241 };
1242 Ok(StepKind::AssertEq { hash, actual, expected })
1243 },
1244 ],
1245
1246 AssertContains => [
1247 name: "ASSERT_CONTAINS",
1248 variant: AssertContains { haystack: AssertTarget, needle: Arg },
1249 syntax: "ASSERT_CONTAINS <haystack> <needle>",
1250 summary: "Assert containment.",
1251 description: indoc! {r#"
1252 Checks containment and aborts the pipeline with a step-numbered
1253 error otherwise: substring for strings, element match for lists,
1254 key presence for maps, substring over stream and pipe buffers.
1255
1256 Like `ASSERT_EQ`, both sides are values read without implicit
1257 I/O; read files explicitly first
1258 (`LET $text: STRING = READ "cfg.txt"`).
1259 Bare `stdout` / `stderr` observe stream buffers; a `$var`
1260 holding a `PIPE` observes its backend bytes.
1261 "#},
1262 args: &[
1263 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 },
1264 ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1265 ],
1266 flags: &[],
1267 default_output: None,
1268 examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1269 ECHO build-complete
1270 ASSERT_CONTAINS stdout "build-complete"
1271 "#} } ],
1272 lower: |flags, args| {
1273 let _ = flags;
1274 let mut it = args.into_iter();
1275 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)))?)?;
1276 let remaining: Vec<Arg> = it
1277 .map(lower_assert_operand)
1278 .collect::<Vec<Arg>>();
1279 if remaining.is_empty() {
1280 return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1281 }
1282 let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1283 Ok(StepKind::AssertContains { haystack, needle })
1284 },
1285 ],
1286
1287 HashSha256 => [
1288 name: "HASH_SHA256",
1289 variant: HashSha256 { path: Arg },
1290 syntax: "HASH_SHA256 <path>",
1291 summary: "Print SHA-256.",
1292 description: "Computes digest.",
1293 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1294 flags: &[],
1295 default_output: Some(Stream::Stdout),
1296 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1297 WRITE payload.txt hello
1298 HASH_SHA256 payload.txt
1299 "#} } ],
1300 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)))? }),
1301 ],
1302
1303 Exit => [
1304 name: "EXIT",
1305 variant: Exit(Arg),
1306 syntax: "EXIT <code>",
1307 summary: "Exit pipeline.",
1308 description: indoc! {r#"
1309 Stops the pipeline immediately with an `EXIT requested with code <code>`
1310 error; steps after it never run, at any nesting depth.
1311
1312 Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1313 anonymous background tasks are killed synchronously, and files written
1314 before the EXIT persist.
1315 "#},
1316 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1317 flags: &[],
1318 default_output: None,
1319 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1320 lower: |_flags, args| {
1321 let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1324 Ok(StepKind::Exit(code))
1325 },
1326 ],
1327
1328 Sleep => [
1329 name: "SLEEP",
1330 variant: Sleep { duration: Arg },
1331 syntax: "SLEEP <duration>",
1332 summary: "Pause execution for a duration.",
1333 description: indoc! {r#"
1334 Parks the step for the duration (e.g. 500ms, 10s, 2m).
1335
1336 Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1337 teardown interrupts the sleep. Cross-platform alternative to shell sleep
1338 for testing time boundaries.
1339 "#},
1340 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1341 flags: &[],
1342 default_output: None,
1343 examples: &[
1344 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1345 Example {
1346 name: "sleep variable duration",
1347 fence_meta: None,
1348 code: indoc! {r#"
1349 # Durations resolve at runtime, so variables work too:
1350 # quoted or bare, both bind the same string.
1351 LET $pause: STRING = "100ms"
1352 SLEEP $pause
1353
1354 LET $bare: STRING = 100ms
1355 SLEEP $bare
1356 "#},
1357 },
1358 ],
1359 lower: |_flags, args| {
1360 let mut it = args.into_iter();
1361 let raw = it
1362 .next()
1363 .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1364 if it.next().is_some() {
1365 return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1366 }
1367 Ok(StepKind::Sleep { duration: raw })
1370 },
1371 ],
1372
1373 ListAppend => [
1374 name: "LIST_APPEND",
1375 variant: ListAppend { list: String, item: Arg },
1376 syntax: "LIST_APPEND $list <item>",
1377 summary: "Append an item to a LIST variable in place.",
1378 description: indoc! {r#"
1379 Appends the item to the LIST variable in place.
1380
1381 When the binding holds the only reference the push runs in
1382 amortized constant time. Aliased buffers detach first, so
1383 other holders keep their contents.
1384 "#},
1385 args: &[
1386 ArgSpec { name: "list", arg_type: ArgType::List, description: "Target LIST variable (`$name`)", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
1387 ArgSpec { name: "item", arg_type: ArgType::Any, description: "Item to append (any value)", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
1388 ],
1389 flags: &[],
1390 default_output: None,
1391 examples: &[ Example { name: "list append", fence_meta: None, code: indoc! {r#"
1392 LET $items: LIST = []
1393 LIST_APPEND $items "first"
1394 LIST_APPEND $items "second"
1395 LET $want: LIST = ["first", "second"]
1396 ASSERT_EQ $items $want
1397 "#} } ],
1398 lower: |_flags, args| {
1399 let mut it = args.into_iter();
1400 let raw_list = it
1401 .next()
1402 .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)))?;
1403 let list = match raw_list {
1404 Arg::Expr(Expr::Var(name)) => name,
1405 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
1406 other => return Err(ParseError::validation("LIST_APPEND", format!("LIST_APPEND requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
1407 };
1408 if list.is_empty() {
1409 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND requires a LIST variable (e.g. LIST_APPEND $items $x)".to_string(), &SpanContext::line_only(0)))
1410 }
1411 let item = it
1412 .next()
1413 .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)))?;
1414 if it.next().is_some() {
1415 return Err(ParseError::validation("LIST_APPEND", "LIST_APPEND takes exactly two arguments: LIST_APPEND $list <item>".to_string(), &SpanContext::line_only(0)))
1416 }
1417 Ok(StepKind::ListAppend { list, item })
1418 },
1419 ],
1420}
1421
1422pub fn all_structural_metadata() -> Vec<CommandMeta> {
1430 vec![
1431 CommandMeta {
1432 name: "WITH_IO",
1433 syntax: "WITH_IO [<stream>[=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
1434 summary: "Reroute standard streams.",
1435 description: indoc! {r#"
1436 Reroutes the standard streams of the next command or, in block form,
1437 of every enclosed command.
1438
1439 Bindings map streams (`stdin`, `stdout`, `stderr`) to a PIPE-typed
1440 variable (`stdout=$p`, `stdin=$p`), resolved from the variable
1441 when the step runs. Both stdout and stderr pipes capture output
1442 the same way. Declare the handle first with `LET $p: PIPE`.
1443
1444 Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
1445 producer can finish before the consumer starts.
1446
1447 If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
1448 not, the pipe is a zero copy OS kernel pipe instead: pair it with a
1449 consumer that runs while the producer is alive, since output past the
1450 64 KiB kernel buffer stalls until drained. That promotion never crosses
1451 a function boundary: pipes created, bound, or passed by variable inside FUNC
1452 bodies are always script pipes, even when the surrounding task would
1453 otherwise promote.
1454
1455 A second producer or consumer on a live handle is an explicit
1456 error. A handle bound as output can later feed another
1457 command's `stdin`, connecting commands without touching the
1458 terminal. Binding `stdout` and `stderr` to the same live
1459 handle fails deterministically. Merge streams in shell
1460 via `2>&1` instead.
1461
1462 Nested blocks stack defaults; inline bindings override inherited ones for
1463 their command only; closing a block restores previous wiring.
1464 "#},
1465 args: &[],
1466 flags: &[],
1467 default_output: None,
1468 examples: &[
1469 Example {
1470 name: "with_io block",
1471 fence_meta: None,
1472 code: indoc! {r#"
1473 LET $log: PIPE
1474 WITH_IO [stdout=$log] {
1475 ECHO first
1476 ECHO second
1477 }
1478 WITH_IO [stdin=$log] WRITE captured.txt
1479 "#},
1480 },
1481 Example {
1482 name: "variable pipe binding",
1483 fence_meta: None,
1484 code: indoc! {r#"
1485 # Declare the pipe first: `LET $p: PIPE` mints a fresh
1486 # backend without touching a stream. A plain string here
1487 # would be a TypeMismatch.
1488 LET $p: PIPE
1489 WITH_IO [stdout=$p] ECHO hello
1490 WITH_IO [stdin=$p] READ_LINE $line
1491 ASSERT_EQ $line "hello"
1492 "#},
1493 },
1494 ],
1495 },
1496 CommandMeta {
1497 name: "FOR",
1498 syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
1499 summary: "Iterate over a list or map.",
1500 description: indoc! {r#"
1501 The loop variable receives each element (lists) or value (maps); with
1502 two variables, the first receives the key.
1503
1504 Loop variables are declared with explicit types and scoped per iteration;
1505 they do not leak outward. The body may be a braced block
1506 or a single-line `{ ... }` command.
1507
1508 `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
1509 `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
1510 empty when nothing matches, and rejects `..` escapes.
1511 "#},
1512 args: &[],
1513 flags: &[],
1514 default_output: None,
1515 examples: &[
1516 Example {
1517 name: "for loop",
1518 fence_meta: None,
1519 code: indoc! {r#"
1520 LET $items: LIST = ["a", "b"]
1521 FOR $item: STRING IN $items {
1522 ECHO $item
1523 }
1524
1525 LET $map: MAP = {"x": 1}
1526 FOR $k: STRING, $v: INT IN $map {
1527 ECHO "$k=$v"
1528 }
1529 "#},
1530 },
1531 Example {
1532 name: "expand every match",
1533 fence_meta: None,
1534 code: indoc! {r#"
1535 # single-line body; $x is a template path, WHO an override
1536 WRITE a.txt "hi \{{ env:WHO }}!"
1537 IMPORT [STD]
1538 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
1539 ASSERT_CONTAINS stdout "hi World!"
1540 "#},
1541 },
1542 ],
1543 },
1544 CommandMeta {
1545 name: "IF",
1546 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> } ...] [ELSE { <commands> }]",
1547 summary: "Conditional execution.",
1548 description: indoc! {r#"
1549 The condition is evaluated as a boolean expression.
1550
1551 Prefix `!` negates (`IF !false`); `&&` binds tighter than
1552 `||`, and both short-circuit, so `IF true || $missing`
1553 never evaluates the right side. Only Bool values are
1554 accepted as conditions.
1555 "#},
1556 args: &[],
1557 flags: &[],
1558 default_output: None,
1559 examples: &[
1560 Example {
1561 name: "if else",
1562 fence_meta: None,
1563 code: indoc! {r#"
1564 IMPORT [STD]
1565 IF true {
1566 WRITE yes.txt taken
1567 } ELSE {
1568 WRITE yes.txt skipped
1569 }
1570
1571 IF false {
1572 WRITE skipped.txt no
1573 } ELSE IF true {
1574 WRITE fallback.txt taken
1575 }
1576
1577 # !false evaluates to true, so this branch runs.
1578 IF !false {
1579 WRITE negated.txt taken
1580 }
1581 LET $yes_body: STRING = READ yes.txt
1582 LET $fallback_body: STRING = READ fallback.txt
1583 LET $negated_body: STRING = READ negated.txt
1584 ASSERT_EQ $yes_body "taken"
1585 ASSERT_EQ $fallback_body "taken"
1586 ASSERT_EQ $negated_body "taken"
1587 LET $t: STRING = PATH_TYPE("skipped.txt")
1588 ASSERT_EQ $t "absent"
1589 "#},
1590 },
1591 Example {
1592 name: "logical condition composition",
1593 fence_meta: None,
1594 code: indoc! {r#"
1595 IMPORT [STD]
1596 LET $role: STRING = "admin"
1597 LET $level: INT = 3
1598
1599 # || is true when either side holds; && needs both.
1600 IF $role == "owner" || $level >= 5 {
1601 WRITE unexpected.txt no
1602 } ELSE {
1603 WRITE fallback.txt or-false
1604 }
1605
1606 LET $fb: STRING = READ fallback.txt
1607 ASSERT_EQ $fb "or-false"
1608 LET $t1: STRING = PATH_TYPE("unexpected.txt")
1609 ASSERT_EQ $t1 "absent"
1610
1611 IF $role == "admin" || $level >= 5 {
1612 WRITE chosen.txt or-true
1613 }
1614
1615 LET $ch: STRING = READ chosen.txt
1616 ASSERT_EQ $ch "or-true"
1617
1618 IF $role == "admin" && $level >= 5 {
1619 WRITE unexpected-too.txt no
1620 } ELSE {
1621 WRITE and.txt and-false
1622 }
1623
1624 LET $an: STRING = READ and.txt
1625 ASSERT_EQ $an "and-false"
1626 LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
1627 ASSERT_EQ $t2 "absent"
1628 "#},
1629 },
1630 ],
1631 },
1632 CommandMeta {
1633 name: "LET",
1634 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> }",
1635 summary: "Bind script-local variables.",
1636 description: indoc! {r#"
1637 Declares a script-local variable with an explicit type (STRING, INT,
1638 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1639 in the same scope frame is a redeclaration error; mutate with
1640 `$var = <expr>`.
1641
1642 Variables are usable in templates (`{{ $var }}`), guards, and
1643 expressions. With `ASYNC`, spawns a background task and stores its
1644 handle (see ASYNC). The `$` sigil on the name is mandatory.
1645
1646 No hoisting: a variable exists only after its LET runs, in
1647 execution order. Reading `$var` before its LET (or after the
1648 block that declared it exits) fails with
1649 `undefined variable $var`. Scopes are a stack of frames and
1650 resolution walks innermost outward, so nothing pre-declares
1651 names. Function bodies read outer variables through the same
1652 walk, but their own LETs never leak out (see FUNC).
1653
1654 The right-hand side is always an expression — literals, lists, maps,
1655 arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
1656 parentheses), comparisons (`< <= > >=` binding tighter than
1657 `== !=`), logical `&&` (tighter) and `||` with short-circuit,
1658 `!` negation, `env:KEY` reads, `INSPECT($var)` snapshots,
1659 `GLOB("*.md")`, `INT(x)` / `FLOAT(x)` conversions — never a
1660 `{{ ... }}` template; interpolation happens in string values,
1661 not here.
1662 The one exception is pipes: `LET $p: PIPE` with no `=`
1663 and no initializer mints a fresh anonymous backend,
1664 lazily materialized at first binding, so two declarations
1665 never share a channel.
1666
1667 Numbers are numeric literals: `42` binds `INT`, `3.14` binds
1668 `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
1669 so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
1670 Division by zero, overflow, and non-finite results are errors.
1671 Both numeric sides compare numerically (`1 == 1.0` is true);
1672 otherwise `==`/`!=` compare rendered strings and ordering on
1673 non-numerics is a Type Error. Constant subtrees fold at parse
1674 time and dynamic arithmetic compiles to flat RPN with
1675 identical semantics.
1676
1677 Float equality is exact with no epsilon. Floats store decimals
1678 in binary, so a value is exact only when its reduced fraction
1679 has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
1680 (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
1681 repeat forever in binary (like 1/3 in decimal) and truncate,
1682 so `0.1 + 0.2 == 0.3` is false (the sum is
1683 `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
1684 .125, .625, .875 are exact; .1, .2, .3 and similar are
1685 approximations. Bound approximations instead of comparing
1686 them: `IF $sum > 0.299999 && $sum < 0.300001`.
1687
1688 Comparisons do not chain: `a < b < c` is a parse error, not
1689 `(a < b) < c`. Chaining would compare a `BOOL` against a
1690 number (a runtime Type Error in C-style parsing) or evaluate
1691 the middle term twice (Python-style chaining), so the grammar
1692 accepts exactly one comparison operator per level. Write the
1693 conjunction explicitly: `$a < $b && $b < $c`. The same holds
1694 for equality (`$a == $b == $c` is rejected).
1695
1696 Captured command output is a string, so convert before math:
1697 `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
1698 whitespace; `FLOAT` accepts int strings and rejects
1699 non-finite).
1700
1701 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
1702 as quoted.
1703
1704 When the right-hand side is a synchronous command
1705 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
1706 exact stdout bytes are captured into the variable as a string (no newline
1707 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
1708 an error). Combining capture with an explicit
1709 `WITH_IO [stdout=$var]` is a parse error.
1710
1711 Coming from Bash, the capture line looks familiar but behaves
1712 strictly:
1713
1714 | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
1715 | --- | --- | --- |
1716 | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
1717 | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
1718 | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
1719 | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
1720
1721 `LET $out: TYPE = AWAIT $var` binds the background task's
1722 explicit `RETURN` value instead (tasks stream their stdout
1723 live, so there is no output left to capture); a task that
1724 succeeded without `RETURN` yields `INT` 0, like a process
1725 exit status.
1726
1727 An inline block (`LET $var: TYPE = { <commands> }`) runs its
1728 steps in a fresh scope and binds the nearest `RETURN` value,
1729 like a zero-arg function body: fallthrough without `RETURN`
1730 binds `""`, and `BREAK`/`CONTINUE` escaping the block are
1731 errors. The block reads outer variables but its own LETs
1732 never leak out. A `{k: v}` shape still parses as a map
1733 literal; anything else in braces is a block.
1734
1735 The split is deliberate: synchronous commands capture
1736 stdout because they run inline to completion on the same
1737 thread; background tasks never capture stdout because
1738 concurrent output has no well-defined value. Task results
1739 travel only through `RETURN` (or `INT` 0 for void tasks).
1740
1741 `LET $e: STRING = env:FOO` reads the script environment into a plain
1742 string.
1743 "#},
1744 args: &[],
1745 flags: &[],
1746 default_output: None,
1747 examples: &[
1748 Example {
1749 name: "let",
1750 fence_meta: None,
1751 code: indoc! {r#"
1752 LET $name: STRING = "world"
1753 ECHO "hello, {{ $name }}"
1754
1755 LET $items: LIST = ["a", "b"]
1756 LET $count: INT = 42
1757 "#},
1758 },
1759 Example {
1760 name: "no hoisting",
1761 fence_meta: Some("expect_error:\"undefined variable\""),
1762 code: indoc! {r#"
1763 # reading before the LET runs is an error, not an empty value
1764 ECHO $too_early
1765 LET $too_early: STRING = "too late"
1766 "#},
1767 },
1768 Example {
1769 name: "glob binding",
1770 fence_meta: None,
1771 code: indoc! {r#"
1772 # the RHS is an expression: GLOB(...) runs and binds a list
1773 WRITE a.txt "x"
1774 IMPORT [STD]
1775 LET $files: LIST = GLOB("*.txt")
1776 FOR $f: STRING IN $files { ECHO $f }
1777 ASSERT_CONTAINS stdout "a.txt"
1778 "#},
1779 },
1780 Example {
1781 name: "scoped variable reverts",
1782 fence_meta: None,
1783 code: indoc! {r#"
1784 # LET inside a braced block reverts when the block exits
1785 LET $a: STRING = "outer"
1786
1787 [bool:true] {
1788 LET $a: STRING = "inner"
1789 WRITE inner.txt "{{ $a }}"
1790 }
1791
1792 WRITE outer.txt "{{ $a }}"
1793
1794 LET $in_body: STRING = READ inner.txt
1795 ASSERT_EQ $in_body "inner"
1796
1797 LET $out_body: STRING = READ outer.txt
1798 ASSERT_EQ $out_body "outer"
1799 "#},
1800 },
1801 Example {
1802 name: "capture command output",
1803 fence_meta: None,
1804 code: indoc! {r#"
1805 LET $out: STRING = ECHO hi
1806 ASSERT_EQ $out "hi\n"
1807 "#},
1808 },
1809 Example {
1810 name: "inline block",
1811 fence_meta: None,
1812 code: indoc! {r#"
1813 LET $who: STRING = "ada"
1814 LET $res: STRING = {
1815 LET $loud: STRING = "{{ $who }}!"
1816 RETURN $loud
1817 }
1818 ASSERT_EQ $res "ada!"
1819
1820 # Any declared type works: the block value checks like any RHS.
1821 LET $n: INT = {
1822 RETURN 40 + 2
1823 }
1824 ASSERT_EQ $n 42
1825 "#},
1826 },
1827 Example {
1828 name: "arithmetic over captured output",
1829 fence_meta: None,
1830 code: indoc! {r#"
1831 LET $size_str: STRING = ECHO 41
1832 IMPORT [STD]
1833 LET $total: INT = INT($size_str) + 1
1834 ASSERT_EQ $total 42
1835
1836 LET $ratio: FLOAT = 1 + 2.5
1837 ASSERT_EQ $ratio 3.5
1838
1839 # Int x Int stays INT: integer division truncates.
1840 LET $half: INT = 7 / 2
1841 ASSERT_EQ $half 3
1842 "#},
1843 },
1844 Example {
1845 name: "float equality is exact",
1846 fence_meta: None,
1847 code: indoc! {r#"
1848 # Binary fractions compare cleanly; decimal fractions may not:
1849 # 0.1 + 0.2 is 0.30000000000000004, so == is false.
1850 IMPORT [STD]
1851 LET $exact: BOOL = 0.5 + 0.25 == 0.75
1852 LET $decimal: BOOL = 0.1 + 0.2 == 0.3
1853 IF $exact {
1854 WRITE exact.txt yes
1855 }
1856 IF $decimal {
1857 WRITE unexpected.txt no
1858 }
1859
1860 LET $ok: STRING = READ exact.txt
1861 ASSERT_EQ $ok "yes"
1862
1863 LET $t: STRING = PATH_TYPE("unexpected.txt")
1864 ASSERT_EQ $t "absent"
1865 "#},
1866 },
1867 Example {
1868 name: "bound inexact decimals",
1869 fence_meta: None,
1870 code: indoc! {r#"
1871 # Never test inexact decimals for equality; bound them.
1872 LET $sum: FLOAT = 0.1 + 0.2
1873 IF $sum > 0.299999 && $sum < 0.300001 {
1874 WRITE bounded.txt yes
1875 }
1876
1877 LET $ok: STRING = READ bounded.txt
1878 ASSERT_EQ $ok "yes"
1879 "#},
1880 },
1881 Example {
1882 name: "inspect a variable",
1883 fence_meta: None,
1884 code: indoc! {r#"
1885 # INSPECT($var) snapshots a variable into a MAP: declared
1886 # type plus live details (pipe backend stats here), so
1887 # scripts can branch on engine state.
1888 LET $p: PIPE
1889 WITH_IO [stdout=$p] ECHO hello
1890 LET $info: MAP = INSPECT($p)
1891 IF $info.is_os_pipe {
1892 WRITE unexpected.txt "should be a script pipe"
1893 }
1894
1895 ASSERT_EQ $info.type "PIPE"
1896 "#},
1897 },
1898 ],
1899 },
1900 CommandMeta {
1901 name: "MUTATION",
1902 syntax: "$var = <expr>",
1903 summary: "Mutate a declared variable.",
1904 description: indoc! {r#"
1905 Reassigns an existing variable, converting the new value to
1906 the type declared at LET time. The explicit annotation is
1907 what authorizes string-to-number conversion here (`$n = "42"`
1908 binds 42 for an INT); a non-numeric string is an error.
1909 Expressions never convert: `"100" + 1` is a Type Error, use
1910 `INT()` / `FLOAT()` to cross that boundary explicitly.
1911
1912 The leading `$` distinguishes mutation from `KEY=value` command
1913 assignments. Assigning an undeclared variable or a mismatched type is
1914 an error.
1915
1916 Mutation writes through to the scope where the variable was
1917 declared, so it survives block exit: `LET $x` outside a block
1918 followed by `$x = ...` inside still reads back the new value
1919 afterwards, for every type. This is the counterpart to LET
1920 shadowing, where `LET $x` *inside* the block declares a
1921 separate inner variable that reverts on exit.
1922 "#},
1923 args: &[],
1924 flags: &[],
1925 default_output: None,
1926 examples: &[
1927 Example {
1928 name: "mutate",
1929 fence_meta: None,
1930 code: indoc! {r#"
1931 LET $count: INT = 1
1932 $count = 2
1933 ASSERT_EQ $count 2
1934 "#},
1935 },
1936 Example {
1937 name: "convert before math",
1938 fence_meta: None,
1939 code: indoc! {r#"
1940 # Captured output is a string: `"100" + 1` is a Type Error.
1941 # Convert explicitly, then mutate with arithmetic.
1942 LET $raw: STRING = ECHO 100
1943 IMPORT [STD]
1944 LET $n: INT = INT($raw)
1945 $n = $n + 1
1946
1947 # The declared type also converts plain strings on assignment.
1948 $n = "42"
1949 ASSERT_EQ $n 42
1950
1951 # Same crossing for decimals via FLOAT().
1952 LET $frac_str: STRING = ECHO 2.5
1953 LET $f: FLOAT = FLOAT($frac_str) + 0.25
1954 ASSERT_EQ $f 2.75
1955 "#},
1956 },
1957 ],
1958 },
1959 CommandMeta {
1960 name: "ASYNC",
1961 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
1962 summary: "Run steps in a background thread.",
1963 description: indoc! {r#"
1964 Runs a command or block of commands in a background thread with
1965 subshell isolation.
1966
1967 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
1968 task handle for `AWAIT`. Task output streams live to the parent
1969 stdout; a task publishes a value with an explicit `RETURN`,
1970 which `LET $out: TYPE = AWAIT $task` binds.
1971 "#},
1972 args: &[],
1973 flags: &[],
1974 default_output: None,
1975 examples: &[
1976 Example {
1977 name: "async",
1978 fence_meta: None,
1979 code: indoc! {r#"
1980 ASYNC ECHO "first"
1981
1982 ASYNC {
1983 ECHO "first"
1984 ECHO "second"
1985 }
1986 "#},
1987 },
1988 Example {
1989 name: "async task handle",
1990 fence_meta: None,
1991 code: indoc! {r#"
1992 LET $task: HANDLE = ASYNC {
1993 ECHO "built"
1994 }
1995 AWAIT $task
1996 "#},
1997 },
1998 ],
1999 },
2000 CommandMeta {
2001 name: "AWAIT",
2002 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
2003 summary: "Join a background task.",
2004 description: indoc! {r#"
2005 Blocks until the named task completes. Propagates errors if the task failed.
2006
2007 Task output streams live during the run; joining binds nothing by
2008 itself. `LET $out: TYPE = AWAIT $var` binds the task's explicit
2009 `RETURN` value instead, or `INT` 0 when the task succeeded
2010 without one (add `RETURN <expr>` to the task body to yield
2011 a value).
2012 "#},
2013 args: &[],
2014 flags: &[],
2015 default_output: None,
2016 examples: &[
2017 Example {
2018 name: "await",
2019 fence_meta: None,
2020 code: indoc! {r#"
2021 LET $task: HANDLE = ASYNC ECHO "done"
2022 AWAIT $task
2023 "#},
2024 },
2025 Example {
2026 name: "await capture",
2027 fence_meta: None,
2028 code: indoc! {r#"
2029 LET $task: HANDLE = ASYNC {
2030 ECHO "logged"
2031 RETURN "returned"
2032 }
2033 LET $out: STRING = AWAIT $task
2034 ASSERT_EQ $out "returned"
2035 "#},
2036 },
2037 ],
2038 },
2039 CommandMeta {
2040 name: "CANCEL",
2041 syntax: "CANCEL $var",
2042 summary: "Synchronously cancel a background task.",
2043 description: indoc! {r#"
2044 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
2045
2046 Blocking: returns only after the task thread has been joined and its OS
2047 process reaped, so no residual filesystem or stream mutation follows. A
2048 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
2049 "#},
2050 args: &[],
2051 flags: &[],
2052 default_output: None,
2053 examples: &[Example {
2054 name: "cancel",
2055 fence_meta: None,
2056 code: indoc! {r#"
2057 LET $task: HANDLE = ASYNC SLEEP 30s
2058 CANCEL $task
2059 "#},
2060 }],
2061 },
2062 CommandMeta {
2063 name: "TIMEOUT",
2064 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
2065 summary: "Enforce an execution deadline.",
2066 description: indoc! {r#"
2067 Aborts the wrapped step or block with a deadline error if it exceeds the
2068 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
2069
2070 A blocking foreground process is killed.
2071 "#},
2072 args: &[],
2073 flags: &[],
2074 default_output: None,
2075 examples: &[
2076 Example {
2077 name: "timeout",
2078 fence_meta: None,
2079 code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
2080 },
2081 Example {
2082 name: "timeout block",
2083 fence_meta: None,
2084 code: indoc! {r#"
2085 TIMEOUT 30s {
2086 WRITE a.txt one
2087 WRITE b.txt two
2088 }
2089 "#},
2090 },
2091 Example {
2092 name: "timeout variable duration",
2093 fence_meta: None,
2094 code: indoc! {r#"
2095 # Durations resolve at runtime, so variables work too.
2096 LET $budget: DURATION = "30s"
2097 TIMEOUT $budget WRITE heartbeat.txt alive
2098
2099 LET $beat: STRING = READ heartbeat.txt
2100 ASSERT_EQ $beat "alive"
2101 "#},
2102 },
2103 ],
2104 },
2105 CommandMeta {
2106 name: "FUNC",
2107 syntax: "FUNC NAME([$param: TYPE, ...]) { <commands> }",
2108 summary: "Define a user function.",
2109 description: indoc! {r#"
2110 Defines a user function with UPPERCASE name and explicitly typed
2111 parameters.
2112
2113 Params bind by position, converting each argument to its
2114 declared parameter type before the body runs.
2115 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
2116 FUNC definition is scoped to its block and reverts on exit. Names share
2117 one namespace with native and host-registered functions, which a FUNC
2118 may never shadow.
2119
2120 Functions resolve like variables: a name is visible from its
2121 definition line, so recursion works but mutual recursion does
2122 not (the second name does not exist while the first body
2123 lowers). Calls name their module (`STD::GLOB(...)`) unless
2124 imported; see IMPORT.
2125
2126 Invoke any function with one syntax: `NAME(...)` as a statement
2127 (discarding the value) or `LET $var: TYPE = NAME(...)` to capture
2128 the RETURN value (fallthrough without RETURN captures as "").
2129 "#},
2130 args: &[],
2131 flags: &[],
2132 default_output: None,
2133 examples: &[
2134 Example {
2135 name: "func def call",
2136 fence_meta: None,
2137 code: indoc! {r#"
2138 FUNC GREET($name: STRING) {
2139 RETURN $name
2140 }
2141
2142 LET $res: STRING = GREET("ada")
2143 ASSERT_EQ $res "ada"
2144
2145 # Statement form: parens stay, the value drops.
2146 GREET("bex")
2147 "#},
2148 },
2149 Example {
2150 name: "call with pipes",
2151 fence_meta: None,
2152 code: indoc! {r#"
2153 # A pipe handle travels into a function as a typed argument
2154 # and is usable as a binding target in both directions.
2155 # `LET $p: PIPE` mints the handle; `$p` passes it on.
2156 FUNC DRAIN($q: PIPE) {
2157 WITH_IO [stdin=$q] READ_LINE $line
2158 RETURN $line
2159 }
2160
2161 LET $p: PIPE
2162 WITH_IO [stdout=$p] ECHO "payload"
2163
2164 LET $got: STRING = DRAIN($p)
2165 ASSERT_EQ $got "payload"
2166 "#},
2167 },
2168 ],
2169 },
2170 CommandMeta {
2171 name: "RETURN",
2172 syntax: "RETURN [<expr>]",
2173 summary: "Return a value from a function, task, or inline block.",
2174 description: indoc! {r#"
2175 Ends the nearest enclosing boundary with a value: a function
2176 call, an `ASYNC` task (bound by `LET $o = AWAIT $t`), or an
2177 inline `LET` block. Bare `RETURN` with no expression yields
2178 `""`.
2179
2180 Falling off the end without RETURN yields "". RETURN with no
2181 enclosing boundary (including at top level) is an error; use
2182 EXIT or ECHO there.
2183 "#},
2184 args: &[],
2185 flags: &[],
2186 default_output: None,
2187 examples: &[Example {
2188 name: "return",
2189 fence_meta: None,
2190 code: indoc! {r#"
2191 FUNC PICK($flag: BOOL) {
2192 IF $flag {
2193 RETURN "yes"
2194 }
2195 RETURN "no"
2196 }
2197
2198 LET $res: STRING = PICK(true)
2199 ASSERT_EQ $res "yes"
2200 "#},
2201 }],
2202 },
2203 CommandMeta {
2204 name: "WHILE",
2205 syntax: "WHILE <bool-expr> { <commands> }",
2206 summary: "Loop while a condition holds.",
2207 description: indoc! {r#"
2208 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
2209 non-Bool is a type error).
2210
2211 Each iteration runs in a fresh scope; mutate outer state with $var = ...
2212 so the next check observes it. BREAK exits the loop; CONTINUE skips to
2213 the next check.
2214 "#},
2215 args: &[],
2216 flags: &[],
2217 default_output: None,
2218 examples: &[Example {
2219 name: "while loop",
2220 fence_meta: None,
2221 code: indoc! {r#"
2222 LET $done: BOOL = false
2223 WHILE !$done {
2224 WRITE tick.txt "once"
2225 $done = true
2226 }
2227
2228 LET $tick: STRING = READ tick.txt
2229 ASSERT_EQ $tick "once"
2230 "#},
2231 }],
2232 },
2233 CommandMeta {
2234 name: "BREAK",
2235 syntax: "BREAK",
2236 summary: "Exit the innermost loop.",
2237 description: indoc! {r#"
2238 Exits the innermost enclosing FOR or WHILE loop.
2239
2240 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
2241 "#},
2242 args: &[],
2243 flags: &[],
2244 default_output: None,
2245 examples: &[Example {
2246 name: "break",
2247 fence_meta: None,
2248 code: indoc! {r#"
2249 FOR $x: STRING IN ["a", "b"] {
2250 BREAK
2251 }
2252 "#},
2253 }],
2254 },
2255 CommandMeta {
2256 name: "CONTINUE",
2257 syntax: "CONTINUE",
2258 summary: "Skip to the next loop iteration.",
2259 description: indoc! {r#"
2260 Skips the rest of the innermost enclosing FOR or WHILE body and starts
2261 the next iteration.
2262
2263 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
2264 "#},
2265 args: &[],
2266 flags: &[],
2267 default_output: None,
2268 examples: &[Example {
2269 name: "continue",
2270 fence_meta: None,
2271 code: indoc! {r#"
2272 FOR $x: STRING IN ["a", "b"] {
2273 CONTINUE
2274 }
2275 "#},
2276 }],
2277 },
2278 CommandMeta {
2279 name: KEYWORD_IMPORT,
2280 syntax: "IMPORT [<module>, ...] | IMPORT <module>",
2281 summary: "Bring module functions into bare-call scope.",
2282 description: indoc! {r#"
2283 Every function call names its module (`STD::GLOB(...)`,
2284 `MOCK::READ_CSV(...)`) unless the module is imported:
2285 `IMPORT [STD]` lets the rest of the scope call `GLOB(...)`
2286 bare. Calls resolve at parse time against `SCRIPT`
2287 definitions first, then imported modules; unknown modules,
2288 unknown functions, and unimported bare calls are parse
2289 errors, never runtime surprises.
2290
2291 IMPORT is a lowering directive, not a step: it applies from
2292 its line to the enclosing block exit, then reverts, exactly
2293 like `LET` scoping but with no runtime footprint. Guards do
2294 not apply to it. Two imported modules exporting one name is
2295 an ambiguity error: qualify the call instead.
2296
2297 `EXPORT` is reserved for future script-module support and
2298 cannot be used yet.
2299 "#},
2300 args: &[],
2301 flags: &[],
2302 default_output: None,
2303 examples: &[Example {
2304 name: "import",
2305 fence_meta: None,
2306 code: indoc! {r#"
2307 WRITE a.txt "hi \{{ env:WHO }}!"
2308 IMPORT [STD]
2309 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
2310 ASSERT_CONTAINS stdout "hi World!"
2311 "#},
2312 }],
2313 },
2314 ]
2315}
2316
2317impl fmt::Display for StepKind {
2320 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2321 match self {
2322 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
2323 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
2324 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
2325 StepKind::Env { key, value } => {
2326 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
2327 }
2328 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
2329 StepKind::RunExec { argv } => {
2330 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
2331 write!(f, "RUN [{}]", parts.join(", "))
2332 }
2333 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
2334 StepKind::Copy {
2335 from_workspace,
2336 from,
2337 to,
2338 } => {
2339 if let Some(target) = from_workspace {
2340 write!(
2341 f,
2342 "COPY --from-workspace {} {} {}",
2343 target,
2344 fmt_value(from, quote_arg),
2345 fmt_value(to, quote_arg)
2346 )
2347 } else {
2348 write!(
2349 f,
2350 "COPY {} {}",
2351 fmt_value(from, quote_arg),
2352 fmt_value(to, quote_arg)
2353 )
2354 }
2355 }
2356 StepKind::Symlink {
2357 from_workspace,
2358 from,
2359 to,
2360 } => {
2361 if let Some(target) = from_workspace {
2362 write!(
2363 f,
2364 "SYMLINK --from-workspace {} {} {}",
2365 target,
2366 fmt_value(from, quote_arg),
2367 fmt_value(to, quote_arg)
2368 )
2369 } else {
2370 write!(
2371 f,
2372 "SYMLINK {} {}",
2373 fmt_value(from, quote_arg),
2374 fmt_value(to, quote_arg)
2375 )
2376 }
2377 }
2378 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
2379 StepKind::Ls(a) => {
2380 write!(f, "LS")?;
2381 if let Some(x) = a {
2382 write!(f, " {}", fmt_value(x, quote_arg))?;
2383 }
2384 Ok(())
2385 }
2386 StepKind::Cwd => write!(f, "CWD"),
2387 StepKind::Read(a) => {
2388 write!(f, "READ")?;
2389 if let Some(x) = a {
2390 write!(f, " {}", fmt_value(x, quote_arg))?;
2391 }
2392 Ok(())
2393 }
2394 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
2395 StepKind::Write { path, contents } => {
2396 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
2397 if let Some(b) = contents {
2398 write!(f, " {}", fmt_value(b, quote_msg))?;
2399 }
2400 Ok(())
2401 }
2402 StepKind::Append { path, contents } => {
2403 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
2404 if let Some(b) = contents {
2405 write!(f, " {}", fmt_value(b, quote_msg))?;
2406 }
2407 Ok(())
2408 }
2409 StepKind::Expand { path, overrides } => {
2410 write!(f, "EXPAND")?;
2411 if let Some(p) = path {
2412 write!(f, " {}", fmt_value(p, quote_arg))?;
2413 }
2414 for (k, v) in overrides {
2415 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
2416 }
2417 Ok(())
2418 }
2419 StepKind::AssertEq {
2420 hash,
2421 actual,
2422 expected,
2423 } => {
2424 if let Some(d) = hash {
2425 write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
2426 } else {
2427 write!(
2428 f,
2429 "ASSERT_EQ {} {}",
2430 fmt_assert_target(actual),
2431 fmt_value(
2432 expected
2433 .as_ref()
2434 .expect("Display of ASSERT_EQ without --hash needs expected"),
2435 quote_msg
2436 )
2437 )?;
2438 }
2439 Ok(())
2440 }
2441 StepKind::AssertContains { haystack, needle } => write!(
2442 f,
2443 "ASSERT_CONTAINS {} {}",
2444 fmt_assert_target(haystack),
2445 fmt_value(needle, quote_msg)
2446 ),
2447 StepKind::WithIo { bindings, cmd } => {
2448 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2449 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
2450 }
2451 StepKind::WithIoBlock { bindings } => {
2452 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2453 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
2454 }
2455 StepKind::CopyGit {
2456 rev,
2457 from,
2458 to,
2459 include_dirty,
2460 } => {
2461 if *include_dirty {
2462 write!(
2463 f,
2464 "COPY_GIT --include-dirty {} {} {}",
2465 fmt_value(rev, quote_arg),
2466 fmt_value(from, quote_arg),
2467 fmt_value(to, quote_arg)
2468 )
2469 } else {
2470 write!(
2471 f,
2472 "COPY_GIT {} {} {}",
2473 fmt_value(rev, quote_arg),
2474 fmt_value(from, quote_arg),
2475 fmt_value(to, quote_arg)
2476 )
2477 }
2478 }
2479 StepKind::HashSha256 { path } => {
2480 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
2481 }
2482 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
2483 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
2484 StepKind::ListAppend { list, item } => {
2485 write!(f, "LIST_APPEND ${} {}", list, fmt_raw_arg(item))
2486 }
2487 StepKind::For {
2488 key_var,
2489 key_type,
2490 var,
2491 var_type,
2492 in_expr,
2493 body,
2494 } => {
2495 match key_var {
2496 Some(k) => {
2497 let kt = key_type.as_deref().unwrap_or("STRING");
2498 write!(
2499 f,
2500 "FOR ${}: {}, ${}: {} IN {} {{",
2501 k, kt, var, var_type, in_expr
2502 )?
2503 }
2504 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
2505 }
2506 for s in body {
2507 write!(f, "\n {}", s)?;
2508 }
2509 write!(f, "\n}}")
2510 }
2511 StepKind::If {
2512 cond,
2513 then_body,
2514 else_ifs,
2515 else_body,
2516 } => {
2517 write!(f, "IF {} {{", cond)?;
2518 for s in then_body {
2519 write!(f, "\n {}", s)?;
2520 }
2521 write!(f, " }}")?;
2522 for (c, b) in else_ifs {
2523 write!(f, " ELSE IF {} {{", c)?;
2524 for s in b {
2525 write!(f, "\n {}", s)?;
2526 }
2527 write!(f, " }}")?;
2528 }
2529 if let Some(b) = else_body {
2530 write!(f, " ELSE {{")?;
2531 for s in b {
2532 write!(f, "\n {}", s)?;
2533 }
2534 write!(f, " }}")?;
2535 }
2536 Ok(())
2537 }
2538 StepKind::Assign {
2539 var,
2540 decl_type,
2541 expr,
2542 } => {
2543 if matches!(expr, Expr::FreshPipe) {
2545 write!(f, "LET ${}: {}", var, decl_type)
2546 } else {
2547 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
2548 }
2549 }
2550 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
2551 StepKind::AssignCapture {
2552 var,
2553 decl_type,
2554 cmd,
2555 } => {
2556 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
2557 }
2558 StepKind::AsyncBlock { body } => {
2559 write!(f, "ASYNC {{")?;
2560 for s in body {
2561 write!(f, "\n {}", s)?;
2562 }
2563 write!(f, "\n}}")
2564 }
2565 StepKind::AssignAsync {
2566 var,
2567 decl_type,
2568 body,
2569 } => {
2570 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
2571 for s in body {
2572 write!(f, "\n {}", s)?;
2573 }
2574 write!(f, "\n}}")
2575 }
2576 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
2577 StepKind::AwaitCapture {
2578 out_var,
2579 out_type,
2580 task_var,
2581 } => {
2582 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
2583 }
2584 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
2585 StepKind::Timeout { duration, body } => {
2586 let budget = fmt_raw_arg(duration);
2587 if body.len() == 1 {
2588 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
2589 } else {
2590 write!(f, "TIMEOUT {} {{", budget)?;
2591 for s in body {
2592 write!(f, "\n {}", s)?;
2593 }
2594 write!(f, "\n}}")
2595 }
2596 }
2597 StepKind::FuncDef { name, params, body } => {
2598 let ps: Vec<String> = params
2599 .iter()
2600 .map(|(p, t)| format!("${}: {}", p, t))
2601 .collect();
2602 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
2603 for s in body {
2604 write!(f, "\n {}", s)?;
2605 }
2606 write!(f, "\n}}")
2607 }
2608 StepKind::Call { name, args } => {
2609 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
2610 write!(f, "{}({})", name, ps.join(", "))
2611 }
2612 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
2613 StepKind::While { cond, body } => {
2614 write!(f, "WHILE {} {{", cond)?;
2615 for s in body {
2616 write!(f, "\n {}", s)?;
2617 }
2618 write!(f, "\n}}")
2619 }
2620 StepKind::Break => write!(f, "BREAK"),
2621 StepKind::Continue => write!(f, "CONTINUE"),
2622 }
2623 }
2624}
2625
2626#[cfg(test)]
2627mod tests {
2628 use super::*;
2629 use crate::command::{format_duration, parse_duration};
2630 use crate::parser::parse_script;
2631
2632 fn parse_err(script: &str) -> String {
2633 parse_script(script, lower_command)
2634 .expect_err("script must fail to parse")
2635 .to_string()
2636 }
2637
2638 #[test]
2639 fn malformed_with_io_binding_names_the_bad_binding() {
2640 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
2641 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
2642 assert!(!err.contains("unknown command"), "{err}");
2643 assert!(err.contains("stdout=discard"), "{err}");
2644 assert!(err.contains("[stdout=$p]"), "{err}");
2645 }
2646
2647 #[test]
2648 fn connect_is_unknown_command() {
2649 let err = parse_err("CONNECT 127.0.0.1:8080\n");
2652 assert!(err.contains("unknown command"), "{err}");
2653 assert!(err.contains("CONNECT"), "{err}");
2654 }
2655
2656 #[test]
2657 fn listen_is_unknown_command() {
2658 let err = parse_err("LISTEN 127.0.0.1:8080\n");
2661 assert!(err.contains("unknown command"), "{err}");
2662 assert!(err.contains("LISTEN"), "{err}");
2663 }
2664
2665 #[test]
2666 fn await_without_task_variable_points_at_syntax() {
2667 let err = parse_err("AWAIT ECHO \"test\"\n");
2668 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
2669 assert!(!err.contains("unknown command"), "{err}");
2670 assert!(err.contains("AWAIT $t"), "{err}");
2671 assert!(err.contains("ECHO"), "{err}");
2672 }
2673
2674 #[test]
2675 fn bare_let_without_type_points_at_typed_syntax() {
2676 let err = parse_err("LET $x = 1\n");
2677 assert!(err.contains("invalid syntax for command LET"), "{err}");
2678 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
2679 }
2680
2681 #[test]
2682 fn workspace_accepts_all_four_targets_uppercase_only() {
2683 for (spelling, target) in [
2686 ("SNAPSHOT", WorkspaceTarget::Snapshot),
2687 ("LOCAL", WorkspaceTarget::Local),
2688 ("CACHE", WorkspaceTarget::Cache { local: false }),
2689 ("SYSTEM", WorkspaceTarget::System),
2690 ] {
2691 let steps =
2692 parse_script(&format!("WORKSPACE {spelling}\n"), lower_command).expect("parses");
2693 assert_eq!(steps.len(), 1);
2694 assert_eq!(steps[0].kind, StepKind::Workspace(target.clone()));
2695 assert_eq!(steps[0].kind.to_string(), format!("WORKSPACE {target}"));
2696 }
2697 for spelling in ["snapshot", "local", "cache", "system"] {
2698 let err = parse_err(&format!("WORKSPACE {spelling}\n"));
2699 assert!(
2700 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
2701 "{spelling}: {err}"
2702 );
2703 }
2704 let err = parse_err("WORKSPACE REMOTE\n");
2705 assert!(
2706 err.contains("expected one of SNAPSHOT|LOCAL|CACHE|SYSTEM"),
2707 "{err}"
2708 );
2709
2710 let steps = parse_script("WORKSPACE CACHE --local\n", lower_command).expect("parses");
2713 assert_eq!(
2714 steps[0].kind,
2715 StepKind::Workspace(WorkspaceTarget::Cache { local: true })
2716 );
2717 assert_eq!(steps[0].kind.to_string(), "WORKSPACE CACHE --local");
2718 for bad in [
2719 "WORKSPACE SNAPSHOT --local\n",
2720 "WORKSPACE LOCAL --local\n",
2721 "WORKSPACE SYSTEM --local\n",
2722 ] {
2723 let err = parse_err(bad);
2724 assert!(err.contains("--local requires CACHE"), "{bad}: {err}");
2725 }
2726 }
2727
2728 #[test]
2729 fn copy_from_workspace_selects_source_root() {
2730 for (spelling, target) in [
2731 ("SNAPSHOT", WorkspaceTarget::Snapshot),
2732 ("LOCAL", WorkspaceTarget::Local),
2733 ("CACHE", WorkspaceTarget::Cache { local: false }),
2734 ("SYSTEM", WorkspaceTarget::System),
2735 ] {
2736 let steps = parse_script(
2737 &format!("COPY --from-workspace {spelling} a.txt b.txt\n"),
2738 lower_command,
2739 )
2740 .expect("parses");
2741 assert!(
2742 matches!(&steps[0].kind, StepKind::Copy { from_workspace: Some(t), .. } if *t == target),
2743 "unexpected lowering for {spelling}: {:?}",
2744 steps[0].kind
2745 );
2746 }
2747 for script in [
2750 "COPY --from-workspace=CACHE a.txt b.txt\n",
2751 "COPY --from-workspace=\"CACHE\" a.txt b.txt\n",
2752 ] {
2753 let steps = parse_script(script, lower_command).expect("parses");
2754 assert!(
2755 matches!(
2756 &steps[0].kind,
2757 StepKind::Copy {
2758 from_workspace: Some(WorkspaceTarget::Cache { local: false }),
2759 ..
2760 }
2761 ),
2762 "unexpected lowering for {script:?}: {:?}",
2763 steps[0].kind
2764 );
2765 }
2766 let steps = parse_script("COPY a.txt b.txt\n", lower_command).expect("parses");
2768 assert!(
2769 matches!(
2770 &steps[0].kind,
2771 StepKind::Copy {
2772 from_workspace: None,
2773 ..
2774 }
2775 ),
2776 "unexpected lowering: {:?}",
2777 steps[0].kind
2778 );
2779 for bad in ["REMOTE", "local"] {
2781 let err = parse_err(&format!("COPY --from-workspace {bad} a.txt b.txt\n"));
2782 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
2783 }
2784 }
2785
2786 #[test]
2787 fn symlink_from_workspace_selects_source_root() {
2788 for (spelling, target) in [
2789 ("SNAPSHOT", WorkspaceTarget::Snapshot),
2790 ("LOCAL", WorkspaceTarget::Local),
2791 ("CACHE", WorkspaceTarget::Cache { local: false }),
2792 ("SYSTEM", WorkspaceTarget::System),
2793 ] {
2794 let steps = parse_script(
2795 &format!("SYMLINK --from-workspace {spelling} a.txt b.txt\n"),
2796 lower_command,
2797 )
2798 .expect("parses");
2799 assert!(
2800 matches!(&steps[0].kind, StepKind::Symlink { from_workspace: Some(t), .. } if *t == target),
2801 "unexpected lowering for {spelling}: {:?}",
2802 steps[0].kind
2803 );
2804 let roundtrip = steps[0].kind.to_string();
2805 assert!(
2806 roundtrip.contains("--from-workspace"),
2807 "display should round-trip the flag: {roundtrip}"
2808 );
2809 }
2810 let steps = parse_script("SYMLINK a.txt b.txt\n", lower_command).expect("parses");
2811 assert!(
2812 matches!(
2813 &steps[0].kind,
2814 StepKind::Symlink {
2815 from_workspace: None,
2816 ..
2817 }
2818 ),
2819 "unexpected lowering: {:?}",
2820 steps[0].kind
2821 );
2822 for bad in ["REMOTE", "local"] {
2823 let err = parse_err(&format!("SYMLINK --from-workspace {bad} a.txt b.txt\n"));
2824 assert!(err.contains("unknown workspace source"), "{bad}: {err}");
2825 }
2826 let steps = parse_script(
2828 "SYMLINK --from-workspace=LOCAL a.txt b.txt\n",
2829 lower_command,
2830 )
2831 .expect("parses");
2832 assert!(
2833 matches!(
2834 &steps[0].kind,
2835 StepKind::Symlink {
2836 from_workspace: Some(WorkspaceTarget::Local),
2837 ..
2838 }
2839 ),
2840 "unexpected lowering: {:?}",
2841 steps[0].kind
2842 );
2843 }
2844
2845 #[test]
2846 fn dash_dash_equals_tokens_bypass_assignment() {
2847 let steps = parse_script("ENV --foo=bar\n", lower_command).expect("parses");
2850 let StepKind::Env { key, value } = &steps[0].kind else {
2851 panic!("expected Env, got {:?}", steps[0].kind);
2852 };
2853 assert_eq!(key, "--foo");
2854 assert_eq!(value.as_str(), "bar");
2855
2856 let steps = parse_script("RUN echo --foo=bar\n", lower_command).expect("parses");
2857 let StepKind::Run(cmd) = &steps[0].kind else {
2858 panic!("expected Run, got {:?}", steps[0].kind);
2859 };
2860 assert!(
2861 cmd.as_str().contains("--foo=bar"),
2862 "unexpected RUN lowering: {cmd:?}"
2863 );
2864
2865 let digest = "08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c";
2866 let steps = parse_script(&format!("ASSERT_EQ --hash={digest} $body\n"), lower_command)
2867 .expect("parses");
2868 let StepKind::AssertEq { hash, .. } = &steps[0].kind else {
2869 panic!("expected AssertEq, got {:?}", steps[0].kind);
2870 };
2871 assert_eq!(hash.as_deref(), Some(digest));
2872
2873 let steps = parse_script("EXPAND --k=v\n", lower_command).expect("parses");
2875 let StepKind::Expand { path, overrides } = &steps[0].kind else {
2876 panic!("expected Expand, got {:?}", steps[0].kind);
2877 };
2878 assert!(path.is_none());
2879 assert_eq!(overrides.len(), 1);
2880 assert_eq!(overrides[0].0.as_str(), "--k");
2881 }
2882
2883 #[test]
2884 fn space_before_paren_is_not_a_call() {
2885 let steps = parse_script("ECHO (1 + 2)\n", lower_command).expect("parses");
2888 assert_eq!(steps.len(), 1);
2889 assert!(
2890 !matches!(steps[0].kind, crate::ast::StepKind::Call { .. }),
2891 "space before paren must not route to a call: {:?}",
2892 steps[0].kind
2893 );
2894 }
2895
2896 #[test]
2897 fn unknown_type_tag_parses_as_custom() {
2898 let steps = parse_script("LET $x: FOO = 1\n", lower_command).expect("custom tag parses");
2902 let StepKind::Assign { decl_type, .. } = &steps[0].kind else {
2903 panic!("expected Assign, got {:?}", steps[0].kind);
2904 };
2905 assert_eq!(decl_type, "FOO");
2906 }
2907
2908 #[test]
2909 fn bare_for_without_types_is_rejected() {
2910 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
2911 assert!(err.contains("FOR requires explicit types"), "{err}");
2912 }
2913
2914 #[test]
2915 fn mutate_statement_parses_without_keyword() {
2916 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
2917 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
2918 }
2919
2920 #[test]
2921 fn set_keyword_is_rejected_with_mutation_hint() {
2922 let err = parse_err("SET $y = 2\n");
2923 assert!(err.contains("not a keyword"), "{err}");
2924 assert!(err.contains("$var = <expr>"), "{err}");
2925 }
2926
2927 #[test]
2928 fn structural_fallthrough_commits_per_keyword() {
2929 for (script, cmd) in [
2930 ("CANCEL foo\n", "CANCEL"),
2931 ("TIMEOUT foo\n", "TIMEOUT"),
2932 ("FOR foo\n", "FOR"),
2933 ("IF foo\n", "IF"),
2934 ("LET foo\n", "LET"),
2935 ("ASYNC\n", "ASYNC"),
2939 ("ELSE foo\n", "ELSE"),
2940 ] {
2941 let err = parse_err(script);
2942 assert!(
2943 err.contains(&format!("invalid syntax for command {cmd}")),
2944 "{cmd}: {err}"
2945 );
2946 assert!(!err.contains("unknown command"), "{cmd}: {err}");
2947 }
2948 }
2949
2950 #[test]
2951 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
2952 let err = parse_err("SLEEP 1s 2s\n");
2953 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
2954 assert!(!err.contains("unknown command"), "{err}");
2955 }
2956
2957 #[test]
2958 fn list_append_lowers_variable_and_item() {
2959 let steps = parse_script("LIST_APPEND $items \"hi\"\n", lower_command).expect("parses");
2960 let StepKind::ListAppend { list, item } = &steps[0].kind else {
2961 panic!("expected ListAppend, got {:?}", steps[0].kind);
2962 };
2963 assert_eq!(list, "items");
2964 assert!(matches!(item, Arg::String(s, _) if s == "hi"));
2965 assert_eq!(steps[0].kind.to_string(), "LIST_APPEND $items \"hi\"");
2966 }
2967
2968 #[test]
2969 fn list_append_rejects_wrong_arity() {
2970 for script in [
2971 "LIST_APPEND\n",
2972 "LIST_APPEND $items\n",
2973 "LIST_APPEND $items \"a\" \"b\"\n",
2974 ] {
2975 let err = parse_err(script);
2976 assert!(
2977 err.contains("invalid syntax for command LIST_APPEND"),
2978 "{script}: {err}"
2979 );
2980 assert!(!err.contains("unknown command"), "{script}: {err}");
2981 }
2982 }
2983
2984 #[test]
2985 fn list_append_rejects_non_variable_target() {
2986 let err = parse_err("LIST_APPEND items \"a\"\n");
2987 assert!(
2988 err.contains("invalid syntax for command LIST_APPEND"),
2989 "{err}"
2990 );
2991 assert!(!err.contains("unknown command"), "{err}");
2992 }
2993
2994 #[test]
2995 fn read_line_rejects_bare_word_target() {
2996 let err = parse_err("READ_LINE reply\n");
3000 assert!(err.contains("READ_LINE requires a $variable"), "{err}");
3001 assert!(!err.contains("unknown command"), "{err}");
3002 }
3003
3004 #[test]
3005 fn genuinely_unknown_command_keeps_bare_message() {
3006 let err = parse_err("FROBNICATE hi\n");
3007 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
3008 assert!(!err.contains("did you mean"), "{err}");
3009 }
3010
3011 #[test]
3012 fn lowercase_command_suggests_uppercase() {
3013 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
3017 .expect_err("must fail")
3018 .to_string();
3019 assert!(err.contains("unknown command: echo"), "{err}");
3020 assert!(err.contains("did you mean `ECHO`"), "{err}");
3021 }
3022
3023 #[test]
3024 fn func_def_requires_typed_uppercase_name() {
3025 let steps = parse_script(
3026 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
3027 lower_command,
3028 )
3029 .expect("func def parses");
3030 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
3031 panic!("expected FuncDef, got {:?}", steps[0].kind);
3032 };
3033 assert_eq!(name, "GREET");
3034 assert_eq!(
3035 params,
3036 &vec![("name".to_string(), "STRING".to_string())],
3037 "{params:?}"
3038 );
3039 assert!(matches!(body[0].kind, StepKind::Return { .. }));
3040 }
3041
3042 #[test]
3043 fn lowercase_func_name_is_rejected() {
3044 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
3045 assert!(err.contains("FUNC"), "{err}");
3046 }
3047
3048 #[test]
3049 fn call_and_while_lower_correctly() {
3050 let steps = parse_script(
3051 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nGREET(\"ada\")\n",
3052 lower_command,
3053 )
3054 .expect("call parses");
3055 assert!(
3056 matches!(&steps[1].kind, StepKind::Call { name, .. } if name == "SCRIPT::GREET"),
3057 "{:?}",
3058 steps[1].kind
3059 );
3060 let steps = parse_script(
3061 "FUNC GREET($a: STRING, $b: STRING) {\n RETURN $a\n}\nGREET(\"ada\", \"bex\")\n",
3062 lower_command,
3063 )
3064 .expect("spaced call parses");
3065 assert!(
3066 matches!(&steps[1].kind, StepKind::Call { name, args } if name == "SCRIPT::GREET" && args.len() == 2),
3067 "{:?}",
3068 steps[1].kind
3069 );
3070 let steps = parse_script(
3071 indoc! {r#"
3072 WHILE !$done {
3073 BREAK
3074 }
3075 "#},
3076 lower_command,
3077 )
3078 .expect("while parses");
3079 let StepKind::While { body, .. } = &steps[0].kind else {
3080 panic!("expected While, got {:?}", steps[0].kind);
3081 };
3082 assert!(matches!(body[0].kind, StepKind::Break));
3083 }
3084
3085 #[test]
3086 fn let_capture_call_and_async_call_lower() {
3087 let steps = parse_script(
3090 indoc! {r#"
3091 FUNC GREET($name: STRING) {
3092 RETURN $name
3093 }
3094 LET $r: STRING = GREET("ada")
3095 "#},
3096 lower_command,
3097 )
3098 .expect("capture call parses");
3099 let StepKind::Assign { var, expr, .. } = &steps[1].kind else {
3100 panic!("expected Assign, got {:?}", steps[1].kind);
3101 };
3102 assert_eq!(var, "r");
3103 assert!(
3104 matches!(expr, Expr::Call { name, .. } if name == "SCRIPT::GREET"),
3105 "{expr:?}"
3106 );
3107 let steps = parse_script(
3108 "FUNC GREET($name: STRING) {\n RETURN $name\n}\nLET $t: HANDLE = ASYNC GREET(\"a\")\n",
3109 lower_command,
3110 )
3111 .expect("async call parses");
3112 assert!(
3113 matches!(&steps[1].kind, StepKind::AssignAsync { .. }),
3114 "{:?}",
3115 steps[1].kind
3116 );
3117 }
3118
3119 #[test]
3120 fn multiline_call_args_span_lines() {
3121 let steps = parse_script(
3125 "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",
3126 lower_command,
3127 )
3128 .expect("multiline call parses");
3129 let StepKind::Assign { expr, .. } = &steps[1].kind else {
3130 panic!("expected Assign, got {:?}", steps[1].kind);
3131 };
3132 let Expr::Call { name, args } = expr else {
3133 panic!("expected Call expr, got {expr:?}");
3134 };
3135 assert_eq!(name, "SCRIPT::SERVE");
3136 assert_eq!(args.len(), 4);
3137 assert!(matches!(&args[3], Expr::Map(entries) if entries.len() == 1));
3138 let rendered = steps[1].to_string();
3140 assert!(!rendered.contains('\n'), "{rendered}");
3141 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";
3142 let again = parse_script(script, lower_command).expect("reparse ok");
3143 assert_eq!(again, steps);
3144 }
3145
3146 #[test]
3147 fn multiline_bare_call_and_list_span_lines() {
3148 let steps = parse_script(
3149 indoc! {r#"
3150 FUNC GREET($a: STRING) {
3151 RETURN $a
3152 }
3153 GREET(
3154 "ada"
3155 )
3156 "#},
3157 lower_command,
3158 )
3159 .expect("multiline bare call parses");
3160 let StepKind::Call { name, args } = &steps[1].kind else {
3161 panic!("expected Call, got {:?}", steps[1].kind);
3162 };
3163 assert_eq!(name, "SCRIPT::GREET");
3164 assert_eq!(args.len(), 1);
3165 let steps = parse_script(
3166 indoc! {r#"
3167 LET $l: LIST = [
3168 "a",
3169 "b"
3170 ]
3171 "#},
3172 lower_command,
3173 )
3174 .expect("multiline list parses");
3175 let StepKind::Assign { expr, .. } = &steps[0].kind else {
3176 panic!("expected Assign, got {:?}", steps[0].kind);
3177 };
3178 assert!(
3179 matches!(expr, Expr::List(items) if items.len() == 2),
3180 "{expr:?}"
3181 );
3182 }
3183
3184 #[test]
3185 fn parse_duration_units() {
3186 use std::time::Duration;
3187 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
3188 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
3189 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
3190 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
3191 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
3192 }
3193
3194 #[test]
3195 fn parse_duration_rejects_garbage() {
3196 assert!(parse_duration("").is_err());
3197 assert!(parse_duration("banana").is_err());
3198 assert!(parse_duration("10x").is_err());
3199 assert!(parse_duration("0s").is_err());
3200 assert!(parse_duration("0").is_err());
3201 assert!(parse_duration("-5s").is_err());
3202 }
3203
3204 #[test]
3205 fn format_duration_round_trips() {
3206 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
3207 let parsed = parse_duration(text).unwrap();
3208 let rendered = format_duration(&parsed);
3209 assert_eq!(
3210 parse_duration(&rendered).unwrap(),
3211 parsed,
3212 "round-trip failed for {text}"
3213 );
3214 }
3215 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
3216 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
3217 }
3218
3219 #[test]
3220 fn structural_metadata_covers_all_structural_kinds() {
3221 use crate::ast::Value;
3222
3223 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
3227 match kind {
3228 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
3229 StepKind::For { .. } => Some("FOR"),
3230 StepKind::If { .. } => Some("IF"),
3231 StepKind::Assign { .. } => Some("LET"),
3232 StepKind::Set { .. } => Some("MUTATION"),
3233 StepKind::AssignCapture { .. } => Some("LET"),
3234 StepKind::AwaitCapture { .. } => Some("AWAIT"),
3235 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
3236 StepKind::Await { .. } => Some("AWAIT"),
3237 StepKind::Cancel { .. } => Some("CANCEL"),
3238 StepKind::Timeout { .. } => Some("TIMEOUT"),
3239 StepKind::FuncDef { .. } => Some("FUNC"),
3240 StepKind::Call { .. } => None,
3243 StepKind::Return { .. } => Some("RETURN"),
3244 StepKind::While { .. } => Some("WHILE"),
3245 StepKind::Break => Some("BREAK"),
3246 StepKind::Continue => Some("CONTINUE"),
3247 StepKind::RunExec { .. } => None,
3248 StepKind::Workdir(_)
3249 | StepKind::Workspace(_)
3250 | StepKind::Env { .. }
3251 | StepKind::InheritEnv { .. }
3252 | StepKind::Run(_)
3253 | StepKind::Echo(_)
3254 | StepKind::Copy { .. }
3255 | StepKind::Symlink { .. }
3256 | StepKind::Mkdir(_)
3257 | StepKind::Ls(_)
3258 | StepKind::Cwd
3259 | StepKind::Read(_)
3260 | StepKind::ReadLine { .. }
3261 | StepKind::Write { .. }
3262 | StepKind::Append { .. }
3263 | StepKind::Expand { .. }
3264 | StepKind::AssertEq { .. }
3265 | StepKind::AssertContains { .. }
3266 | StepKind::CopyGit { .. }
3267 | StepKind::HashSha256 { .. }
3268 | StepKind::Exit(_)
3269 | StepKind::Sleep { .. }
3270 | StepKind::ListAppend { .. } => None,
3271 }
3272 }
3273
3274 let dummies: Vec<StepKind> = vec![
3277 StepKind::WithIo {
3278 bindings: Vec::new(),
3279 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
3280 "x".to_string(),
3281 false,
3282 ))),
3283 },
3284 StepKind::For {
3285 key_var: None,
3286 key_type: None,
3287 var: "i".to_string(),
3288 var_type: "STRING".to_string(),
3289 in_expr: Expr::Literal(Value::bool(true)),
3290 body: Vec::new(),
3291 },
3292 StepKind::If {
3293 cond: Box::new(Expr::Literal(Value::bool(true))),
3294 then_body: Vec::new(),
3295 else_ifs: Vec::new(),
3296 else_body: None,
3297 },
3298 StepKind::Assign {
3299 var: "v".to_string(),
3300 decl_type: "BOOL".to_string(),
3301 expr: Expr::Literal(Value::bool(true)),
3302 },
3303 StepKind::Set {
3304 var: "v".to_string(),
3305 expr: Expr::Literal(Value::bool(true)),
3306 },
3307 StepKind::AssignCapture {
3308 var: "v".to_string(),
3309 decl_type: "STRING".to_string(),
3310 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
3311 "x".to_string(),
3312 false,
3313 ))),
3314 },
3315 StepKind::AwaitCapture {
3316 out_var: "o".to_string(),
3317 out_type: "STRING".to_string(),
3318 task_var: "t".to_string(),
3319 },
3320 StepKind::AsyncBlock { body: Vec::new() },
3321 StepKind::AssignAsync {
3322 var: "t".to_string(),
3323 decl_type: "HANDLE".to_string(),
3324 body: Vec::new(),
3325 },
3326 StepKind::Await {
3327 var: "t".to_string(),
3328 },
3329 StepKind::Cancel {
3330 var: "t".to_string(),
3331 },
3332 StepKind::Timeout {
3333 duration: Arg::String("1s".to_string(), false),
3334 body: Vec::new(),
3335 },
3336 StepKind::FuncDef {
3337 name: "F".to_string(),
3338 params: Vec::new(),
3339 body: Vec::new(),
3340 },
3341 StepKind::Call {
3342 name: "F".to_string(),
3343 args: Vec::new(),
3344 },
3345 StepKind::Return {
3346 expr: Box::new(Expr::Literal(Value::bool(true))),
3347 },
3348 StepKind::While {
3349 cond: Box::new(Expr::Literal(Value::bool(true))),
3350 body: Vec::new(),
3351 },
3352 StepKind::Break,
3353 StepKind::Continue,
3354 ];
3355 let registry = all_structural_metadata();
3356 for kind in &dummies {
3357 let Some(name) = metadata_name(kind) else {
3359 continue;
3360 };
3361 assert!(
3362 registry.iter().any(|meta| meta.name == name),
3363 "no structural metadata entry for {}",
3364 name
3365 );
3366 }
3367 }
3368}