1use std::fmt;
16
17use crate::ast::{
18 Arg, ArgPart, Expr, IoBinding, IoStream, PipeTarget, Step, TypeKind, Value, WorkspaceTarget,
19};
20use crate::command::{
21 ArgSpec, ArgType, CommandMeta, Example, FlagSpec, FlagValueType, IoDirection, Stream,
22 split_assignment,
23};
24use crate::error::{ParseError, ParseResult, SpanContext};
25use indoc::indoc;
26
27fn join_value(args: Vec<Arg>, cmd_name: &str) -> ParseResult<Arg> {
38 if args.is_empty() {
39 return Err(ParseError::validation(
40 cmd_name,
41 format!("{cmd_name} requires at least one argument"),
42 &SpanContext::line_only(0),
43 ));
44 }
45 if args.len() == 1 {
46 return Ok(args.into_iter().next().unwrap());
47 }
48 if args.iter().all(|a| matches!(a, Arg::String(..))) {
49 return Ok(Arg::String(
50 args.iter()
51 .map(|a| a.as_str())
52 .collect::<Vec<_>>()
53 .join(" "),
54 false,
55 ));
56 }
57 let mut parts = Vec::new();
58 for (index, arg) in args.into_iter().enumerate() {
59 if index > 0 {
60 parts.push(ArgPart::Text(" ".to_string(), false));
61 }
62 match arg {
63 Arg::String(text, quoted) => parts.push(ArgPart::Text(text, quoted)),
64 Arg::Expr(expr) => parts.push(ArgPart::Expr(expr)),
65 Arg::Parts(inner) => parts.extend(inner),
66 }
67 }
68 Ok(Arg::Parts(parts))
69}
70
71pub fn lower_env_assignment(args: Vec<Arg>) -> ParseResult<StepKind> {
75 let arg = args.into_iter().next().ok_or_else(|| {
76 ParseError::validation(
77 "ENV",
78 "ENV requires KEY=value".to_string(),
79 &SpanContext::line_only(0),
80 )
81 })?;
82 let Some((key, value)) = split_assignment(arg.as_str())
83 .map_err(|e| ParseError::validation("ENV", e.to_string(), &SpanContext::line_only(0)))?
84 else {
85 return Err(ParseError::validation(
86 "ENV",
87 "ENV requires KEY=value format".to_string(),
88 &SpanContext::line_only(0),
89 ));
90 };
91 Ok(StepKind::Env { key, value })
92}
93
94pub(crate) fn canonical_assignment_arg(key: &str, value: &Arg) -> Arg {
99 Arg::String(format!("{key}={}", value.render()), false)
100}
101
102fn fmt_assert_target(target: &AssertTarget) -> String {
105 match target {
106 AssertTarget::Value(arg) => fmt_value(arg, quote_msg),
107 _ => target.render(),
108 }
109}
110
111fn fmt_value(arg: &Arg, quote: fn(&str) -> String) -> String {
116 match arg {
117 Arg::Expr(_) => arg.render(),
118 Arg::String(text, _) => quote(text),
119 Arg::Parts(_) => {
120 let rendered = arg.render();
121 if rendered.contains(';')
122 || rendered.contains('}')
123 || rendered.contains('\n')
124 || rendered.contains('\r')
125 {
126 quote(&rendered)
127 } else {
128 rendered
129 }
130 }
131 }
132}
133
134fn quote_arg(s: &str) -> String {
135 let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
136 && !s.starts_with(|c: char| c.is_ascii_digit() || c == '-' || c == '/' || c == '.')
137 && !crate::Command::is_statement_keyword(s);
138 if is_safe && !s.is_empty() {
139 s.to_string()
140 } else {
141 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
142 }
143}
144
145fn quote_msg(s: &str) -> String {
146 let safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
147 && !s.starts_with(|c: char| c.is_ascii_digit())
148 && !crate::Command::is_statement_keyword(s);
149 if safe && !s.is_empty() {
150 s.to_string()
151 } else {
152 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
153 }
154}
155
156fn quote_run(s: &str) -> String {
157 if s.is_empty() || s.chars().any(|c| c == ';' || c == '\n') || s.contains("//") {
158 return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
159 }
160 s.split(' ')
161 .map(|w| {
162 if w.starts_with(|c: char| c.is_ascii_digit())
163 || w.starts_with(['/', '.', '-', ':', '='])
164 {
165 format!("\"{}\"", w.replace('\\', "\\\\").replace('"', "\\\""))
166 } else {
167 w.to_string()
168 }
169 })
170 .collect::<Vec<_>>()
171 .join(" ")
172}
173
174fn fmt_exec_arg(arg: &Arg) -> String {
180 match arg {
181 Arg::String(text, _) => {
182 format!("\"{}\"", text.replace('\\', "\\\\").replace('"', "\\\""))
183 }
184 Arg::Expr(_) => arg.render(),
185 Arg::Parts(_) => {
186 let rendered = arg.render();
187 if rendered.contains(';')
188 || rendered.contains('}')
189 || rendered.contains('\n')
190 || rendered.contains('\r')
191 {
192 format!(
193 "\"{}\"",
194 rendered.replace('\\', "\\\\").replace('"', "\\\"")
195 )
196 } else {
197 rendered
198 }
199 }
200 }
201}
202
203fn fmt_raw_arg(arg: &Arg) -> String {
207 match arg {
208 Arg::String(s, true) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
209 _ => arg.render(),
210 }
211}
212
213fn fmt_io(b: &IoBinding) -> String {
214 let s = match b.stream {
215 IoStream::Stdin => "stdin",
216 IoStream::Stdout => "stdout",
217 IoStream::Stderr => "stderr",
218 };
219 match &b.pipe {
220 Some(PipeTarget::Name(p)) => format!("{}=pipe:{}", s, p),
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), or `LET $out: STRING = AWAIT $t`; 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 "CALL" => Some(format!(
352 "CALL invokes a function, e.g. `CALL GREET(\"ada\")` or `LET $r: STRING = CALL GREET(\"ada\")`; got {got}."
353 )),
354 "RETURN" => Some(format!(
355 "RETURN ends a function with a value, e.g. `RETURN $x`; got {got}."
356 )),
357 "WHILE" => Some(format!(
358 "WHILE needs a Bool condition and a block, e.g. `WHILE !$done {{ ... }}`; got {got}."
359 )),
360 "BREAK" => Some(
361 "`BREAK` exits the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
362 ),
363 "CONTINUE" => Some(
364 "`CONTINUE` skips to the next iteration of the innermost enclosing FOR/WHILE loop; it must appear inside a loop.".to_string(),
365 ),
366 "INHERIT_ENV" => Some(format!(
367 "INHERIT_ENV takes a key list, e.g. `INHERIT_ENV [HOME PATH]`; got {got}."
368 )),
369 _ => None,
370 }
371}
372
373fn with_io_hint(got: &str, received: &str) -> String {
376 const SYNTAX: &str =
377 "WITH_IO needs `WITH_IO [bindings] <command>` or `WITH_IO [bindings] { <commands> }`";
378 const BINDINGS: &str = "bindings are `stdin`, `stdout`, `stderr`, `<stream>=pipe:<name>`, or `<stream>=$var` with a PIPE-typed variable (e.g. `[stdout=pipe:log]`, `[stdin=$p]`)";
379 if let Some(after_open) = received.strip_prefix('[') {
380 match after_open.split_once(']') {
381 None => {
382 return format!("{SYNTAX}: missing closing `]` in the binding list; got {got}.");
383 }
384 Some((bindings, _)) => {
385 for part in bindings.split(',') {
386 let part = part.trim();
387 if part.is_empty() {
388 continue;
389 }
390 let (stream, binding) = match part.split_once('=') {
391 Some((stream, binding)) => (stream.trim(), Some(binding.trim())),
392 None => (part, None),
393 };
394 if !matches!(stream, "stdin" | "stdout" | "stderr") {
395 return format!(
396 "{SYNTAX}: invalid stream `{stream}`; expected `stdin`, `stdout`, or `stderr`; got {got}."
397 );
398 }
399 let valid = match binding {
400 None => true,
401 Some(value) => value
402 .strip_prefix("pipe:")
403 .map(|pipe| !pipe.trim().is_empty())
404 .unwrap_or(false),
405 };
406 if !valid {
407 return format!(
408 "{SYNTAX}: invalid binding `{part}`; {BINDINGS}; got {got}."
409 );
410 }
411 }
412 }
413 }
414 }
415 format!("{SYNTAX}; got {got}. {BINDINGS}.")
416}
417
418fn case_hint(name: &str) -> Option<String> {
420 let upper = name.to_ascii_uppercase();
421 if upper != name
422 && all_metadata()
423 .iter()
424 .any(|meta| meta.name == upper.as_str())
425 {
426 return Some(format!("did you mean `{upper}`? commands are uppercase."));
427 }
428 None
429}
430
431macro_rules! declare_commands {
432 (
433 structural [
434 $( $sname:ident $( { $( $sfname:ident : $sftype:ty ),* $(,)? } )? ),* $(,)?
435 ]
436
437 $(
438 $cmd_ident:ident => [
439 name: $name:expr,
440 variant: $vname:ident $( { $( $vfname:ident : $vftype:ty ),* $(,)? } )? $( ( $( $ttuple:ty ),* $(,)? ) )?,
441 syntax: $syntax:expr,
442 summary: $summary:expr,
443 description: $desc:expr,
444 args: $args:expr,
445 flags: $flags:expr,
446 default_output: $out:expr,
447 examples: $examples:expr,
448 lower: $lower:expr,
449 ]
450 ),* $(,)?
451 ) => {
452 #[derive(Debug, Clone, PartialEq)]
453 pub enum StepKind {
454 $( $vname $( { $( $vfname : $vftype ),* } )? $( ( $( $ttuple ),* ) )?, )*
455 $( $sname $( { $( $sfname : $sftype ),* } )?, )*
456 }
457
458 pub fn lower_command(name: &str, raw_args: Vec<Arg>) -> ParseResult<StepKind> {
459 match name {
460 $(
461 s if s == $name => {
462 let meta = CommandMeta {
463 name: $name, syntax: $syntax, summary: $summary,
464 description: $desc, args: $args, flags: $flags,
465 default_output: $out, examples: $examples,
466 };
467 let (flags, positional) = crate::strip_flags(raw_args, &meta)?;
468 crate::command::validate_positionals_against_meta(
469 s,
470 &meta.args,
471 &positional,
472 )?;
473 let lower_fn: fn(Vec<(String, Arg)>, Vec<Arg>) -> ParseResult<StepKind> = $lower;
474 lower_fn(flags, positional)
475 }
476 )*
477 _ => {
478 Err(classify(name, &raw_args))
479 }
480 }
481 }
482
483 pub fn all_metadata() -> Vec<CommandMeta> {
484 let mut out = vec![
485 $( CommandMeta {
486 name: $name, syntax: $syntax, summary: $summary,
487 description: $desc, args: $args, flags: $flags,
488 default_output: $out, examples: $examples,
489 }, )*
490 ];
491 out.extend(all_structural_metadata());
495 out
496 }
497 };
498}
499
500#[derive(Debug, Clone, PartialEq)]
507pub enum AssertTarget {
508 Value(Arg),
509 Stdout,
510 Stderr,
511 Pipe(String),
512}
513
514impl AssertTarget {
515 pub fn render(&self) -> String {
516 match self {
517 AssertTarget::Value(arg) => arg.render(),
518 AssertTarget::Stdout => "stdout".to_string(),
519 AssertTarget::Stderr => "stderr".to_string(),
520 AssertTarget::Pipe(name) => format!("pipe:{name}"),
521 }
522 }
523}
524
525fn lower_assert_target(arg: Arg, cmd_name: &str) -> ParseResult<AssertTarget> {
533 match arg {
534 Arg::Expr(_) => Ok(AssertTarget::Value(arg)),
535 Arg::String(text, quoted) if !quoted => match text.as_str() {
536 "stdout" => Ok(AssertTarget::Stdout),
537 "stderr" => Ok(AssertTarget::Stderr),
538 _ => match text.strip_prefix("pipe:") {
539 Some(name) if !name.is_empty() => Ok(AssertTarget::Pipe(name.to_string())),
540 Some(_) => Err(ParseError::validation(
541 cmd_name,
542 format!("{cmd_name} pipe target needs a name, got {text:?}"),
543 &SpanContext::line_only(0),
544 )),
545 None => Ok(AssertTarget::Value(lower_assert_operand(Arg::String(
546 text, false,
547 )))),
548 },
549 },
550 other => Ok(AssertTarget::Value(lower_assert_operand(other))),
551 }
552}
553
554fn lower_assert_operand(arg: Arg) -> Arg {
563 match arg {
564 Arg::String(text, false) => {
565 if let Ok(i) = text.parse::<i64>() {
566 Arg::Expr(Expr::Literal(Value::Int(i)))
567 } else if text.contains('.') && text.parse::<f64>().is_ok() {
568 Arg::Expr(Expr::Literal(Value::Float(
569 text.parse::<f64>().unwrap_or(f64::NAN),
570 )))
571 } else if text == "true" {
572 Arg::Expr(Expr::Literal(Value::Bool(true)))
573 } else if text == "false" {
574 Arg::Expr(Expr::Literal(Value::Bool(false)))
575 } else {
576 Arg::String(text, false)
577 }
578 }
579 other => other,
580 }
581}
582
583declare_commands! {
584 structural [
585 WithIo { bindings: Vec<IoBinding>, cmd: Box<StepKind> },
586 WithIoBlock { bindings: Vec<IoBinding> },
587 For { key_var: Option<String>, key_type: Option<TypeKind>, var: String, var_type: TypeKind, in_expr: Expr, body: Vec<Step> },
588 If { cond: Box<Expr>, then_body: Vec<Step>, else_ifs: Vec<(Box<Expr>, Vec<Step>)>, else_body: Option<Vec<Step>> },
589 Assign { var: String, decl_type: TypeKind, expr: Expr },
590 Set { var: String, expr: Expr },
591 AssignCapture { var: String, decl_type: TypeKind, cmd: Box<StepKind> },
592 AwaitCapture { out_var: String, out_type: TypeKind, task_var: String },
593 AsyncBlock { body: Vec<Step> },
594 AssignAsync { var: String, decl_type: TypeKind, body: Vec<Step> },
595 Await { var: String },
596 Cancel { var: String },
597 Timeout { duration: Arg, body: Vec<Step> },
598 RunExec { argv: Vec<Arg> },
599 FuncDef { name: String, params: Vec<(String, TypeKind)>, body: Vec<Step> },
600 Call { name: String, args: Vec<Expr> },
601 Return { expr: Box<Expr> },
602 While { cond: Box<Expr>, body: Vec<Step> },
603 Break,
604 Continue,
605 ]
606
607 Workdir => [
608 name: "WORKDIR",
609 variant: Workdir(Arg),
610 syntax: "WORKDIR <path>",
611 summary: "Change the working directory.",
612 description: indoc! {r#"
613 Sets the current working directory.
614
615 Relative paths resolve against the current directory; `/` resets to
616 the workspace root. Paths cannot escape the workspace.
617 "#},
618 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Directory to change to", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
619 flags: &[],
620 default_output: None,
621 examples: &[ Example { name: "change working directory", fence_meta: None, code: indoc! {r#"
622 WORKDIR project/src
623 WRITE generated.txt generated-under-workdir
624 LET $body: STRING = READ generated.txt
625 ASSERT_EQ $body "generated-under-workdir"
626 "#} } ],
627 lower: |_flags, args| {
628 let path = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKDIR", "WORKDIR requires a path".to_string(), &SpanContext::line_only(0)))?;
629 Ok(StepKind::Workdir(path))
630 },
631 ],
632
633 Workspace => [
634 name: "WORKSPACE",
635 variant: Workspace(WorkspaceTarget),
636 syntax: "WORKSPACE SNAPSHOT|LOCAL",
637 summary: "Switch workspace roots.",
638 description: "SNAPSHOT or LOCAL root.",
639 args: &[ ArgSpec { name: "target", arg_type: ArgType::OneOf(&["SNAPSHOT", "LOCAL"]), description: "Target root", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
640 flags: &[],
641 default_output: None,
642 examples: &[ Example { name: "switch roots", fence_meta: None, code: indoc! {r#"WORKSPACE LOCAL"#} } ],
643 lower: |_flags, args| {
644 let target = args.into_iter().next().ok_or_else(|| ParseError::validation("WORKSPACE", "WORKSPACE requires a target".to_string(), &SpanContext::line_only(0)))?;
645 match target.as_str() {
646 "SNAPSHOT" | "snapshot" => Ok(StepKind::Workspace(WorkspaceTarget::Snapshot)),
647 "LOCAL" | "local" => Ok(StepKind::Workspace(WorkspaceTarget::Local)),
648 other => Err(ParseError::validation("WORKSPACE", format!("unknown workspace target: {other}"), &SpanContext::line_only(0))),
649 }
650 },
651 ],
652
653 Env => [
654 name: "ENV",
655 variant: Env { key: String, value: Arg },
656 syntax: "ENV KEY=value",
657 summary: "Set an environment variable.",
658 description: indoc! {r#"
659 Inserts or updates an env var.
660
661 The value uses the unified string-value rules shared by every command:
662 `"..."` or `'...'` quotes keep exact bytes (spaces, tabs), a lone `$var`
663 evaluates that variable, `{{ ... }}` placeholders interpolate, unquoted
664 words join with single spaces, and the first `=` splits key from value
665 (`KEY=a=b` stores `a=b`).
666
667 A `$var` inside larger text stays literal — write `{{ $var }}` to
668 interpolate there.
669 "#},
670 args: &[ ArgSpec { name: "assignment", arg_type: ArgType::KeyValue, description: "KEY=value pair; the value resolves as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
671 flags: &[],
672 default_output: None,
673 examples: &[
674 Example { name: "set env", fence_meta: None, code: indoc! {r#"ENV APP_MODE=production"#} },
675 Example { name: "quoted value with spaces", fence_meta: None, code: indoc! {r#"
676 # quotes keep the space: SET_FORTH stores `outer scope`
677 ENV SET_FORTH="outer scope"
678 WRITE out.txt "{{ env:SET_FORTH }}"
679 LET $body: STRING = READ out.txt
680 ASSERT_EQ $body "outer scope"
681 "#} },
682 Example { name: "variable value", fence_meta: None, code: indoc! {r#"
683 # a lone $var evaluates, like ECHO $var
684 LET $who: STRING = "Alice"
685 ENV GREETING=$who
686 WRITE out.txt "{{ env:GREETING }}"
687 LET $body: STRING = READ out.txt
688 ASSERT_EQ $body "Alice"
689 "#} },
690 Example { name: "all value forms agree", fence_meta: None, code: indoc! {r#"
691 # a bare variable, a quoted literal, and a template all
692 # store plain strings through the same value rules
693 LET $x: STRING = "Ada"
694 ENV A=$x
695 ENV B="hello world"
696 ENV C="{{ $x }} concatenated"
697 WRITE check.txt "{{ env:A }}|{{ env:B }}|{{ env:C }}"
698 LET $body: STRING = READ check.txt
699 ASSERT_EQ $body "Ada|hello world|Ada concatenated"
700 "#} },
701 Example { name: "scoped env reverts", fence_meta: None, code: indoc! {r#"
702 # ENV inside a braced block reverts when the block exits
703 ENV MODE=production
704 [bool:true] {
705 ENV MODE=staging
706 WRITE inner.txt "{{ env:MODE }}"
707 }
708 WRITE outer.txt "{{ env:MODE }}"
709 LET $inner_body: STRING = READ inner.txt
710 LET $outer_body: STRING = READ outer.txt
711 ASSERT_EQ $inner_body "staging"
712 ASSERT_EQ $outer_body "production"
713 "#} },
714 ],
715 lower: |_flags, args| lower_env_assignment(args),
716 ],
717
718 InheritEnv => [
719 name: "INHERIT_ENV",
720 variant: InheritEnv { keys: Vec<String> },
721 syntax: "INHERIT_ENV <key>...",
722 summary: "Inherit env vars from host.",
723 description: indoc! {r#"
724 Declares which host environment variables to inherit into the script.
725
726 Must appear before any other commands and at most once. Without this
727 directive, the script starts with an empty environment.
728 "#},
729 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 } ],
730 flags: &[],
731 default_output: None,
732 examples: &[ Example { name: "inherit env", fence_meta: None, code: indoc! {r#"INHERIT_ENV [PATH, HOME]"#} } ],
733 lower: |_flags, args| {
734 let keys = args.into_iter().map(|a| a.as_str().to_string()).collect();
735 Ok(StepKind::InheritEnv { keys })
736 },
737 ],
738
739 Echo => [
740 name: "ECHO",
741 variant: Echo(Arg),
742 syntax: "ECHO <message>",
743 summary: "Print to stdout.",
744 description: "Outputs message to stdout.",
745 args: &[ ArgSpec { name: "message", arg_type: ArgType::Rest(&ArgType::String), description: "Text", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
746 flags: &[],
747 default_output: Some(Stream::Stdout),
748 examples: &[
749 Example { name: "echo", fence_meta: None, code: indoc! {r#"ECHO build-complete"#} },
750 Example { name: "variables", fence_meta: None, code: indoc! {r#"
751 # a lone $x evaluates; {{ }} interpolates inside text
752 LET $x: STRING = "World"
753 ECHO {{ $x }}
754 ECHO $x
755 ASSERT_CONTAINS stdout "World"
756 "#} },
757 ],
758 lower: |_flags, args| Ok(StepKind::Echo(join_value(args, "ECHO")?)),
759 ],
760
761 Run => [
762 name: "RUN",
763 variant: Run(Arg),
764 syntax: "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
765 summary: "Execute shell command or direct executable.",
766 description: indoc! {r#"
767 Shell form (`RUN <command...>`) runs the joined command string in the
768 system shell (`$SHELL -c` / `COMSPEC /C`).
769
770 Exec form (`RUN ["exe", "arg", ...]`) spawns the executable directly
771 with no shell, so there is no shell expansion, globbing, redirection,
772 or pipes; use it for portable commands.
773
774 Guards and wrappers (`ASYNC`, `TIMEOUT`, `WITH_IO`, `LET`) apply to
775 both forms.
776 "#},
777 args: &[ ArgSpec { name: "command", arg_type: ArgType::Rest(&ArgType::String), description: "Command", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
778 flags: &[],
779 default_output: None,
780 examples: &[ Example { name: "run", fence_meta: None, code: indoc! {r#"RUN echo hello"#} }, Example { name: "run exec form", fence_meta: None, code: indoc! {r#"RUN ["cargo", "--version"]"#} } ],
781 lower: |_flags, args| match args.as_slice() {
782 [Arg::Expr(Expr::List(elems))] if elems.is_empty() => {
783 Err(ParseError::validation("RUN", "RUN requires at least one argument".to_string(), &SpanContext::line_only(0)))
784 }
785 [Arg::Expr(Expr::List(elems))] => Ok(StepKind::RunExec {
786 argv: elems.iter().cloned().map(Arg::Expr).collect(),
787 }),
788 _ => Ok(StepKind::Run(join_value(args, "RUN")?)),
789 },
790 ],
791
792 Copy => [
793 name: "COPY",
794 variant: Copy { from_current_workspace: bool, from: Arg, to: Arg },
795 syntax: "COPY [--from-current-workspace] <from> <to>",
796 summary: "Copy file into workspace.",
797 description: "Copies from host.",
798 args: &[
799 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Source", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
800 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Dest", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
801 ],
802 flags: &[ FlagSpec { name: "from_current_workspace", long: "--from-current-workspace", value_type: FlagValueType::Flag, required: false, description: "Copy from workspace instead of build context" } ],
803 default_output: None,
804 examples: &[ Example { name: "copy", fence_meta: Some("roots:unified"), code: indoc! {r#"
805 WRITE src.txt content
806 COPY src.txt dst.txt
807 LET $body: STRING = READ dst.txt
808 ASSERT_EQ $body "content"
809 "#} }, Example { name: "copy from workspace", fence_meta: Some("roots:unified"), code: indoc! {r#"
810 WRITE ws-src.txt ws-content
811 COPY --from-current-workspace ws-src.txt ws-copy.txt
812 LET $body: STRING = READ ws-copy.txt
813 ASSERT_EQ $body "ws-content"
814 "#} } ],
815 lower: |flags, args| {
816 let from_current_workspace = flags.iter().any(|(k, _)| k == "from_current_workspace");
817 let mut it = args.into_iter();
818 let from = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a source".to_string(), &SpanContext::line_only(0)))?;
819 let to = it.next().ok_or_else(|| ParseError::validation("COPY", "COPY requires a destination".to_string(), &SpanContext::line_only(0)))?;
820 Ok(StepKind::Copy { from_current_workspace, from, to })
821 },
822 ],
823
824 CopyGit => [
825 name: "COPY_GIT",
826 variant: CopyGit { rev: Arg, from: Arg, to: Arg, include_dirty: bool },
827 syntax: "COPY_GIT [--include-dirty] <rev> <src> <dst>",
828 summary: "Copy from git revision.",
829 description: "Checkout and copy.",
830 args: &[
831 ArgSpec { name: "rev", arg_type: ArgType::String, description: "Rev", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
832 ArgSpec { name: "src", arg_type: ArgType::Path, description: "Src", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
833 ArgSpec { name: "dst", arg_type: ArgType::Path, description: "Dst", io: IoDirection::Write, index: 2, required: true, fallback_stream: None },
834 ],
835 flags: &[ FlagSpec { name: "dirty", long: "--include-dirty", value_type: FlagValueType::Flag, required: false, description: "Include dirty" } ],
836 default_output: None,
837 examples: &[ Example { name: "git copy", fence_meta: Some("expect_error:\"COPY source missing\""), code: indoc! {r#"COPY_GIT HEAD src.txt dst.txt"#} } ],
838 lower: |flags, args| {
839 let include_dirty = flags.iter().any(|(k, _)| k == "dirty");
840 let mut it = args.into_iter();
841 let rev = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a revision".to_string(), &SpanContext::line_only(0)))?;
842 let from = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a source".to_string(), &SpanContext::line_only(0)))?;
843 let to = it.next().ok_or_else(|| ParseError::validation("COPY_GIT", "COPY_GIT requires a destination".to_string(), &SpanContext::line_only(0)))?;
844 Ok(StepKind::CopyGit { rev, from, to, include_dirty })
845 },
846 ],
847
848 Symlink => [
849 name: "SYMLINK",
850 variant: Symlink { from: Arg, to: Arg },
851 syntax: "SYMLINK <from> <to>",
852 summary: "Create symlink.",
853 description: "Creates symlink.",
854 args: &[
855 ArgSpec { name: "from", arg_type: ArgType::Path, description: "Target", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
856 ArgSpec { name: "to", arg_type: ArgType::Path, description: "Link", io: IoDirection::Write, index: 1, required: true, fallback_stream: None },
857 ],
858 flags: &[],
859 default_output: None,
860 examples: &[ Example { name: "symlink", fence_meta: Some("roots:unified"), code: indoc! {r#"
861 WRITE original.txt content
862 SYMLINK original.txt link.txt
863 LET $body: STRING = READ link.txt
864 ASSERT_EQ $body "content"
865 "#} } ],
866 lower: |_flags, args| {
867 let mut it = args.into_iter();
868 let from = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a source".to_string(), &SpanContext::line_only(0)))?;
869 let to = it.next().ok_or_else(|| ParseError::validation("SYMLINK", "SYMLINK requires a target".to_string(), &SpanContext::line_only(0)))?;
870 Ok(StepKind::Symlink { from, to })
871 },
872 ],
873
874 Mkdir => [
875 name: "MKDIR",
876 variant: Mkdir(Arg),
877 syntax: "MKDIR <path>",
878 summary: "Create directory.",
879 description: "Creates dir with parents.",
880 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir path", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
881 flags: &[],
882 default_output: None,
883 examples: &[ Example { name: "mkdir", fence_meta: None, code: indoc! {r#"MKDIR deeply/nested/tree"#} } ],
884 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)))?)),
885 ],
886
887 Ls => [
888 name: "LS",
889 variant: Ls(Option<Arg>),
890 syntax: "LS [<path>]",
891 summary: "List directory.",
892 description: "Lists entries.",
893 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "Dir", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
894 flags: &[],
895 default_output: Some(Stream::Stdout),
896 examples: &[ Example { name: "ls", fence_meta: None, code: indoc! {r#"
897 MKDIR inventory
898 WRITE inventory/a.txt a
899 LS inventory
900 "#} } ],
901 lower: |_flags, args| Ok(StepKind::Ls(args.into_iter().next())),
902 ],
903
904 Cwd => [
905 name: "CWD",
906 variant: Cwd,
907 syntax: "CWD",
908 summary: "Print working directory.",
909 description: "Outputs cwd.",
910 args: &[],
911 flags: &[],
912 default_output: Some(Stream::Stdout),
913 examples: &[ Example { name: "cwd", fence_meta: None, code: indoc! {r#"CWD"#} } ],
914 lower: |_flags, _args| Ok(StepKind::Cwd),
915 ],
916
917 Read => [
918 name: "READ",
919 variant: Read(Option<Arg>),
920 syntax: "READ [<path>]",
921 summary: "Read file to stdout.",
922 description: "Outputs file contents.",
923 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: false, fallback_stream: None } ],
924 flags: &[],
925 default_output: Some(Stream::Stdout),
926 examples: &[ Example { name: "read", fence_meta: None, code: indoc! {r#"
927 WRITE note.txt "hello"
928 READ note.txt
929 "#} } ],
930 lower: |_flags, args| Ok(StepKind::Read(args.into_iter().next())),
931 ],
932
933 ReadLine => [
934 name: "READ_LINE",
935 variant: ReadLine { var: String },
936 syntax: "READ_LINE $var",
937 summary: "Read one line from stdin into a variable.",
938 description: indoc! {r#"
939 Reads bytes until newline without waiting for EOF, leaving the pipe open.
940
941 Trailing newline is stripped (shell-read parity). On premature EOF
942 assigns accumulated bytes and returns.
943 "#},
944 args: &[ ArgSpec { name: "var", arg_type: ArgType::Var, description: "Target variable (`$name`); the line binds as STRING", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
945 flags: &[],
946 default_output: None,
947 examples: &[ Example { name: "read line", fence_meta: None, code: indoc! {r#"
948 WITH_IO [stdout=pipe:lines] ECHO "first"
949 WITH_IO [stdin=pipe:lines] READ_LINE $reply
950 "#} } ],
951 lower: |_flags, args| {
952 let arg = args.into_iter().next().ok_or_else(|| ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))?;
953 let var = match arg {
954 Arg::Expr(Expr::Var(name)) => name,
955 Arg::String(s, _) => s.trim_start_matches('$').to_string(),
956 other => return Err(ParseError::validation("READ_LINE", format!("READ_LINE requires a $variable, found {:?}", other), &SpanContext::line_only(0))),
957 };
958 if var.is_empty() {
959 return Err(ParseError::validation("READ_LINE", "READ_LINE requires a variable".to_string(), &SpanContext::line_only(0)))
960 }
961 Ok(StepKind::ReadLine { var })
962 },
963 ],
964
965 Write => [
966 name: "WRITE",
967 variant: Write { path: Arg, contents: Option<Arg> },
968 syntax: "WRITE <path> [<contents>]",
969 summary: "Write to file.",
970 description: "Writes contents.",
971 args: &[
972 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
973 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
974 ],
975 flags: &[],
976 default_output: None,
977 examples: &[ Example { name: "write", fence_meta: None, code: indoc! {r#"WRITE output.txt hello-world"#} } ],
978 lower: |_flags, args| {
979 let mut it = args.into_iter();
980 let path = it.next().ok_or_else(|| ParseError::validation("WRITE", "WRITE requires a path".to_string(), &SpanContext::line_only(0)))?;
981 let remaining: Vec<Arg> = it.collect();
982 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "WRITE")?) };
983 Ok(StepKind::Write { path, contents })
984 },
985 ],
986
987 Append => [
988 name: "APPEND",
989 variant: Append { path: Arg, contents: Option<Arg> },
990 syntax: "APPEND <path> [<contents>]",
991 summary: "Append to file.",
992 description: "Appends contents.",
993 args: &[
994 ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Write, index: 0, required: true, fallback_stream: None },
995 ArgSpec { name: "contents", arg_type: ArgType::Rest(&ArgType::String), description: "Content", io: IoDirection::Write, index: 1, required: false, fallback_stream: Some(Stream::Stdin) },
996 ],
997 flags: &[],
998 default_output: None,
999 examples: &[ Example { name: "append", fence_meta: None, code: indoc! {r#"
1000 WRITE log.txt line1
1001 APPEND log.txt line2
1002 LET $all: STRING = READ log.txt
1003 ASSERT_EQ $all "line1line2"
1004 "#} } ],
1005 lower: |_flags, args| {
1006 let mut it = args.into_iter();
1007 let path = it.next().ok_or_else(|| ParseError::validation("APPEND", "APPEND requires a path".to_string(), &SpanContext::line_only(0)))?;
1008 let remaining: Vec<Arg> = it.collect();
1009 let contents = if remaining.is_empty() { None } else { Some(join_value(remaining, "APPEND")?) };
1010 Ok(StepKind::Append { path, contents })
1011 },
1012 ],
1013
1014 Expand => [
1015 name: "EXPAND",
1016 variant: Expand { path: Option<Arg>, overrides: Vec<(String, Arg)> },
1017 syntax: "EXPAND [<path>] [<KEY=val> ...]",
1018 summary: "Expand a template file (or stdin) to stdout.",
1019 description: indoc! {r#"
1020 A template is any text file — or piped stdin when no path is given —
1021 containing `{{ ... }}` placeholders. EXPAND replaces each placeholder
1022 and prints the result to stdout.
1023
1024 Placeholders: `{{ NAME }}` reads a `KEY=val` override passed on this
1025 command; `{{ env:NAME }}` reads an override, falling back to the
1026 environment; `{{ $var }}` reads a script variable (dotted paths allowed).
1027 A missing key is an error, never a silent empty.
1028
1029 Substitution runs in a single pass. EXPAND is not recursive and does not
1030 expand nested placeholders: a value that itself contains `{{ ... }}` is
1031 inserted verbatim and never expanded again.
1032
1033 A bare `$var` argument is a template path; `KEY=val` arguments are
1034 overrides whose values follow the unified string-value rules (same as
1035 `ENV`: quotes keep exact bytes, a lone `$var` evaluates,
1036 `{{ ... }}` interpolates).
1037
1038 NOTE: `WRITE` interpolates `{{ ... }}` while writing, so escape it
1039 (`\{{ ... }}`) when writing a template file for a later `EXPAND`.
1040
1041 With no path, the template arrives on stdin through a pipe. When piping
1042 from a shell, single-quote the template (`echo '{{ $x }}'`): double
1043 quotes let the shell swallow `$x`, so oxdock receives an empty `{{ }}`
1044 placeholder and errors.
1045 "#},
1046 args: &[
1047 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 },
1048 ArgSpec { name: "overrides", arg_type: ArgType::Rest(&ArgType::KeyValue), description: "Template overrides shadowing that key (unified string values)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1049 ],
1050 flags: &[],
1051 default_output: Some(Stream::Stdout),
1052 examples: &[
1053 Example { name: "expand", fence_meta: None, code: indoc! {r#"
1054 ENV NAME="Alice"
1055 WRITE template.md "Hello {{ env:NAME }}!"
1056 EXPAND template.md
1057 ASSERT_CONTAINS stdout "Hello Alice!"
1058 "#} },
1059 Example { name: "override with spaces", fence_meta: None, code: indoc! {r#"
1060 # WRITE would interpolate {{ }} right away, so escape it:
1061 # the file must literally contain {{ env:NAME }} for EXPAND
1062 WRITE template.md "Hello \{{ env:NAME }}!"
1063 EXPAND template.md NAME="Alice Smith"
1064 ASSERT_CONTAINS stdout "Hello Alice Smith!"
1065 "#} },
1066 Example { name: "variable override", fence_meta: None, code: indoc! {r#"
1067 # same escaping: keep the placeholder literal until EXPAND;
1068 # a lone $who evaluates, like ECHO $who
1069 LET $who: STRING = "Bob"
1070 WRITE template.md "Hi \{{ env:WHO }}!"
1071 EXPAND template.md WHO=$who
1072 ASSERT_CONTAINS stdout "Hi Bob!"
1073 "#} },
1074 Example { name: "override forms agree", fence_meta: None, code: indoc! {r#"
1075 # a bare variable and a template-with-tail expand identically
1076 LET $x: STRING = "Ada"
1077 WRITE template.md "Hi \{{ env:NAME }} and \{{ env:NAME2 }}!"
1078 EXPAND template.md NAME=$x NAME2="{{ $x }} concatenated"
1079 ASSERT_CONTAINS stdout "Hi Ada and Ada concatenated!"
1080 "#} },
1081 Example { name: "expand stdin", fence_meta: None, code: indoc! {r#"
1082 # no path: the template arrives on stdin through a pipe
1083 WITH_IO [stdout=pipe:tpl] ECHO "Hello \{{ env:NAME }}!"
1084 WITH_IO [stdin=pipe:tpl] EXPAND NAME=Alice
1085 ASSERT_CONTAINS stdout "Hello Alice!"
1086 "#} },
1087 Example { name: "override does not leak", fence_meta: None, code: indoc! {r#"
1088 # KEY=val overrides shadow env for that EXPAND only —
1089 # they never update the environment itself
1090 ENV NAME="Alice"
1091 WRITE template.md "Hi \{{ env:NAME }}!"
1092 EXPAND template.md NAME="Bob"
1093 ASSERT_CONTAINS stdout "Hi Bob!"
1094 EXPAND template.md
1095 ASSERT_CONTAINS stdout "Hi Alice!"
1096 "#} },
1097 ],
1098 lower: |_flags, args| {
1099 let mut path = None;
1100 let mut overrides = Vec::new();
1101 for arg in args {
1102 let text = arg.as_str();
1103 if let Some((key, value)) = split_assignment(text).map_err(|e| ParseError::validation("EXPAND", e.to_string(), &SpanContext::line_only(0)))? {
1104 overrides.push((key, value));
1105 } else if path.is_none() { path = Some(arg); }
1106 else { return Err(ParseError::validation("EXPAND", "EXPAND accepts at most one path".to_string(), &SpanContext::line_only(0))) }
1107 }
1108 Ok(StepKind::Expand { path, overrides })
1109 },
1110 ],
1111
1112 AssertEq => [
1113 name: "ASSERT_EQ",
1114 variant: AssertEq { hash: Option<String>, actual: AssertTarget, expected: Option<Arg> },
1115 syntax: "ASSERT_EQ [--hash <sha256>] <actual> <expected>",
1116 summary: "Assert strict equality.",
1117 description: indoc! {r#"
1118 Compares two evaluated values with typed equality (no coercion:
1119 `Int(42)` never equals `String("42")`), aborting the pipeline
1120 with a step-numbered error showing expected vs actual otherwise.
1121
1122 Both sides are values: `$var`, literals, templates, and calls
1123 evaluate in memory and never touch disk. Read files explicitly
1124 first (`LET $text: STRING = READ "out.txt"`, then
1125 `ASSERT_EQ $text ...`).
1126 Bare `stdout` / `stderr` observe stream buffers; `pipe:NAME`
1127 observes a pipe buffer. `--hash` compares the SHA-256 of the
1128 actual's string bytes instead of the bytes themselves.
1129 "#},
1130 args: &[
1131 ArgSpec { name: "actual", arg_type: ArgType::Any, description: "Value, stdout, stderr, or pipe:NAME", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1132 ArgSpec { name: "expected", arg_type: ArgType::Rest(&ArgType::Any), description: "Expected (required unless --hash)", io: IoDirection::Read, index: 1, required: false, fallback_stream: None },
1133 ],
1134 flags: &[ FlagSpec { name: "hash", long: "--hash", value_type: FlagValueType::String, required: false, description: "SHA-256" } ],
1135 default_output: None,
1136 examples: &[ Example { name: "assert eq", fence_meta: None, code: indoc! {r#"
1137 LET $status: INT = 200
1138 ASSERT_EQ $status 200
1139 "#} },
1140 Example { name: "assert eq file", fence_meta: None, code: indoc! {r#"
1141 WRITE payload.bin stable-content
1142 LET $body: STRING = READ payload.bin
1143 ASSERT_EQ $body "stable-content"
1144 "#} },
1145 Example { name: "assert eq hash", fence_meta: None, code: indoc! {r#"
1146 # --hash compares the SHA-256 digest instead of raw bytes
1147 WRITE payload.bin stable-content
1148 LET $body: STRING = READ payload.bin
1149 ASSERT_EQ --hash 08135c1b6349b0e4f894c36221952f0de00e6b4d82f80895abf359755e77103c $body
1150 "#} } ],
1151 lower: |flags, args| {
1152 let hash = flags.iter().find(|(k, _)| k == "hash").map(|(_, v)| v.as_str().to_string());
1153 let mut it = args.into_iter();
1154 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)))?, "ASSERT_EQ")?;
1155 let remaining: Vec<Arg> = it
1156 .map(lower_assert_operand)
1157 .collect::<Vec<Arg>>();
1158 let expected = if remaining.is_empty() {
1161 if hash.is_some() {
1162 None
1163 } else {
1164 return Err(ParseError::validation("ASSERT_EQ", "ASSERT_EQ requires an expected value".to_string(), &SpanContext::line_only(0)))
1165 }
1166 } else {
1167 Some(join_value(remaining, "ASSERT_EQ")?)
1168 };
1169 Ok(StepKind::AssertEq { hash, actual, expected })
1170 },
1171 ],
1172
1173 AssertContains => [
1174 name: "ASSERT_CONTAINS",
1175 variant: AssertContains { haystack: AssertTarget, needle: Arg },
1176 syntax: "ASSERT_CONTAINS <haystack> <needle>",
1177 summary: "Assert containment.",
1178 description: indoc! {r#"
1179 Checks containment and aborts the pipeline with a step-numbered
1180 error otherwise: substring for strings, element match for lists,
1181 key presence for maps, substring over stream and pipe buffers.
1182
1183 Like `ASSERT_EQ`, both sides are values read without implicit
1184 I/O; read files explicitly first
1185 (`LET $text: STRING = READ "cfg.txt"`).
1186 Bare `stdout` / `stderr` observe stream buffers; `pipe:NAME`
1187 observes a pipe buffer.
1188 "#},
1189 args: &[
1190 ArgSpec { name: "haystack", arg_type: ArgType::Any, description: "Value, stdout, stderr, or pipe:NAME", io: IoDirection::Read, index: 0, required: true, fallback_stream: None },
1191 ArgSpec { name: "needle", arg_type: ArgType::Rest(&ArgType::Any), description: "Substring, element, or key", io: IoDirection::Read, index: 1, required: true, fallback_stream: None },
1192 ],
1193 flags: &[],
1194 default_output: None,
1195 examples: &[ Example { name: "assert contains", fence_meta: None, code: indoc! {r#"
1196 ECHO build-complete
1197 ASSERT_CONTAINS stdout "build-complete"
1198 "#} } ],
1199 lower: |flags, args| {
1200 let _ = flags;
1201 let mut it = args.into_iter();
1202 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)))?, "ASSERT_CONTAINS")?;
1203 let remaining: Vec<Arg> = it
1204 .map(lower_assert_operand)
1205 .collect::<Vec<Arg>>();
1206 if remaining.is_empty() {
1207 return Err(ParseError::validation("ASSERT_CONTAINS", "ASSERT_CONTAINS requires a needle".to_string(), &SpanContext::line_only(0)))
1208 }
1209 let needle = join_value(remaining, "ASSERT_CONTAINS")?;
1210 Ok(StepKind::AssertContains { haystack, needle })
1211 },
1212 ],
1213
1214 HashSha256 => [
1215 name: "HASH_SHA256",
1216 variant: HashSha256 { path: Arg },
1217 syntax: "HASH_SHA256 <path>",
1218 summary: "Print SHA-256.",
1219 description: "Computes digest.",
1220 args: &[ ArgSpec { name: "path", arg_type: ArgType::Path, description: "File", io: IoDirection::Read, index: 0, required: true, fallback_stream: None } ],
1221 flags: &[],
1222 default_output: Some(Stream::Stdout),
1223 examples: &[ Example { name: "hash", fence_meta: None, code: indoc! {r#"
1224 WRITE payload.txt hello
1225 HASH_SHA256 payload.txt
1226 "#} } ],
1227 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)))? }),
1228 ],
1229
1230 Exit => [
1231 name: "EXIT",
1232 variant: Exit(Arg),
1233 syntax: "EXIT <code>",
1234 summary: "Exit pipeline.",
1235 description: indoc! {r#"
1236 Stops the pipeline immediately with an `EXIT requested with code <code>`
1237 error; steps after it never run, at any nesting depth.
1238
1239 Enclosing blocks still unwind their LET/ENV/WORKDIR/WORKSPACE state,
1240 anonymous background tasks are killed synchronously, and files written
1241 before the EXIT persist.
1242 "#},
1243 args: &[ ArgSpec { name: "code", arg_type: ArgType::Int, description: "Code", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1244 flags: &[],
1245 default_output: None,
1246 examples: &[ Example { name: "exit", fence_meta: Some("expect_error:\"EXIT requested with code 0\""), code: indoc! {r#"EXIT 0"#} } ],
1247 lower: |_flags, args| {
1248 let code = args.into_iter().next().ok_or_else(|| ParseError::validation("EXIT", "EXIT requires a code".to_string(), &SpanContext::line_only(0)))?;
1251 Ok(StepKind::Exit(code))
1252 },
1253 ],
1254
1255 Sleep => [
1256 name: "SLEEP",
1257 variant: Sleep { duration: Arg },
1258 syntax: "SLEEP <duration>",
1259 summary: "Pause execution for a duration.",
1260 description: indoc! {r#"
1261 Parks the step for the duration (e.g. 500ms, 10s, 2m).
1262
1263 Cooperative: checks for cancellation so an enclosing TIMEOUT or task
1264 teardown interrupts the sleep. Cross-platform alternative to shell sleep
1265 for testing time boundaries.
1266 "#},
1267 args: &[ ArgSpec { name: "duration", arg_type: ArgType::Duration, description: "How long to sleep", io: IoDirection::Write, index: 0, required: true, fallback_stream: None } ],
1268 flags: &[],
1269 default_output: None,
1270 examples: &[
1271 Example { name: "sleep", fence_meta: None, code: indoc! {r#"SLEEP 100ms"#} },
1272 Example {
1273 name: "sleep variable duration",
1274 fence_meta: None,
1275 code: indoc! {r#"
1276 # durations resolve at runtime, so variables work too —
1277 # quoted or bare, both bind the same string
1278 LET $pause: STRING = "100ms"
1279 SLEEP $pause
1280 LET $bare: STRING = 100ms
1281 SLEEP $bare
1282 "#},
1283 },
1284 ],
1285 lower: |_flags, args| {
1286 let mut it = args.into_iter();
1287 let raw = it
1288 .next()
1289 .ok_or_else(|| ParseError::validation("SLEEP", "SLEEP requires a duration (e.g. SLEEP 500ms)".to_string(), &SpanContext::line_only(0)))?;
1290 if it.next().is_some() {
1291 return Err(ParseError::validation("SLEEP", "SLEEP takes exactly one duration argument".to_string(), &SpanContext::line_only(0)))
1292 }
1293 Ok(StepKind::Sleep { duration: raw })
1296 },
1297 ],
1298}
1299
1300pub fn all_structural_metadata() -> Vec<CommandMeta> {
1308 vec![
1309 CommandMeta {
1310 name: "WITH_IO",
1311 syntax: "WITH_IO [<stream>[=pipe:<name>|=$var], ...] <command> | WITH_IO [bindings] { <commands> }",
1312 summary: "Reroute standard streams.",
1313 description: indoc! {r#"
1314 Reroutes the standard streams of the next command or, in block form,
1315 of every enclosed command.
1316
1317 Bindings map streams (`stdin`, `stdout`, `stderr`) to named script
1318 pipes (`stdout=pipe:name`, `stderr=pipe:name`) or to a PIPE-typed
1319 variable (`stdin=$p`, resolved against the live pipe registry when
1320 the step runs). Both stdout and stderr pipes capture output the same way.
1321
1322 Pipes hold bytes in memory and spill to a temp file above 8 MiB, so a
1323 producer can finish before the consumer starts.
1324
1325 If WITH_IO wraps an ASYNC block whose body is a single RUN, guarded or
1326 not, the pipe is a zero copy OS kernel pipe instead: pair it with a
1327 consumer that runs while the producer is alive, since output past the
1328 64 KiB kernel buffer stalls until drained. That promotion never crosses
1329 a CALL boundary: pipes created, bound, or passed by variable inside FUNC
1330 bodies are always script pipes, even when the surrounding task would
1331 otherwise promote.
1332
1333 A second producer or consumer on a live name is an explicit error. A name
1334 bound as output can later feed another command's `stdin`, connecting
1335 commands without touching the terminal. Binding `stdout` and `stderr` to
1336 the same live pipe name fails deterministically. Merge streams in shell
1337 via `2>&1` instead.
1338
1339 Nested blocks stack defaults; inline bindings override inherited ones for
1340 their command only; closing a block restores previous wiring.
1341 "#},
1342 args: &[],
1343 flags: &[],
1344 default_output: None,
1345 examples: &[
1346 Example {
1347 name: "with_io block",
1348 fence_meta: None,
1349 code: indoc! {r#"
1350 WITH_IO [stdout=pipe:log] {
1351 ECHO first
1352 ECHO second
1353 }
1354 WITH_IO [stdin=pipe:log] WRITE captured.txt
1355 "#},
1356 },
1357 Example {
1358 name: "variable pipe binding",
1359 fence_meta: None,
1360 code: indoc! {r#"
1361 # Declare the pipe first with the explicit handle operator
1362 # (like `env:KEY`): `pipe:log` names a pipe without touching
1363 # a stream. A plain string here would be a TypeMismatch.
1364 # `$p` (not `pipe:$p`) is the variable form; literals stay
1365 # `pipe:name`.
1366 LET $p: PIPE = pipe:log
1367 WITH_IO [stdout=$p] ECHO hello
1368 WITH_IO [stdin=$p] READ_LINE $line
1369 ASSERT_EQ $line "hello"
1370 "#},
1371 },
1372 ],
1373 },
1374 CommandMeta {
1375 name: "FOR",
1376 syntax: "FOR $item: TYPE IN <expr> { <commands> } | FOR $key: STRING, $value: TYPE IN <expr> { <commands> }",
1377 summary: "Iterate over a list or map.",
1378 description: indoc! {r#"
1379 The loop variable receives each element (lists) or value (maps); with
1380 two variables, the first receives the key.
1381
1382 Loop variables are declared with explicit types and scoped per iteration;
1383 they do not leak outward. The body may be a braced block
1384 or a single-line `{ ... }` command.
1385
1386 `GLOB("...")` patterns must be quoted (`*` is not a bare word, so
1387 `GLOB(*)` is a parse error); GLOB returns a root-relative sorted list,
1388 empty when nothing matches, and rejects `..` escapes.
1389 "#},
1390 args: &[],
1391 flags: &[],
1392 default_output: None,
1393 examples: &[
1394 Example {
1395 name: "for loop",
1396 fence_meta: None,
1397 code: indoc! {r#"
1398 LET $items: LIST = ["a", "b"]
1399 FOR $item: STRING IN $items {
1400 ECHO $item
1401 }
1402
1403 LET $map: MAP = {"x": 1}
1404 FOR $k: STRING, $v: INT IN $map {
1405 ECHO "$k=$v"
1406 }
1407 "#},
1408 },
1409 Example {
1410 name: "expand every match",
1411 fence_meta: None,
1412 code: indoc! {r#"
1413 # single-line body; $x is a template path, WHO an override
1414 WRITE a.txt "hi \{{ env:WHO }}!"
1415 FOR $x: STRING IN GLOB("*.txt") { EXPAND $x WHO=World }
1416 ASSERT_CONTAINS stdout "hi World!"
1417 "#},
1418 },
1419 ],
1420 },
1421 CommandMeta {
1422 name: "IF",
1423 syntax: "IF <expr> { <commands> } [ELSE IF <expr> { <commands> }] [ELSE { <commands> }]",
1424 summary: "Conditional execution.",
1425 description: indoc! {r#"
1426 The condition is evaluated as a boolean expression.
1427
1428 Prefix `!` negates (`IF !false`); `&&` binds tighter than
1429 `||`, and both short-circuit, so `IF true || $missing`
1430 never evaluates the right side. Only Bool values are
1431 accepted as conditions.
1432 "#},
1433 args: &[],
1434 flags: &[],
1435 default_output: None,
1436 examples: &[
1437 Example {
1438 name: "if else",
1439 fence_meta: None,
1440 code: indoc! {r#"
1441 IF true {
1442 WRITE yes.txt taken
1443 } ELSE {
1444 WRITE yes.txt skipped
1445 }
1446
1447 IF false {
1448 WRITE skipped.txt no
1449 } ELSE IF true {
1450 WRITE fallback.txt taken
1451 }
1452
1453 # !false evaluates to true, so this branch runs.
1454 IF !false {
1455 WRITE negated.txt taken
1456 }
1457 LET $yes_body: STRING = READ yes.txt
1458 LET $fallback_body: STRING = READ fallback.txt
1459 LET $negated_body: STRING = READ negated.txt
1460 ASSERT_EQ $yes_body "taken"
1461 ASSERT_EQ $fallback_body "taken"
1462 ASSERT_EQ $negated_body "taken"
1463 LET $t: STRING = PATH_TYPE("skipped.txt")
1464 ASSERT_EQ $t "absent"
1465 "#},
1466 },
1467 Example {
1468 name: "logical condition composition",
1469 fence_meta: None,
1470 code: indoc! {r#"
1471 LET $role: STRING = "admin"
1472 LET $level: INT = 3
1473 # || is true when either side holds; && needs both.
1474 IF $role == "owner" || $level >= 5 {
1475 WRITE unexpected.txt no
1476 } ELSE {
1477 WRITE fallback.txt or-false
1478 }
1479 IF $role == "admin" || $level >= 5 {
1480 WRITE chosen.txt or-true
1481 }
1482 IF $role == "admin" && $level >= 5 {
1483 WRITE unexpected-too.txt no
1484 } ELSE {
1485 WRITE and.txt and-false
1486 }
1487 LET $fb: STRING = READ fallback.txt
1488 LET $ch: STRING = READ chosen.txt
1489 LET $an: STRING = READ and.txt
1490 ASSERT_EQ $fb "or-false"
1491 ASSERT_EQ $ch "or-true"
1492 ASSERT_EQ $an "and-false"
1493 LET $t1: STRING = PATH_TYPE("unexpected.txt")
1494 LET $t2: STRING = PATH_TYPE("unexpected-too.txt")
1495 ASSERT_EQ $t1 "absent"
1496 ASSERT_EQ $t2 "absent"
1497 "#},
1498 },
1499 ],
1500 },
1501 CommandMeta {
1502 name: "LET",
1503 syntax: "LET $var: TYPE = <expr> | LET $var: TYPE = ASYNC { <commands> } | LET $var: TYPE = <command> | LET $var: TYPE = AWAIT $task",
1504 summary: "Bind script-local variables.",
1505 description: indoc! {r#"
1506 Declares a script-local variable with an explicit type (STRING, INT,
1507 FLOAT, BOOL, PIPE, LIST, MAP, HANDLE, DURATION, PATH). Duplicate LET
1508 in the same scope frame is a redeclaration error; mutate with
1509 `$var = <expr>`.
1510
1511 Variables are usable in templates (`{{ $var }}`), guards, and
1512 expressions. With `ASYNC`, spawns a background task and stores its
1513 handle (see ASYNC). The `$` sigil on the name is mandatory.
1514
1515 The right-hand side is always an expression — literals, lists, maps,
1516 arithmetic (`+ - * /` with `*`/`/` binding tighter, unary `-`,
1517 parentheses), comparisons (`< <= > >=` binding tighter than
1518 `== !=`), logical `&&` (tighter) and `||` with short-circuit,
1519 `!` negation, `env:KEY` reads, `pipe:NAME` handles,
1520 `INSPECT($var)` snapshots, `GLOB("*.md")`, `INT(x)` /
1521 `FLOAT(x)` conversions — never a `{{ ... }}` template;
1522 interpolation happens in string values, not here.
1523
1524 Numbers are numeric literals: `42` binds `INT`, `3.14` binds
1525 `FLOAT`. `Int x Int` stays `INT` (checked, integer division,
1526 so `7 / 2` is `3`); any `Float` operand promotes to `FLOAT`.
1527 Division by zero, overflow, and non-finite results are errors.
1528 Both numeric sides compare numerically (`1 == 1.0` is true);
1529 otherwise `==`/`!=` compare rendered strings and ordering on
1530 non-numerics is a Type Error. Constant subtrees fold at parse
1531 time and dynamic arithmetic compiles to flat RPN with
1532 identical semantics.
1533
1534 Float equality is exact with no epsilon. Floats store decimals
1535 in binary, so a value is exact only when its reduced fraction
1536 has a power-of-2 denominator: 0.5 (1/2), 0.25 (1/4), 0.75
1537 (3/4) are exact, while 0.1 (1/10), 0.2 (1/5), 0.3 (3/10)
1538 repeat forever in binary (like 1/3 in decimal) and truncate,
1539 so `0.1 + 0.2 == 0.3` is false (the sum is
1540 `0.30000000000000004`). Rule of thumb: endings .5, .25, .75,
1541 .125, .625, .875 are exact; .1, .2, .3 and similar are
1542 approximations. Bound approximations instead of comparing
1543 them: `IF $sum > 0.299999 && $sum < 0.300001`.
1544
1545 Comparisons do not chain: `a < b < c` is a parse error, not
1546 `(a < b) < c`. Chaining would compare a `BOOL` against a
1547 number (a runtime Type Error in C-style parsing) or evaluate
1548 the middle term twice (Python-style chaining), so the grammar
1549 accepts exactly one comparison operator per level. Write the
1550 conjunction explicitly: `$a < $b && $b < $c`. The same holds
1551 for equality (`$a == $b == $c` is rejected).
1552
1553 Captured command output is a string, so convert before math:
1554 `LET $total: INT = $total + INT($size_str)` (`INT` trims ASCII
1555 whitespace; `FLOAT` accepts int strings and rejects
1556 non-finite).
1557
1558 Bare words need no quotes: `LET $d: STRING = 30s` binds the same string
1559 as quoted.
1560
1561 When the right-hand side is a synchronous command
1562 (`LET $out: STRING = ECHO hi`), the command runs to completion and its
1563 exact stdout bytes are captured into the variable as a string (no newline
1564 stripping; commands with no stdout capture as `""`; non-UTF8 stdout is
1565 an error). Combining capture with an explicit
1566 `WITH_IO [stdout=pipe:...]` is a parse error.
1567
1568 Coming from Bash, the capture line looks familiar but behaves
1569 strictly:
1570
1571 | | Bash `output=$(...)` | OxDock `LET $out: STRING = ...` |
1572 | --- | --- | --- |
1573 | Trailing newlines | Stripped (all of them) | Preserved byte-exact |
1574 | Variable type | Always an untyped string | Declared: STRING, INT, FLOAT, ... |
1575 | Math on output | Implicit: `$((var + 1))` | Explicit: `INT($out) + 1` |
1576 | Failing command | Continues with empty output unless `set -e` | Step fails immediately, binds nothing |
1577
1578 `LET $out: STRING = AWAIT $var` captures a background task's stdout the
1579 same way; bare `AWAIT $var` forwards it to the parent stdout instead.
1580
1581 `LET $e: STRING = env:FOO` reads the script environment into a plain
1582 string.
1583 "#},
1584 args: &[],
1585 flags: &[],
1586 default_output: None,
1587 examples: &[
1588 Example {
1589 name: "let",
1590 fence_meta: None,
1591 code: indoc! {r#"
1592 LET $name: STRING = "world"
1593 ECHO "hello, {{ $name }}"
1594
1595 LET $items: LIST = ["a", "b"]
1596 LET $count: INT = 42
1597 "#},
1598 },
1599 Example {
1600 name: "glob binding",
1601 fence_meta: None,
1602 code: indoc! {r#"
1603 # the RHS is an expression: GLOB(...) runs and binds a list
1604 WRITE a.txt "x"
1605 LET $files: LIST = GLOB("*.txt")
1606 FOR $f: STRING IN $files { ECHO $f }
1607 ASSERT_CONTAINS stdout "a.txt"
1608 "#},
1609 },
1610 Example {
1611 name: "scoped variable reverts",
1612 fence_meta: None,
1613 code: indoc! {r#"
1614 # LET inside a braced block reverts when the block exits
1615 LET $a: STRING = "outer"
1616 [bool:true] {
1617 LET $a: STRING = "inner"
1618 WRITE inner.txt "{{ $a }}"
1619 }
1620 WRITE outer.txt "{{ $a }}"
1621 LET $in_body: STRING = READ inner.txt
1622 LET $out_body: STRING = READ outer.txt
1623 ASSERT_EQ $in_body "inner"
1624 ASSERT_EQ $out_body "outer"
1625 "#},
1626 },
1627 Example {
1628 name: "capture command output",
1629 fence_meta: None,
1630 code: indoc! {r#"
1631 LET $out: STRING = ECHO hi
1632 ASSERT_EQ $out "hi\n"
1633 "#},
1634 },
1635 Example {
1636 name: "arithmetic over captured output",
1637 fence_meta: None,
1638 code: indoc! {r#"
1639 LET $size_str: STRING = ECHO 41
1640 LET $total: INT = INT($size_str) + 1
1641 LET $ratio: FLOAT = 1 + 2.5
1642 # Int x Int stays INT: integer division truncates.
1643 LET $half: INT = 7 / 2
1644 ASSERT_EQ $total 42
1645 ASSERT_EQ $ratio 3.5
1646 ASSERT_EQ $half 3
1647 "#},
1648 },
1649 Example {
1650 name: "float equality is exact",
1651 fence_meta: None,
1652 code: indoc! {r#"
1653 # Binary fractions compare cleanly; decimal fractions may not:
1654 # 0.1 + 0.2 is 0.30000000000000004, so == is false.
1655 LET $exact: BOOL = 0.5 + 0.25 == 0.75
1656 LET $decimal: BOOL = 0.1 + 0.2 == 0.3
1657 IF $exact {
1658 WRITE exact.txt yes
1659 }
1660 IF $decimal {
1661 WRITE unexpected.txt no
1662 }
1663 LET $ok: STRING = READ exact.txt
1664 ASSERT_EQ $ok "yes"
1665 LET $t: STRING = PATH_TYPE("unexpected.txt")
1666 ASSERT_EQ $t "absent"
1667 "#},
1668 },
1669 Example {
1670 name: "bound inexact decimals",
1671 fence_meta: None,
1672 code: indoc! {r#"
1673 # Never test inexact decimals for equality; bound them.
1674 LET $sum: FLOAT = 0.1 + 0.2
1675 IF $sum > 0.299999 && $sum < 0.300001 {
1676 WRITE bounded.txt yes
1677 }
1678 LET $ok: STRING = READ bounded.txt
1679 ASSERT_EQ $ok "yes"
1680 "#},
1681 },
1682 Example {
1683 name: "inspect a variable",
1684 fence_meta: None,
1685 code: indoc! {r#"
1686 # INSPECT($var) snapshots a variable into a MAP: declared
1687 # type plus live details (pipe backend stats here), so
1688 # scripts can branch on engine state.
1689 LET $p: PIPE = pipe:log
1690 WITH_IO [stdout=$p] ECHO hello
1691 LET $info: MAP = INSPECT($p)
1692 IF $info.is_os_pipe {
1693 WRITE unexpected.txt "should be a script pipe"
1694 }
1695 ASSERT_EQ $info.type "PIPE"
1696 "#},
1697 },
1698 ],
1699 },
1700 CommandMeta {
1701 name: "MUTATION",
1702 syntax: "$var = <expr>",
1703 summary: "Mutate a declared variable.",
1704 description: indoc! {r#"
1705 Reassigns an existing variable, converting the new value to
1706 the type declared at LET time. The explicit annotation is
1707 what authorizes string-to-number conversion here (`$n = "42"`
1708 binds 42 for an INT); a non-numeric string is an error.
1709 Expressions never convert: `"100" + 1` is a Type Error, use
1710 `INT()` / `FLOAT()` to cross that boundary explicitly.
1711
1712 The leading `$` distinguishes mutation from `KEY=value` command
1713 assignments. Assigning an undeclared variable or a mismatched type is
1714 an error.
1715
1716 Mutation writes through to the scope where the variable was
1717 declared, so it survives block exit: `LET $x` outside a block
1718 followed by `$x = ...` inside still reads back the new value
1719 afterwards, for every type. This is the counterpart to LET
1720 shadowing, where `LET $x` *inside* the block declares a
1721 separate inner variable that reverts on exit.
1722 "#},
1723 args: &[],
1724 flags: &[],
1725 default_output: None,
1726 examples: &[
1727 Example {
1728 name: "mutate",
1729 fence_meta: None,
1730 code: indoc! {r#"
1731 LET $count: INT = 1
1732 $count = 2
1733 ASSERT_EQ $count 2
1734 "#},
1735 },
1736 Example {
1737 name: "convert before math",
1738 fence_meta: None,
1739 code: indoc! {r#"
1740 # Captured output is a string: `"100" + 1` is a Type Error.
1741 # Convert explicitly, then mutate with arithmetic.
1742 LET $raw: STRING = ECHO 100
1743 LET $n: INT = INT($raw)
1744 $n = $n + 1
1745 # The declared type also converts plain strings on assignment.
1746 $n = "42"
1747 # Same crossing for decimals via FLOAT().
1748 LET $frac_str: STRING = ECHO 2.5
1749 LET $f: FLOAT = FLOAT($frac_str) + 0.25
1750 ASSERT_EQ $n 42
1751 ASSERT_EQ $f 2.75
1752 "#},
1753 },
1754 ],
1755 },
1756 CommandMeta {
1757 name: "ASYNC",
1758 syntax: "ASYNC <command...> | ASYNC { <commands> } | LET $var: HANDLE = ASYNC { <commands> }",
1759 summary: "Run steps in a background thread.",
1760 description: indoc! {r#"
1761 Runs a command or block of commands in a background thread with
1762 subshell isolation.
1763
1764 Mutations (ENV, WORKDIR) stay within the block. With `LET`, stores a
1765 task handle for `AWAIT`.
1766 "#},
1767 args: &[],
1768 flags: &[],
1769 default_output: None,
1770 examples: &[
1771 Example {
1772 name: "async",
1773 fence_meta: None,
1774 code: indoc! {r#"
1775 ASYNC ECHO "first"
1776
1777 ASYNC {
1778 ECHO "first"
1779 ECHO "second"
1780 }
1781 "#},
1782 },
1783 Example {
1784 name: "async task handle",
1785 fence_meta: None,
1786 code: indoc! {r#"
1787 LET $task: HANDLE = ASYNC {
1788 ECHO "built"
1789 }
1790 AWAIT $task
1791 "#},
1792 },
1793 ],
1794 },
1795 CommandMeta {
1796 name: "AWAIT",
1797 syntax: "AWAIT $var | LET $out: STRING = AWAIT $var",
1798 summary: "Join a background task.",
1799 description: indoc! {r#"
1800 Blocks until the named task completes. Propagates errors if the task failed.
1801
1802 Bare `AWAIT $var` forwards the task's stdout to the parent stdout;
1803 `LET $out: STRING = AWAIT $var` captures it into `$out` instead (same
1804 UTF-8 and spilling rules as `LET $var: STRING = <command>`).
1805 "#},
1806 args: &[],
1807 flags: &[],
1808 default_output: None,
1809 examples: &[
1810 Example {
1811 name: "await",
1812 fence_meta: None,
1813 code: indoc! {r#"
1814 LET $task: HANDLE = ASYNC ECHO "done"
1815 AWAIT $task
1816 "#},
1817 },
1818 Example {
1819 name: "await capture",
1820 fence_meta: None,
1821 code: indoc! {r#"
1822 LET $task: HANDLE = ASYNC ECHO "done"
1823 LET $out: STRING = AWAIT $task
1824 ASSERT_EQ $out "done\n"
1825 "#},
1826 },
1827 ],
1828 },
1829 CommandMeta {
1830 name: "CANCEL",
1831 syntax: "CANCEL $var",
1832 summary: "Synchronously cancel a background task.",
1833 description: indoc! {r#"
1834 Kills the named background task spawned via LET $var: HANDLE = ASYNC ....
1835
1836 Blocking: returns only after the task thread has been joined and its OS
1837 process reaped, so no residual filesystem or stream mutation follows. A
1838 later AWAIT $var reports cancellation. Only named tasks can be cancelled.
1839 "#},
1840 args: &[],
1841 flags: &[],
1842 default_output: None,
1843 examples: &[Example {
1844 name: "cancel",
1845 fence_meta: None,
1846 code: indoc! {r#"
1847 LET $task: HANDLE = ASYNC SLEEP 30s
1848 CANCEL $task
1849 "#},
1850 }],
1851 },
1852 CommandMeta {
1853 name: "TIMEOUT",
1854 syntax: "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> } | TIMEOUT <duration> AWAIT $var",
1855 summary: "Enforce an execution deadline.",
1856 description: indoc! {r#"
1857 Aborts the wrapped step or block with a deadline error if it exceeds the
1858 duration (e.g. 500ms, 10s, 2m; a bare number means seconds).
1859
1860 A blocking foreground process is killed.
1861 "#},
1862 args: &[],
1863 flags: &[],
1864 default_output: None,
1865 examples: &[
1866 Example {
1867 name: "timeout",
1868 fence_meta: None,
1869 code: indoc! {r#"TIMEOUT 30s WRITE heartbeat.txt alive"#},
1870 },
1871 Example {
1872 name: "timeout block",
1873 fence_meta: None,
1874 code: indoc! {r#"
1875 TIMEOUT 30s {
1876 WRITE a.txt one
1877 WRITE b.txt two
1878 }
1879 "#},
1880 },
1881 Example {
1882 name: "timeout variable duration",
1883 fence_meta: None,
1884 code: indoc! {r#"
1885 # durations resolve at runtime, so variables work too
1886 LET $budget: DURATION = "30s"
1887 TIMEOUT $budget WRITE heartbeat.txt alive
1888 LET $beat: STRING = READ heartbeat.txt
1889 ASSERT_EQ $beat "alive"
1890 "#},
1891 },
1892 ],
1893 },
1894 CommandMeta {
1895 name: "FUNC",
1896 syntax: "FUNC NAME($param: TYPE, ...) { <commands> }",
1897 summary: "Define a user function.",
1898 description: indoc! {r#"
1899 Defines a user function with UPPERCASE name and explicitly typed
1900 parameters.
1901
1902 Params bind by position, converting each argument to its
1903 declared parameter type before the body runs.
1904 Bodies run in a fresh variable scope; LETs inside do not leak. A nested
1905 FUNC definition is scoped to its block and reverts on exit. Names share
1906 one namespace with host-registered functions.
1907 "#},
1908 args: &[],
1909 flags: &[],
1910 default_output: None,
1911 examples: &[Example {
1912 name: "func def call",
1913 fence_meta: None,
1914 code: indoc! {r#"
1915 FUNC GREET($name: STRING) {
1916 RETURN $name
1917 }
1918 LET $res: STRING = CALL GREET("ada")
1919 ASSERT_EQ $res "ada"
1920 "#},
1921 }],
1922 },
1923 CommandMeta {
1924 name: "CALL",
1925 syntax: "CALL NAME(<expr>, ...) | LET $var: TYPE = CALL NAME(<expr>, ...)",
1926 summary: "Invoke a user or host function.",
1927 description: indoc! {r#"
1928 Invokes a FUNC-defined or host-registered function by UPPERCASE name.
1929
1930 Bare CALL discards the return value and keeps stdout side effects.
1931 LET $var: TYPE = CALL captures the RETURN value (fallthrough without
1932 RETURN captures as ""), coerced to the declared type; stdout inside the
1933 callee stays observable via ASSERT_CONTAINS stdout and pipes.
1934
1935 Combining LET-capture with WITH_IO [stdout=pipe:...] is a parse error.
1936 "#},
1937 args: &[],
1938 flags: &[],
1939 default_output: None,
1940 examples: &[
1941 Example {
1942 name: "call",
1943 fence_meta: None,
1944 code: indoc! {r#"
1945 FUNC SHOUT($name: STRING) {
1946 ECHO "{{ $name }}"
1947 RETURN $name
1948 }
1949 CALL SHOUT("ada")
1950 ASSERT_CONTAINS stdout "ada"
1951 "#},
1952 },
1953 Example {
1954 name: "call with pipes",
1955 fence_meta: None,
1956 code: indoc! {r#"
1957 # A pipe handle travels into a function as a typed argument
1958 # and is usable as a binding target in both directions.
1959 # `pipe:ch` constructs the handle; `$p` passes it on.
1960 FUNC DRAIN($q: PIPE) {
1961 WITH_IO [stdin=$q] READ_LINE $line
1962 RETURN $line
1963 }
1964 LET $p: PIPE = pipe:ch
1965 WITH_IO [stdout=$p] ECHO "payload"
1966 LET $got: STRING = CALL DRAIN($p)
1967 ASSERT_EQ $got "payload"
1968 "#},
1969 },
1970 ],
1971 },
1972 CommandMeta {
1973 name: "RETURN",
1974 syntax: "RETURN <expr>",
1975 summary: "Return a value from a function.",
1976 description: indoc! {r#"
1977 Ends the nearest enclosing function call with a value.
1978
1979 Falling off the end without RETURN yields "". RETURN outside a function
1980 (including at top level or across an ASYNC boundary) is an error.
1981 "#},
1982 args: &[],
1983 flags: &[],
1984 default_output: None,
1985 examples: &[Example {
1986 name: "return",
1987 fence_meta: None,
1988 code: indoc! {r#"
1989 FUNC PICK($flag: BOOL) {
1990 IF $flag {
1991 RETURN "yes"
1992 }
1993 RETURN "no"
1994 }
1995 LET $res: STRING = CALL PICK(true)
1996 ASSERT_EQ $res "yes"
1997 "#},
1998 }],
1999 },
2000 CommandMeta {
2001 name: "WHILE",
2002 syntax: "WHILE <bool-expr> { <commands> }",
2003 summary: "Loop while a condition holds.",
2004 description: indoc! {r#"
2005 Re-evaluates a Bool condition each iteration (same is_truthy rule as IF;
2006 non-Bool is a type error).
2007
2008 Each iteration runs in a fresh scope; mutate outer state with $var = ...
2009 so the next check observes it. BREAK exits the loop; CONTINUE skips to
2010 the next check.
2011 "#},
2012 args: &[],
2013 flags: &[],
2014 default_output: None,
2015 examples: &[Example {
2016 name: "while loop",
2017 fence_meta: None,
2018 code: indoc! {r#"
2019 LET $done: BOOL = false
2020 WHILE !$done {
2021 WRITE tick.txt "once"
2022 $done = true
2023 }
2024 LET $tick: STRING = READ tick.txt
2025 ASSERT_EQ $tick "once"
2026 "#},
2027 }],
2028 },
2029 CommandMeta {
2030 name: "BREAK",
2031 syntax: "BREAK",
2032 summary: "Exit the innermost loop.",
2033 description: indoc! {r#"
2034 Exits the innermost enclosing FOR or WHILE loop.
2035
2036 BREAK outside a loop, or across a FUNC or ASYNC boundary, is an error.
2037 "#},
2038 args: &[],
2039 flags: &[],
2040 default_output: None,
2041 examples: &[Example {
2042 name: "break",
2043 fence_meta: None,
2044 code: indoc! {r#"
2045 FOR $x: STRING IN ["a", "b"] {
2046 BREAK
2047 }
2048 "#},
2049 }],
2050 },
2051 CommandMeta {
2052 name: "CONTINUE",
2053 syntax: "CONTINUE",
2054 summary: "Skip to the next loop iteration.",
2055 description: indoc! {r#"
2056 Skips the rest of the innermost enclosing FOR or WHILE body and starts
2057 the next iteration.
2058
2059 CONTINUE outside a loop, or across a FUNC or ASYNC boundary, is an error.
2060 "#},
2061 args: &[],
2062 flags: &[],
2063 default_output: None,
2064 examples: &[Example {
2065 name: "continue",
2066 fence_meta: None,
2067 code: indoc! {r#"
2068 FOR $x: STRING IN ["a", "b"] {
2069 CONTINUE
2070 }
2071 "#},
2072 }],
2073 },
2074 ]
2075}
2076
2077impl fmt::Display for StepKind {
2080 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
2081 match self {
2082 StepKind::InheritEnv { keys } => write!(f, "INHERIT_ENV [{}]", keys.join(", ")),
2083 StepKind::Workdir(a) => write!(f, "WORKDIR {}", fmt_value(a, quote_arg)),
2084 StepKind::Workspace(t) => write!(f, "WORKSPACE {}", t),
2085 StepKind::Env { key, value } => {
2086 write!(f, "ENV {}={}", key, fmt_value(value, quote_arg))
2087 }
2088 StepKind::Run(c) => write!(f, "RUN {}", fmt_value(c, quote_run)),
2089 StepKind::RunExec { argv } => {
2090 let parts: Vec<String> = argv.iter().map(fmt_exec_arg).collect();
2091 write!(f, "RUN [{}]", parts.join(", "))
2092 }
2093 StepKind::Echo(m) => write!(f, "ECHO {}", fmt_value(m, quote_msg)),
2094 StepKind::Copy {
2095 from_current_workspace,
2096 from,
2097 to,
2098 } => {
2099 if *from_current_workspace {
2100 write!(
2101 f,
2102 "COPY --from-current-workspace {} {}",
2103 fmt_value(from, quote_arg),
2104 fmt_value(to, quote_arg)
2105 )
2106 } else {
2107 write!(
2108 f,
2109 "COPY {} {}",
2110 fmt_value(from, quote_arg),
2111 fmt_value(to, quote_arg)
2112 )
2113 }
2114 }
2115 StepKind::Symlink { from, to } => write!(
2116 f,
2117 "SYMLINK {} {}",
2118 fmt_value(from, quote_arg),
2119 fmt_value(to, quote_arg)
2120 ),
2121 StepKind::Mkdir(a) => write!(f, "MKDIR {}", fmt_value(a, quote_arg)),
2122 StepKind::Ls(a) => {
2123 write!(f, "LS")?;
2124 if let Some(x) = a {
2125 write!(f, " {}", fmt_value(x, quote_arg))?;
2126 }
2127 Ok(())
2128 }
2129 StepKind::Cwd => write!(f, "CWD"),
2130 StepKind::Read(a) => {
2131 write!(f, "READ")?;
2132 if let Some(x) = a {
2133 write!(f, " {}", fmt_value(x, quote_arg))?;
2134 }
2135 Ok(())
2136 }
2137 StepKind::ReadLine { var } => write!(f, "READ_LINE ${}", var),
2138 StepKind::Write { path, contents } => {
2139 write!(f, "WRITE {}", fmt_value(path, quote_arg))?;
2140 if let Some(b) = contents {
2141 write!(f, " {}", fmt_value(b, quote_msg))?;
2142 }
2143 Ok(())
2144 }
2145 StepKind::Append { path, contents } => {
2146 write!(f, "APPEND {}", fmt_value(path, quote_arg))?;
2147 if let Some(b) = contents {
2148 write!(f, " {}", fmt_value(b, quote_msg))?;
2149 }
2150 Ok(())
2151 }
2152 StepKind::Expand { path, overrides } => {
2153 write!(f, "EXPAND")?;
2154 if let Some(p) = path {
2155 write!(f, " {}", fmt_value(p, quote_arg))?;
2156 }
2157 for (k, v) in overrides {
2158 write!(f, " {}={}", k, fmt_value(v, quote_arg))?;
2159 }
2160 Ok(())
2161 }
2162 StepKind::AssertEq {
2163 hash,
2164 actual,
2165 expected,
2166 } => {
2167 if let Some(d) = hash {
2168 write!(f, "ASSERT_EQ --hash {d} {}", fmt_assert_target(actual))?;
2169 } else {
2170 write!(
2171 f,
2172 "ASSERT_EQ {} {}",
2173 fmt_assert_target(actual),
2174 fmt_value(
2175 expected
2176 .as_ref()
2177 .expect("Display of ASSERT_EQ without --hash needs expected"),
2178 quote_msg
2179 )
2180 )?;
2181 }
2182 Ok(())
2183 }
2184 StepKind::AssertContains { haystack, needle } => write!(
2185 f,
2186 "ASSERT_CONTAINS {} {}",
2187 fmt_assert_target(haystack),
2188 fmt_value(needle, quote_msg)
2189 ),
2190 StepKind::WithIo { bindings, cmd } => {
2191 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2192 write!(f, "WITH_IO [{}] {}", p.join(", "), cmd)
2193 }
2194 StepKind::WithIoBlock { bindings } => {
2195 let p: Vec<String> = bindings.iter().map(fmt_io).collect();
2196 write!(f, "WITH_IO [{}] {{...}}", p.join(", "))
2197 }
2198 StepKind::CopyGit {
2199 rev,
2200 from,
2201 to,
2202 include_dirty,
2203 } => {
2204 if *include_dirty {
2205 write!(
2206 f,
2207 "COPY_GIT --include-dirty {} {} {}",
2208 fmt_value(rev, quote_arg),
2209 fmt_value(from, quote_arg),
2210 fmt_value(to, quote_arg)
2211 )
2212 } else {
2213 write!(
2214 f,
2215 "COPY_GIT {} {} {}",
2216 fmt_value(rev, quote_arg),
2217 fmt_value(from, quote_arg),
2218 fmt_value(to, quote_arg)
2219 )
2220 }
2221 }
2222 StepKind::HashSha256 { path } => {
2223 write!(f, "HASH_SHA256 {}", fmt_value(path, quote_arg))
2224 }
2225 StepKind::Exit(code) => write!(f, "EXIT {}", fmt_raw_arg(code)),
2226 StepKind::Sleep { duration } => write!(f, "SLEEP {}", fmt_raw_arg(duration)),
2227 StepKind::For {
2228 key_var,
2229 key_type,
2230 var,
2231 var_type,
2232 in_expr,
2233 body,
2234 } => {
2235 match key_var {
2236 Some(k) => {
2237 let kt = key_type.as_ref().map(|t| t.label()).unwrap_or("STRING");
2238 write!(
2239 f,
2240 "FOR ${}: {}, ${}: {} IN {} {{",
2241 k, kt, var, var_type, in_expr
2242 )?
2243 }
2244 None => write!(f, "FOR ${}: {} IN {} {{", var, var_type, in_expr)?,
2245 }
2246 for s in body {
2247 write!(f, "\n {}", s)?;
2248 }
2249 write!(f, "\n}}")
2250 }
2251 StepKind::If {
2252 cond,
2253 then_body,
2254 else_ifs,
2255 else_body,
2256 } => {
2257 write!(f, "IF {} {{", cond)?;
2258 for s in then_body {
2259 write!(f, "\n {}", s)?;
2260 }
2261 write!(f, " }}")?;
2262 for (c, b) in else_ifs {
2263 write!(f, " ELSE IF {} {{", c)?;
2264 for s in b {
2265 write!(f, "\n {}", s)?;
2266 }
2267 write!(f, " }}")?;
2268 }
2269 if let Some(b) = else_body {
2270 write!(f, " ELSE {{")?;
2271 for s in b {
2272 write!(f, "\n {}", s)?;
2273 }
2274 write!(f, " }}")?;
2275 }
2276 Ok(())
2277 }
2278 StepKind::Assign {
2279 var,
2280 decl_type,
2281 expr,
2282 } => {
2283 write!(f, "LET ${}: {} = {}", var, decl_type, expr)
2284 }
2285 StepKind::Set { var, expr } => write!(f, "${} = {}", var, expr),
2286 StepKind::AssignCapture {
2287 var,
2288 decl_type,
2289 cmd,
2290 } => {
2291 write!(f, "LET ${}: {} = {}", var, decl_type, cmd)
2292 }
2293 StepKind::AsyncBlock { body } => {
2294 write!(f, "ASYNC {{")?;
2295 for s in body {
2296 write!(f, "\n {}", s)?;
2297 }
2298 write!(f, "\n}}")
2299 }
2300 StepKind::AssignAsync {
2301 var,
2302 decl_type,
2303 body,
2304 } => {
2305 write!(f, "LET ${}: {} = ASYNC {{", var, decl_type)?;
2306 for s in body {
2307 write!(f, "\n {}", s)?;
2308 }
2309 write!(f, "\n}}")
2310 }
2311 StepKind::Await { var } => write!(f, "AWAIT ${}", var),
2312 StepKind::AwaitCapture {
2313 out_var,
2314 out_type,
2315 task_var,
2316 } => {
2317 write!(f, "LET ${}: {} = AWAIT ${}", out_var, out_type, task_var)
2318 }
2319 StepKind::Cancel { var } => write!(f, "CANCEL ${}", var),
2320 StepKind::Timeout { duration, body } => {
2321 let budget = fmt_raw_arg(duration);
2322 if body.len() == 1 {
2323 write!(f, "TIMEOUT {} {}", budget, body[0].kind)
2324 } else {
2325 write!(f, "TIMEOUT {} {{", budget)?;
2326 for s in body {
2327 write!(f, "\n {}", s)?;
2328 }
2329 write!(f, "\n}}")
2330 }
2331 }
2332 StepKind::FuncDef { name, params, body } => {
2333 let ps: Vec<String> = params
2334 .iter()
2335 .map(|(p, t)| format!("${}: {}", p, t))
2336 .collect();
2337 write!(f, "FUNC {}({}) {{", name, ps.join(", "))?;
2338 for s in body {
2339 write!(f, "\n {}", s)?;
2340 }
2341 write!(f, "\n}}")
2342 }
2343 StepKind::Call { name, args } => {
2344 let ps: Vec<String> = args.iter().map(|a| format!("{}", a)).collect();
2345 write!(f, "CALL {}({})", name, ps.join(", "))
2346 }
2347 StepKind::Return { expr } => write!(f, "RETURN {}", expr),
2348 StepKind::While { cond, body } => {
2349 write!(f, "WHILE {} {{", cond)?;
2350 for s in body {
2351 write!(f, "\n {}", s)?;
2352 }
2353 write!(f, "\n}}")
2354 }
2355 StepKind::Break => write!(f, "BREAK"),
2356 StepKind::Continue => write!(f, "CONTINUE"),
2357 }
2358 }
2359}
2360
2361#[cfg(test)]
2362mod tests {
2363 use super::*;
2364 use crate::command::{format_duration, parse_duration};
2365 use crate::parser::parse_script;
2366
2367 fn parse_err(script: &str) -> String {
2368 parse_script(script, lower_command)
2369 .expect_err("script must fail to parse")
2370 .to_string()
2371 }
2372
2373 #[test]
2374 fn malformed_with_io_binding_names_the_bad_binding() {
2375 let err = parse_err("WITH_IO [stdout=discard] ECHO \"test\"\n");
2376 assert!(err.contains("invalid syntax for command WITH_IO"), "{err}");
2377 assert!(!err.contains("unknown command"), "{err}");
2378 assert!(err.contains("stdout=discard"), "{err}");
2379 assert!(err.contains("pipe:<name>"), "{err}");
2380 }
2381
2382 #[test]
2383 fn await_without_task_variable_points_at_syntax() {
2384 let err = parse_err("AWAIT ECHO \"test\"\n");
2385 assert!(err.contains("invalid syntax for command AWAIT"), "{err}");
2386 assert!(!err.contains("unknown command"), "{err}");
2387 assert!(err.contains("AWAIT $t"), "{err}");
2388 assert!(err.contains("ECHO"), "{err}");
2389 }
2390
2391 #[test]
2392 fn bare_let_without_type_points_at_typed_syntax() {
2393 let err = parse_err("LET $x = 1\n");
2394 assert!(err.contains("invalid syntax for command LET"), "{err}");
2395 assert!(err.contains("LET $name: STRING = <expr>"), "{err}");
2396 }
2397
2398 #[test]
2399 fn unknown_type_tag_names_valid_inventory() {
2400 let err = parse_err("LET $x: FOO = 1\n");
2401 assert!(err.contains("unknown type `FOO`"), "{err}");
2402 assert!(err.contains("STRING"), "{err}");
2403 }
2404
2405 #[test]
2406 fn bare_for_without_types_is_rejected() {
2407 let err = parse_err("FOR $i IN [1] { ECHO hi }\n");
2408 assert!(err.contains("FOR requires explicit types"), "{err}");
2409 }
2410
2411 #[test]
2412 fn mutate_statement_parses_without_keyword() {
2413 let steps = parse_script("$y = 2\n", lower_command).expect("mutation parses");
2414 assert!(matches!(steps[0].kind, StepKind::Set { .. }));
2415 }
2416
2417 #[test]
2418 fn set_keyword_is_rejected_with_mutation_hint() {
2419 let err = parse_err("SET $y = 2\n");
2420 assert!(err.contains("not a keyword"), "{err}");
2421 assert!(err.contains("$var = <expr>"), "{err}");
2422 }
2423
2424 #[test]
2425 fn structural_fallthrough_commits_per_keyword() {
2426 for (script, cmd) in [
2427 ("CANCEL foo\n", "CANCEL"),
2428 ("TIMEOUT foo\n", "TIMEOUT"),
2429 ("FOR foo\n", "FOR"),
2430 ("IF foo\n", "IF"),
2431 ("LET foo\n", "LET"),
2432 ("ASYNC\n", "ASYNC"),
2436 ("ELSE foo\n", "ELSE"),
2437 ] {
2438 let err = parse_err(script);
2439 assert!(
2440 err.contains(&format!("invalid syntax for command {cmd}")),
2441 "{cmd}: {err}"
2442 );
2443 assert!(!err.contains("unknown command"), "{cmd}: {err}");
2444 }
2445 }
2446
2447 #[test]
2448 fn leaf_arity_errors_carry_invalid_syntax_prefix() {
2449 let err = parse_err("SLEEP 1s 2s\n");
2450 assert!(err.contains("invalid syntax for command SLEEP"), "{err}");
2451 assert!(!err.contains("unknown command"), "{err}");
2452 }
2453
2454 #[test]
2455 fn genuinely_unknown_command_keeps_bare_message() {
2456 let err = parse_err("FROBNICATE hi\n");
2457 assert!(err.contains("unknown command: FROBNICATE"), "{err}");
2458 assert!(!err.contains("did you mean"), "{err}");
2459 }
2460
2461 #[test]
2462 fn lowercase_command_suggests_uppercase() {
2463 let err = lower_command("echo", vec![Arg::String("hi".to_string(), false)])
2467 .expect_err("must fail")
2468 .to_string();
2469 assert!(err.contains("unknown command: echo"), "{err}");
2470 assert!(err.contains("did you mean `ECHO`"), "{err}");
2471 }
2472
2473 #[test]
2474 fn func_def_requires_typed_uppercase_name() {
2475 let steps = parse_script(
2476 "FUNC GREET($name: STRING) {\n RETURN $name\n}\n",
2477 lower_command,
2478 )
2479 .expect("func def parses");
2480 let StepKind::FuncDef { name, params, body } = &steps[0].kind else {
2481 panic!("expected FuncDef, got {:?}", steps[0].kind);
2482 };
2483 assert_eq!(name, "GREET");
2484 assert_eq!(
2485 params,
2486 &vec![("name".to_string(), TypeKind::String)],
2487 "{params:?}"
2488 );
2489 assert!(matches!(body[0].kind, StepKind::Return { .. }));
2490 }
2491
2492 #[test]
2493 fn lowercase_func_name_is_rejected() {
2494 let err = parse_err("FUNC greet($x: STRING) {\n RETURN $x\n}\n");
2495 assert!(err.contains("FUNC"), "{err}");
2496 }
2497
2498 #[test]
2499 fn call_and_while_lower_correctly() {
2500 let steps = parse_script("CALL GREET(\"ada\")\n", lower_command).expect("call parses");
2501 assert!(
2502 matches!(&steps[0].kind, StepKind::Call { name, .. } if name == "GREET"),
2503 "{:?}",
2504 steps[0].kind
2505 );
2506 let steps =
2507 parse_script("WHILE !$done {\n BREAK\n}\n", lower_command).expect("while parses");
2508 let StepKind::While { body, .. } = &steps[0].kind else {
2509 panic!("expected While, got {:?}", steps[0].kind);
2510 };
2511 assert!(matches!(body[0].kind, StepKind::Break));
2512 }
2513
2514 #[test]
2515 fn let_capture_call_and_async_call_lower() {
2516 let steps = parse_script("LET $r: STRING = CALL GREET(\"ada\")\n", lower_command)
2517 .expect("capture call parses");
2518 let StepKind::AssignCapture { var, cmd, .. } = &steps[0].kind else {
2519 panic!("expected AssignCapture, got {:?}", steps[0].kind);
2520 };
2521 assert_eq!(var, "r");
2522 assert!(matches!(&**cmd, StepKind::Call { .. }), "{cmd:?}");
2523 let steps = parse_script("LET $t: HANDLE = ASYNC CALL GREET(\"a\")\n", lower_command)
2524 .expect("async call parses");
2525 assert!(
2526 matches!(&steps[0].kind, StepKind::AssignAsync { .. }),
2527 "{:?}",
2528 steps[0].kind
2529 );
2530 }
2531
2532 #[test]
2533 fn parse_duration_units() {
2534 use std::time::Duration;
2535 assert_eq!(parse_duration("500ms").unwrap(), Duration::from_millis(500));
2536 assert_eq!(parse_duration("10s").unwrap(), Duration::from_secs(10));
2537 assert_eq!(parse_duration("2m").unwrap(), Duration::from_secs(120));
2538 assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
2539 assert_eq!(parse_duration("30").unwrap(), Duration::from_secs(30));
2540 }
2541
2542 #[test]
2543 fn parse_duration_rejects_garbage() {
2544 assert!(parse_duration("").is_err());
2545 assert!(parse_duration("banana").is_err());
2546 assert!(parse_duration("10x").is_err());
2547 assert!(parse_duration("0s").is_err());
2548 assert!(parse_duration("0").is_err());
2549 assert!(parse_duration("-5s").is_err());
2550 }
2551
2552 #[test]
2553 fn format_duration_round_trips() {
2554 for text in ["500ms", "10s", "2m", "1h", "90s", "1500ms"] {
2555 let parsed = parse_duration(text).unwrap();
2556 let rendered = format_duration(&parsed);
2557 assert_eq!(
2558 parse_duration(&rendered).unwrap(),
2559 parsed,
2560 "round-trip failed for {text}"
2561 );
2562 }
2563 assert_eq!(format_duration(&parse_duration("90s").unwrap()), "90s");
2564 assert_eq!(format_duration(&parse_duration("2m").unwrap()), "2m");
2565 }
2566
2567 #[test]
2568 fn structural_metadata_covers_all_structural_kinds() {
2569 use crate::ast::Value;
2570
2571 fn metadata_name(kind: &StepKind) -> Option<&'static str> {
2575 match kind {
2576 StepKind::WithIo { .. } | StepKind::WithIoBlock { .. } => Some("WITH_IO"),
2577 StepKind::For { .. } => Some("FOR"),
2578 StepKind::If { .. } => Some("IF"),
2579 StepKind::Assign { .. } => Some("LET"),
2580 StepKind::Set { .. } => Some("MUTATION"),
2581 StepKind::AssignCapture { .. } => Some("LET"),
2582 StepKind::AwaitCapture { .. } => Some("AWAIT"),
2583 StepKind::AsyncBlock { .. } | StepKind::AssignAsync { .. } => Some("ASYNC"),
2584 StepKind::Await { .. } => Some("AWAIT"),
2585 StepKind::Cancel { .. } => Some("CANCEL"),
2586 StepKind::Timeout { .. } => Some("TIMEOUT"),
2587 StepKind::FuncDef { .. } => Some("FUNC"),
2588 StepKind::Call { .. } => Some("CALL"),
2589 StepKind::Return { .. } => Some("RETURN"),
2590 StepKind::While { .. } => Some("WHILE"),
2591 StepKind::Break => Some("BREAK"),
2592 StepKind::Continue => Some("CONTINUE"),
2593 StepKind::RunExec { .. } => None,
2594 StepKind::Workdir(_)
2595 | StepKind::Workspace(_)
2596 | StepKind::Env { .. }
2597 | StepKind::InheritEnv { .. }
2598 | StepKind::Run(_)
2599 | StepKind::Echo(_)
2600 | StepKind::Copy { .. }
2601 | StepKind::Symlink { .. }
2602 | StepKind::Mkdir(_)
2603 | StepKind::Ls(_)
2604 | StepKind::Cwd
2605 | StepKind::Read(_)
2606 | StepKind::ReadLine { .. }
2607 | StepKind::Write { .. }
2608 | StepKind::Append { .. }
2609 | StepKind::Expand { .. }
2610 | StepKind::AssertEq { .. }
2611 | StepKind::AssertContains { .. }
2612 | StepKind::CopyGit { .. }
2613 | StepKind::HashSha256 { .. }
2614 | StepKind::Exit(_)
2615 | StepKind::Sleep { .. } => None,
2616 }
2617 }
2618
2619 let dummies: Vec<StepKind> = vec![
2622 StepKind::WithIo {
2623 bindings: Vec::new(),
2624 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2625 "x".to_string(),
2626 false,
2627 ))),
2628 },
2629 StepKind::For {
2630 key_var: None,
2631 key_type: None,
2632 var: "i".to_string(),
2633 var_type: TypeKind::String,
2634 in_expr: Expr::Literal(Value::Bool(true)),
2635 body: Vec::new(),
2636 },
2637 StepKind::If {
2638 cond: Box::new(Expr::Literal(Value::Bool(true))),
2639 then_body: Vec::new(),
2640 else_ifs: Vec::new(),
2641 else_body: None,
2642 },
2643 StepKind::Assign {
2644 var: "v".to_string(),
2645 decl_type: TypeKind::Bool,
2646 expr: Expr::Literal(Value::Bool(true)),
2647 },
2648 StepKind::Set {
2649 var: "v".to_string(),
2650 expr: Expr::Literal(Value::Bool(true)),
2651 },
2652 StepKind::AssignCapture {
2653 var: "v".to_string(),
2654 decl_type: TypeKind::String,
2655 cmd: Box::new(StepKind::Echo(crate::ast::Arg::String(
2656 "x".to_string(),
2657 false,
2658 ))),
2659 },
2660 StepKind::AwaitCapture {
2661 out_var: "o".to_string(),
2662 out_type: TypeKind::String,
2663 task_var: "t".to_string(),
2664 },
2665 StepKind::AsyncBlock { body: Vec::new() },
2666 StepKind::AssignAsync {
2667 var: "t".to_string(),
2668 decl_type: TypeKind::Handle,
2669 body: Vec::new(),
2670 },
2671 StepKind::Await {
2672 var: "t".to_string(),
2673 },
2674 StepKind::Cancel {
2675 var: "t".to_string(),
2676 },
2677 StepKind::Timeout {
2678 duration: Arg::String("1s".to_string(), false),
2679 body: Vec::new(),
2680 },
2681 StepKind::FuncDef {
2682 name: "F".to_string(),
2683 params: Vec::new(),
2684 body: Vec::new(),
2685 },
2686 StepKind::Call {
2687 name: "F".to_string(),
2688 args: Vec::new(),
2689 },
2690 StepKind::Return {
2691 expr: Box::new(Expr::Literal(Value::Bool(true))),
2692 },
2693 StepKind::While {
2694 cond: Box::new(Expr::Literal(Value::Bool(true))),
2695 body: Vec::new(),
2696 },
2697 StepKind::Break,
2698 StepKind::Continue,
2699 ];
2700 let registry = all_structural_metadata();
2701 for kind in &dummies {
2702 let name = metadata_name(kind).expect("structural kind must map to metadata");
2703 assert!(
2704 registry.iter().any(|meta| meta.name == name),
2705 "no structural metadata entry for {}",
2706 name
2707 );
2708 }
2709 }
2710
2711 #[test]
2712 fn verify_display_sync_with_metadata() {
2713 fn step_contains_kind(kind: &StepKind, name: &str) -> bool {
2714 if kind.to_string().starts_with(name) {
2715 return true;
2716 }
2717 let bodies: Vec<&Vec<Step>> = match kind {
2718 StepKind::For { body, .. }
2719 | StepKind::While { body, .. }
2720 | StepKind::FuncDef { body, .. }
2721 | StepKind::Timeout { body, .. }
2722 | StepKind::AssignAsync { body, .. }
2723 | StepKind::AsyncBlock { body } => vec![body],
2724 StepKind::If {
2725 then_body,
2726 else_ifs,
2727 else_body,
2728 ..
2729 } => {
2730 let mut out = vec![then_body];
2731 out.extend(else_ifs.iter().map(|(_, b)| b));
2732 out.extend(else_body.iter());
2733 out
2734 }
2735 _ => {
2736 if let StepKind::WithIo { cmd, .. } = kind {
2737 return step_contains_kind(cmd, name);
2738 }
2739 if let StepKind::AssignCapture { cmd, .. } = kind {
2740 return step_contains_kind(cmd, name);
2741 }
2742 return false;
2743 }
2744 };
2745 bodies
2746 .iter()
2747 .any(|body| body.iter().any(|s| step_contains_kind(&s.kind, name)))
2748 }
2749
2750 let registry = all_metadata();
2751 for meta in registry {
2752 if meta.examples.is_empty() {
2753 continue;
2754 }
2755
2756 let code = meta.examples[0].code;
2757 let ast = parse_script(code, lower_command)
2758 .unwrap_or_else(|e| panic!("Failed to parse example for {}: {}", meta.name, e));
2759
2760 let matching = ast.iter().find(|step| {
2761 if meta.name == "MUTATION" {
2764 return matches!(step.kind, StepKind::Set { .. });
2765 }
2766 if matches!(meta.name, "RETURN" | "BREAK" | "CONTINUE") {
2770 return step_contains_kind(&step.kind, meta.name);
2771 }
2772 let kind = match &step.kind {
2773 StepKind::WithIo { cmd, .. } => &**cmd,
2774 other => other,
2775 };
2776 kind.to_string().starts_with(meta.name)
2780 || step.kind.to_string().starts_with(meta.name)
2781 });
2782
2783 assert!(
2784 matching.is_some(),
2785 "No step in example for {} produces Display starting with {}",
2786 meta.name,
2787 meta.name
2788 );
2789 }
2790 }
2791}