1use std::collections::HashMap;
2use std::sync::Arc;
3
4pub use crate::commands::{AssertTarget, StepKind};
5
6#[derive(Copy, Clone, Debug, Eq, PartialEq)]
7pub enum Command {
8 InheritEnv,
9 Workdir,
10 Workspace,
11 Env,
12 Echo,
13 Run,
14 Copy,
15 WithIo,
16 CopyGit,
17 HashSha256,
18 Symlink,
19 Mkdir,
20 Ls,
21 Cwd,
22 Read,
23 ReadLine,
24 Write,
25 Append,
26 Expand,
27 AssertEq,
28 AssertContains,
29 Exit,
30 Async,
31 Timeout,
32 Sleep,
33}
34
35pub const COMMANDS: &[Command] = &[
36 Command::InheritEnv,
37 Command::Workdir,
38 Command::Workspace,
39 Command::Env,
40 Command::Echo,
41 Command::Run,
42 Command::Copy,
43 Command::WithIo,
44 Command::CopyGit,
45 Command::HashSha256,
46 Command::Symlink,
47 Command::Mkdir,
48 Command::Ls,
49 Command::Cwd,
50 Command::Read,
51 Command::ReadLine,
52 Command::Write,
53 Command::Append,
54 Command::Expand,
55 Command::AssertEq,
56 Command::AssertContains,
57 Command::Exit,
58 Command::Timeout,
59 Command::Sleep,
60];
61
62impl Command {
63 pub const fn as_str(self) -> &'static str {
64 match self {
65 Command::InheritEnv => "INHERIT_ENV",
66 Command::Workdir => "WORKDIR",
67 Command::Workspace => "WORKSPACE",
68 Command::Env => "ENV",
69 Command::Echo => "ECHO",
70 Command::Run => "RUN",
71 Command::Copy => "COPY",
72 Command::WithIo => "WITH_IO",
73 Command::CopyGit => "COPY_GIT",
74 Command::HashSha256 => "HASH_SHA256",
75 Command::Symlink => "SYMLINK",
76 Command::Mkdir => "MKDIR",
77 Command::Ls => "LS",
78 Command::Cwd => "CWD",
79 Command::Read => "READ",
80 Command::ReadLine => "READ_LINE",
81 Command::Write => "WRITE",
82 Command::Append => "APPEND",
83 Command::Expand => "EXPAND",
84 Command::AssertEq => "ASSERT_EQ",
85 Command::AssertContains => "ASSERT_CONTAINS",
86 Command::Exit => "EXIT",
87 Command::Async => "ASYNC",
88 Command::Timeout => "TIMEOUT",
89 Command::Sleep => "SLEEP",
90 }
91 }
92
93 pub const fn syntax(self) -> &'static str {
94 match self {
95 Command::InheritEnv => "INHERIT_ENV [KEY1, KEY2, ...]",
96 Command::Workdir => "WORKDIR <path>",
97 Command::Workspace => "WORKSPACE SNAPSHOT|LOCAL",
98 Command::Env => "ENV KEY=value",
99 Command::Echo => "ECHO <message>",
100 Command::Run => "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
101 Command::Copy => "COPY [--from-current-workspace] <from> <to>",
102 Command::CopyGit => "COPY_GIT [--include-dirty] <rev> <src> <dst>",
103 Command::WithIo => "WITH_IO [bindings] [command | { block }]",
104 Command::HashSha256 => "HASH_SHA256 <path>",
105 Command::Symlink => "SYMLINK <from> <to>",
106 Command::Mkdir => "MKDIR <path>",
107 Command::Ls => "LS [<path>]",
108 Command::Cwd => "CWD",
109 Command::Read => "READ [<path>]",
110 Command::ReadLine => "READ_LINE $var",
111 Command::Write => "WRITE <path> [<contents>]",
112 Command::Append => "APPEND <path> [<contents>]",
113 Command::Expand => "EXPAND [<path>] [<KEY=val> ...]",
114 Command::AssertEq => "ASSERT_EQ [--hash <sha256>] <actual> <expected>",
115 Command::AssertContains => "ASSERT_CONTAINS <haystack> <needle>",
116 Command::Exit => "EXIT <code>",
117 Command::Async => "ASYNC <command...> | ASYNC { <commands> }",
118 Command::Timeout => {
119 "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> }"
120 }
121 Command::Sleep => "SLEEP <duration>",
122 }
123 }
124
125 pub const fn expects_inner_command(self) -> bool {
126 matches!(self, Command::WithIo | Command::Async | Command::Timeout)
127 }
128
129 pub fn parse(s: &str) -> Option<Self> {
130 match s {
131 "INHERIT_ENV" => Some(Command::InheritEnv),
132 "WORKDIR" => Some(Command::Workdir),
133 "WORKSPACE" => Some(Command::Workspace),
134 "ENV" => Some(Command::Env),
135 "ECHO" => Some(Command::Echo),
136 "RUN" => Some(Command::Run),
137 "COPY" => Some(Command::Copy),
138 "WITH_IO" => Some(Command::WithIo),
139 "COPY_GIT" => Some(Command::CopyGit),
140 "HASH_SHA256" => Some(Command::HashSha256),
141 "SYMLINK" => Some(Command::Symlink),
142 "MKDIR" => Some(Command::Mkdir),
143 "LS" => Some(Command::Ls),
144 "CWD" => Some(Command::Cwd),
145 "READ" => Some(Command::Read),
146 "READ_LINE" => Some(Command::ReadLine),
147 "WRITE" => Some(Command::Write),
148 "APPEND" => Some(Command::Append),
149 "EXPAND" => Some(Command::Expand),
150 "ASSERT_EQ" => Some(Command::AssertEq),
151 "ASSERT_CONTAINS" => Some(Command::AssertContains),
152 "EXIT" => Some(Command::Exit),
153 "ASYNC" => Some(Command::Async),
154 "TIMEOUT" => Some(Command::Timeout),
155 "SLEEP" => Some(Command::Sleep),
156 _ => None,
157 }
158 }
159
160 pub(crate) fn is_statement_keyword(s: &str) -> bool {
165 Command::parse(s).is_some() || STRUCTURAL_KEYWORDS.contains(&s)
166 }
167}
168
169pub const STRUCTURAL_KEYWORDS: &[&str] = &[
175 "LET", "FOR", "IF", "ELSE", "ASYNC", "AWAIT", "CANCEL", "FUNC", "CALL", "RETURN", "WHILE",
176 "BREAK", "CONTINUE",
177];
178
179pub const CLAUSE_KEYWORDS: &[&str] = &["IN"];
184
185#[derive(Copy, Clone, Debug, Eq, PartialEq)]
186pub enum PlatformGuard {
187 Unix,
188 Windows,
189 Macos,
190 Linux,
191}
192
193#[derive(Debug, Clone, Eq, PartialEq)]
194pub enum Guard {
195 Platform { target: PlatformGuard },
196 EnvExists { key: String },
197 EnvEquals { key: String, value: String },
198 StaticBool { value: String },
199}
200
201#[derive(Debug, Clone, Eq, PartialEq)]
202pub enum GuardExpr {
203 Predicate(Guard),
204 All(Vec<GuardExpr>),
205 Or(Vec<GuardExpr>),
206 Not(Box<GuardExpr>),
207}
208
209impl GuardExpr {
210 pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
211 let mut flat = Vec::new();
212 for expr in exprs {
213 match expr {
214 GuardExpr::All(children) => flat.extend(children),
215 other => flat.push(other),
216 }
217 }
218 match flat.len() {
219 0 => panic!("GuardExpr::all requires at least one expression"),
220 1 => flat.into_iter().next().unwrap(),
221 _ => GuardExpr::All(flat),
222 }
223 }
224
225 pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
226 let mut flat = Vec::new();
227 for expr in exprs {
228 match expr {
229 GuardExpr::Or(children) => flat.extend(children),
230 other => flat.push(other),
231 }
232 }
233 match flat.len() {
234 0 => panic!("GuardExpr::or requires at least one expression"),
235 1 => flat.into_iter().next().unwrap(),
236 _ => GuardExpr::Or(flat),
237 }
238 }
239
240 pub fn invert(expr: GuardExpr) -> GuardExpr {
241 match expr {
242 GuardExpr::Not(inner) => *inner,
243 other => GuardExpr::Not(Box::new(other)),
244 }
245 }
246}
247
248impl std::ops::Not for GuardExpr {
249 type Output = GuardExpr;
250
251 fn not(self) -> GuardExpr {
252 match self {
253 GuardExpr::Not(inner) => *inner,
254 other => GuardExpr::Not(Box::new(other)),
255 }
256 }
257}
258
259impl From<Guard> for GuardExpr {
260 fn from(guard: Guard) -> Self {
261 GuardExpr::Predicate(guard)
262 }
263}
264
265#[derive(Debug, Clone, PartialEq)]
267pub enum Arg {
268 String(String, bool),
272 Expr(Expr),
274 Parts(Vec<ArgPart>),
278}
279
280#[derive(Debug, Clone, PartialEq)]
282pub enum ArgPart {
283 Text(String, bool),
286 Expr(Expr),
288}
289
290impl Arg {
291 pub fn as_str(&self) -> &str {
292 match self {
293 Arg::String(s, _) => s,
294 Arg::Expr(_) | Arg::Parts(_) => "",
297 }
298 }
299
300 pub fn render(&self) -> String {
304 match self {
305 Arg::String(s, _) => s.clone(),
306 Arg::Expr(e) => e.to_string(),
307 Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
308 }
309 }
310
311 pub fn is_quoted(&self) -> bool {
312 matches!(self, Arg::String(_, true))
313 }
314}
315
316impl ArgPart {
317 pub fn render(&self) -> String {
318 match self {
319 ArgPart::Text(s, _) => s.clone(),
320 ArgPart::Expr(e) => e.to_string(),
321 }
322 }
323}
324
325impl From<String> for Arg {
326 fn from(s: String) -> Self {
327 Arg::String(s, false)
328 }
329}
330
331impl From<&str> for Arg {
332 fn from(s: &str) -> Self {
333 Arg::String(s.to_string(), false)
334 }
335}
336
337impl std::fmt::Display for Arg {
338 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339 match self {
340 Arg::String(s, _) => write!(f, "{}", s),
341 Arg::Expr(e) => write!(f, "{}", e),
342 Arg::Parts(parts) => {
343 for part in parts {
344 match part {
345 ArgPart::Text(s, _) => write!(f, "{}", s)?,
346 ArgPart::Expr(e) => write!(f, "{}", e)?,
347 }
348 }
349 Ok(())
350 }
351 }
352 }
353}
354
355impl AsRef<str> for Arg {
356 fn as_ref(&self) -> &str {
357 self.as_str()
358 }
359}
360
361impl PartialEq<str> for Arg {
362 fn eq(&self, other: &str) -> bool {
363 self.as_str() == other
364 }
365}
366
367impl PartialEq<&str> for Arg {
368 fn eq(&self, other: &&str) -> bool {
369 self.as_str() == *other
370 }
371}
372
373#[derive(Debug, Clone, Copy, Eq, PartialEq)]
374pub enum IoStream {
375 Stdin,
376 Stdout,
377 Stderr,
378}
379
380#[derive(Debug, Clone, Eq, PartialEq)]
381pub struct IoBinding {
382 pub stream: IoStream,
383 pub pipe: Option<PipeTarget>,
384}
385
386#[derive(Debug, Clone, Eq, PartialEq)]
390pub enum PipeTarget {
391 Name(String),
392 Var(String),
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
396pub enum TypeKind {
397 String,
398 Int,
399 Float,
400 Bool,
401 Pipe,
402 List,
403 Map,
404 Handle,
405 Duration,
406 Path,
407}
408
409impl TypeKind {
410 pub const CANONICAL: &[TypeKind] = &[
411 TypeKind::String,
412 TypeKind::Int,
413 TypeKind::Float,
414 TypeKind::Bool,
415 TypeKind::Pipe,
416 TypeKind::List,
417 TypeKind::Map,
418 TypeKind::Handle,
419 TypeKind::Duration,
420 TypeKind::Path,
421 ];
422
423 pub fn label(&self) -> &'static str {
427 match self {
428 TypeKind::String => "STRING",
429 TypeKind::Int => "INT",
430 TypeKind::Float => "FLOAT",
431 TypeKind::Bool => "BOOL",
432 TypeKind::Pipe => "PIPE",
433 TypeKind::List => "LIST",
434 TypeKind::Map => "MAP",
435 TypeKind::Handle => "HANDLE",
436 TypeKind::Duration => "DURATION",
437 TypeKind::Path => "PATH",
438 }
439 }
440
441 pub fn doc(&self) -> Option<(String, &'static str)> {
444 let body = match self {
445 TypeKind::String => {
446 "Arbitrary text. Quotes keep exact bytes, lone `$var` evaluates, `{{ ... }}` interpolates."
447 }
448 TypeKind::Int => "64-bit signed integer, e.g. an exit code.",
449 TypeKind::Float => "64-bit float, e.g. a ratio.",
450 TypeKind::Bool => "Boolean `true` or `false`.",
451 TypeKind::Pipe => {
452 "Named script pipe. Validity is checked against the pipe registry at coercion time."
453 }
454 TypeKind::List => "Ordered list of values.",
455 TypeKind::Map => "String-keyed map of values.",
456 TypeKind::Handle => "Background ASYNC task handle for AWAIT/CANCEL.",
457 TypeKind::Duration => {
458 "Positive time span: `500ms`, `10s`, `2m`, `1h`; bare number means seconds."
459 }
460 TypeKind::Path => "Workspace path, resolved against cwd and guarded against escape.",
461 };
462 Some((format!("Value type: {}", self.label()), body))
463 }
464
465 pub fn anchor(&self) -> String {
469 format!("value-type-{}", self.label().to_lowercase())
470 }
471}
472
473impl std::str::FromStr for TypeKind {
474 type Err = anyhow::Error;
475 fn from_str(s: &str) -> Result<Self, Self::Err> {
476 if let Some(kind) = Self::CANONICAL.iter().find(|k| k.label() == s) {
477 return Ok(*kind);
478 }
479 let inventory = Self::CANONICAL
480 .iter()
481 .map(|k| k.label())
482 .collect::<Vec<_>>()
483 .join(", ");
484 anyhow::bail!("unknown type `{s}`; expected one of {inventory}")
485 }
486}
487
488impl std::fmt::Display for TypeKind {
489 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490 write!(f, "{}", self.label())
491 }
492}
493
494#[derive(Debug, Clone, PartialEq)]
495pub enum Value {
496 String(String),
497 Int(i64),
498 Float(f64),
499 List(Vec<Value>),
500 Map(std::collections::BTreeMap<String, Value>),
501 Bool(bool),
502 Pipe(String), Duration(std::time::Duration),
504 #[allow(clippy::disallowed_types)]
507 Path(std::path::PathBuf),
508 TaskHandle(u64),
511}
512
513#[derive(Debug, Clone, Copy, Eq, PartialEq)]
514pub enum CompareOp {
515 Eq,
516 Ne,
517 Lt,
518 Le,
519 Gt,
520 Ge,
521}
522
523#[derive(Debug, Clone, Copy, Eq, PartialEq)]
524pub enum ArithOp {
525 Add,
526 Sub,
527 Mul,
528 Div,
529}
530
531#[derive(Debug, Clone, PartialEq)]
540pub enum MathOp {
541 PushConst(Value),
542 LoadVar(String),
543 LoadEnv(String),
544 LoadKeyPath { base: String, keys: Vec<String> },
545 Call { name: String, arity: usize },
546 Inspect(String),
547 Neg,
548 Add,
549 Sub,
550 Mul,
551 Div,
552 Lt,
553 Le,
554 Gt,
555 Ge,
556 Eq,
557 Ne,
558}
559
560#[derive(Debug, Clone, Copy, Eq, PartialEq)]
561pub enum LogicalOp {
562 And,
563 Or,
564}
565
566#[derive(Debug, Clone, PartialEq)]
567pub enum Expr {
568 Literal(Value),
569 Var(String),
570 Env(String),
573 KeyPath {
574 base: String,
575 keys: Vec<String>,
576 },
577 List(Vec<Expr>),
578 Map(Vec<(String, Expr)>),
579 Call {
580 name: String,
581 args: Vec<Expr>,
582 },
583 Compare {
584 op: CompareOp,
585 left: Box<Expr>,
586 right: Box<Expr>,
587 },
588 Arithmetic {
589 op: ArithOp,
590 left: Box<Expr>,
591 right: Box<Expr>,
592 },
593 CompiledMath(Vec<MathOp>),
596 UnsignedIntBoundary(u64),
600 Not(Box<Expr>),
601 Logical {
602 op: LogicalOp,
603 left: Box<Expr>,
604 right: Box<Expr>,
605 },
606}
607
608#[derive(Debug, Clone, PartialEq)]
609pub struct Step {
610 pub guard: Option<GuardExpr>,
611 pub kind: StepKind,
612 pub scope_enter: usize,
613 pub scope_exit: usize,
614}
615
616#[derive(Debug, Clone, Eq, PartialEq)]
617pub enum WorkspaceTarget {
618 Snapshot,
619 Local,
620}
621
622fn platform_matches(target: PlatformGuard) -> bool {
623 #[allow(clippy::disallowed_macros)]
624 match target {
625 PlatformGuard::Unix => cfg!(unix),
626 PlatformGuard::Windows => cfg!(windows),
627 PlatformGuard::Macos => cfg!(target_os = "macos"),
628 PlatformGuard::Linux => cfg!(target_os = "linux"),
629 }
630}
631
632pub trait EnvLookup {
633 fn get_env(&self, key: &str) -> Option<&str>;
634}
635
636impl EnvLookup for HashMap<String, String> {
637 fn get_env(&self, key: &str) -> Option<&str> {
638 self.get(key).map(|s| s.as_str())
639 }
640}
641
642impl EnvLookup for Arc<HashMap<String, String>> {
643 fn get_env(&self, key: &str) -> Option<&str> {
644 (**self).get_env(key)
645 }
646}
647
648pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
649 match guard {
650 Guard::Platform { target } => platform_matches(*target),
651 Guard::EnvExists { key } => env.get_env(key).map(|v| !v.is_empty()).unwrap_or(false),
652 Guard::EnvEquals { key, value } => env
653 .get_env(key)
654 .map(|v| v == value.as_str())
655 .unwrap_or(false),
656 Guard::StaticBool { value } => value.parse::<bool>().unwrap_or(false),
657 }
658}
659
660pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
661 match expr {
662 GuardExpr::Predicate(guard) => guard_allows(guard, env),
663 GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
664 GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
665 GuardExpr::Not(child) => !guard_expr_allows(child, env),
666 }
667}
668
669pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
670 match expr {
671 Some(e) => guard_expr_allows(e, env),
672 None => true,
673 }
674}
675
676use std::fmt;
677
678impl fmt::Display for PlatformGuard {
679 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
680 match self {
681 PlatformGuard::Unix => write!(f, "unix"),
682 PlatformGuard::Windows => write!(f, "windows"),
683 PlatformGuard::Macos => write!(f, "macos"),
684 PlatformGuard::Linux => write!(f, "linux"),
685 }
686 }
687}
688
689impl fmt::Display for Guard {
690 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691 match self {
692 Guard::Platform { target } => write!(f, "{}", target),
693 Guard::EnvExists { key } => write!(f, "env:{}", key),
694 Guard::EnvEquals { key, value } => write!(f, "eq(env:{}, {})", key, value),
695 Guard::StaticBool { value } => write!(f, "bool:{}", value),
696 }
697 }
698}
699
700impl fmt::Display for WorkspaceTarget {
701 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
702 match self {
703 WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
704 WorkspaceTarget::Local => write!(f, "LOCAL"),
705 }
706 }
707}
708
709impl fmt::Display for Value {
710 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
711 match self {
712 Value::String(s) => write!(f, "\"{}\"", s),
713 Value::Int(i) => write!(f, "{}", i),
714 Value::Float(v) => write!(f, "{}", v),
715 Value::Pipe(n) => write!(f, "pipe:{}", n),
716 Value::Duration(d) => write!(f, "{}", crate::command::format_duration(d)),
717 Value::Path(p) => write!(f, "{}", p.display()),
718 Value::List(items) => {
719 write!(f, "[")?;
720 for (i, item) in items.iter().enumerate() {
721 if i > 0 {
722 write!(f, ", ")?;
723 }
724 write!(f, "{}", item)?;
725 }
726 write!(f, "]")
727 }
728 Value::Map(map) => {
729 write!(f, "{{")?;
730 for (i, (k, v)) in map.iter().enumerate() {
731 if i > 0 {
732 write!(f, ", ")?;
733 }
734 write!(f, "{}: {}", k, v)?;
735 }
736 write!(f, "}}")
737 }
738 Value::Bool(b) => write!(f, "{}", b),
739 Value::TaskHandle(id) => write!(f, "task#{}", id),
740 }
741 }
742}
743
744impl fmt::Display for Expr {
745 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
746 match self {
747 Expr::Literal(v) => write!(f, "{}", v),
748 Expr::Var(name) => write!(f, "${}", name),
749 Expr::Env(key) => write!(f, "env:{}", key),
750 Expr::KeyPath { base, keys } => {
751 write!(f, "${}", base)?;
752 for key in keys {
753 write!(f, ".{}", key)?;
754 }
755 Ok(())
756 }
757 Expr::Call { name, args } => {
758 write!(f, "{}(", name)?;
759 for (i, arg) in args.iter().enumerate() {
760 if i > 0 {
761 write!(f, ", ")?;
762 }
763 write!(f, "{}", arg)?;
764 }
765 write!(f, ")")
766 }
767 Expr::List(items) => {
768 write!(f, "[")?;
769 for (i, item) in items.iter().enumerate() {
770 if i > 0 {
771 write!(f, ", ")?;
772 }
773 write!(f, "{}", item)?;
774 }
775 write!(f, "]")
776 }
777 Expr::Map(entries) => {
778 write!(f, "{{")?;
779 for (i, (key, val)) in entries.iter().enumerate() {
780 if i > 0 {
781 write!(f, ", ")?;
782 }
783 write!(f, "\"{}\": {}", key, val)?;
784 }
785 write!(f, "}}")
786 }
787 Expr::Compare { op, left, right } => {
788 write!(f, "{} {} {}", left, op, right)
789 }
790 Expr::Arithmetic { op, left, right } => {
791 write!(f, "({} {} {})", left, op, right)
792 }
793 Expr::CompiledMath(ops) => {
794 write!(f, "{}", format_compiled_math(ops))
795 }
796 Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
797 Expr::Not(inner) => {
798 match inner.as_ref() {
801 Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
802 write!(f, "!({})", inner)
803 }
804 _ => write!(f, "!{}", inner),
805 }
806 }
807 Expr::Logical { op, left, right } => {
808 write!(f, "({} {} {})", left, op, right)
809 }
810 }
811 }
812}
813
814impl fmt::Display for CompareOp {
815 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
816 match self {
817 CompareOp::Eq => write!(f, "=="),
818 CompareOp::Ne => write!(f, "!="),
819 CompareOp::Lt => write!(f, "<"),
820 CompareOp::Le => write!(f, "<="),
821 CompareOp::Gt => write!(f, ">"),
822 CompareOp::Ge => write!(f, ">="),
823 }
824 }
825}
826
827impl fmt::Display for ArithOp {
828 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
829 match self {
830 ArithOp::Add => write!(f, "+"),
831 ArithOp::Sub => write!(f, "-"),
832 ArithOp::Mul => write!(f, "*"),
833 ArithOp::Div => write!(f, "/"),
834 }
835 }
836}
837
838fn format_compiled_math(ops: &[MathOp]) -> String {
843 let mut stack: Vec<String> = Vec::new();
844 for op in ops {
845 match op {
846 MathOp::PushConst(v) => stack.push(format!("{}", v)),
847 MathOp::LoadVar(name) => stack.push(format!("${}", name)),
848 MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
849 MathOp::LoadKeyPath { base, keys } => {
850 let mut s = format!("${}", base);
851 for key in keys {
852 s.push('.');
853 s.push_str(key);
854 }
855 stack.push(s);
856 }
857 MathOp::Call { name, arity } => {
858 let mut args = Vec::new();
859 for _ in 0..*arity {
860 args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
861 }
862 args.reverse();
863 stack.push(format!("{}({})", name, args.join(", ")));
864 }
865 MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
866 MathOp::Neg => {
867 let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
868 stack.push(format!("(-{})", inner));
869 }
870 MathOp::Add => push_bin(&mut stack, "+"),
871 MathOp::Sub => push_bin(&mut stack, "-"),
872 MathOp::Mul => push_bin(&mut stack, "*"),
873 MathOp::Div => push_bin(&mut stack, "/"),
874 MathOp::Lt => push_bin(&mut stack, "<"),
875 MathOp::Le => push_bin(&mut stack, "<="),
876 MathOp::Gt => push_bin(&mut stack, ">"),
877 MathOp::Ge => push_bin(&mut stack, ">="),
878 MathOp::Eq => push_bin(&mut stack, "=="),
879 MathOp::Ne => push_bin(&mut stack, "!="),
880 }
881 }
882 if stack.len() == 1 {
883 let mut items = stack;
884 items.pop().unwrap_or_else(|| "<empty>".to_string())
885 } else {
886 stack.join(" ")
887 }
888}
889
890fn push_bin(stack: &mut Vec<String>, op: &str) {
891 let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
892 let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
893 stack.push(format!("({} {} {})", left, op, right));
894}
895
896impl fmt::Display for LogicalOp {
897 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
898 match self {
899 LogicalOp::And => write!(f, "&&"),
900 LogicalOp::Or => write!(f, "||"),
901 }
902 }
903}
904
905enum GuardDisplayContext {
906 Root,
907 InAnyArg,
908 InNot,
909 InAll,
910}
911
912impl GuardExpr {
913 fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
914 match self {
915 GuardExpr::Predicate(guard) => write!(f, "{}", guard),
916 GuardExpr::All(children) => {
917 let wrap = matches!(
918 ctx,
919 GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
920 ) && children.len() > 1;
921 if wrap {
922 write!(f, "(")?;
923 }
924 for (i, child) in children.iter().enumerate() {
925 if i > 0 {
926 write!(f, ", ")?;
927 }
928 child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
929 }
930 if wrap {
931 write!(f, ")")?;
932 }
933 Ok(())
934 }
935 GuardExpr::Or(children) => {
936 write!(f, "any(")?;
937 for (i, child) in children.iter().enumerate() {
938 if i > 0 {
939 write!(f, ", ")?;
940 }
941 child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
942 }
943 write!(f, ")")
944 }
945 GuardExpr::Not(child) => {
946 write!(f, "not(")?;
947 child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
948 write!(f, ")")
949 }
950 }
951 }
952}
953
954impl fmt::Display for GuardExpr {
955 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
956 self.fmt_with_ctx(f, GuardDisplayContext::Root)
957 }
958}
959
960impl fmt::Display for Step {
961 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
962 if let Some(expr) = &self.guard {
963 write!(f, "[{}] ", expr)?;
964 }
965 write!(f, "{}", self.kind)
966 }
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 #[test]
985 fn statement_keywords_match_pest_grammar() {
986 use pest_meta::{ast::Expr, parser};
987 use std::collections::{HashMap, HashSet};
988
989 let pest_src = include_str!("dsl.pest");
990 let pairs = parser::parse(parser::Rule::grammar_rules, pest_src)
991 .expect("dsl.pest must parse as a pest grammar");
992 let rules = parser::consume_rules(pairs).expect("dsl.pest rules must consume");
993 let by_name: HashMap<&str, &Expr> = rules
994 .iter()
995 .map(|rule| (rule.name.as_str(), &rule.expr))
996 .collect();
997
998 let mut all_literals = HashSet::new();
999 for rule in &rules {
1000 for node in rule.expr.iter_top_down() {
1001 match node {
1002 Expr::Str(literal) | Expr::Insens(literal) => {
1003 all_literals.insert(literal.clone());
1004 }
1005 _ => {}
1006 }
1007 }
1008 }
1009 for kw in STRUCTURAL_KEYWORDS {
1010 assert!(
1011 all_literals.contains(*kw),
1012 "keyword {kw} in STRUCTURAL_KEYWORDS missing from dsl.pest"
1013 );
1014 }
1015
1016 let mut seen = HashSet::new();
1017 let mut stack = vec!["element".to_string(), "block_element".to_string()];
1018 let mut stmt_literals = HashSet::new();
1019 while let Some(name) = stack.pop() {
1020 if !seen.insert(name.clone()) {
1021 continue;
1022 }
1023 let Some(expr) = by_name.get(name.as_str()) else {
1024 continue;
1025 };
1026 for node in expr.iter_top_down() {
1027 match node {
1028 Expr::Str(literal) | Expr::Insens(literal) => {
1029 stmt_literals.insert(literal.clone());
1030 }
1031 Expr::Ident(dependency) => stack.push(dependency.clone()),
1032 _ => {}
1033 }
1034 }
1035 }
1036 assert!(
1037 seen.contains("element") && seen.contains("block_element"),
1038 "grammar must define element and block_element instruction rules"
1039 );
1040 for kw in stmt_literals {
1041 if kw.len() > 1 && kw.chars().all(|c| c.is_ascii_uppercase()) {
1042 assert!(
1043 crate::Command::is_statement_keyword(&kw)
1044 || CLAUSE_KEYWORDS.contains(&kw.as_str()),
1045 "uppercase literal \"{kw}\" reachable from dsl.pest instruction rules is not a registered statement or clause keyword"
1046 );
1047 }
1048 }
1049 }
1050}