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