1use std::collections::{HashMap, HashSet};
2use std::fmt;
3use std::sync::Arc;
4
5pub use crate::commands::{AssertTarget, StepKind};
6use crate::constants::{
7 KEYWORD_ASYNC, KEYWORD_AWAIT, KEYWORD_BREAK, KEYWORD_CANCEL, KEYWORD_CONTINUE, KEYWORD_ELSE,
8 KEYWORD_EXPORT, KEYWORD_FOR, KEYWORD_FUNC, KEYWORD_IF, KEYWORD_IMPORT, KEYWORD_LET,
9 KEYWORD_RETURN, KEYWORD_WHILE,
10};
11
12#[derive(Debug, Clone, Default)]
16pub struct ModuleFuncs {
17 pub functions: HashSet<String>,
19}
20
21#[derive(Debug, Clone, Default)]
26pub struct ModuleTable {
27 pub modules: HashMap<String, Option<ModuleFuncs>>,
28}
29
30impl ModuleTable {
31 pub fn reserved_base_names(&self) -> HashSet<String> {
35 let mut out = HashSet::new();
36 for funcs in self.modules.values().flatten() {
37 out.extend(funcs.functions.iter().cloned());
38 }
39 out
40 }
41}
42
43#[derive(Copy, Clone, Debug, Eq, PartialEq)]
51pub enum Command {
52 InheritEnv,
53 Workdir,
54 Workspace,
55 Env,
56 Echo,
57 Run,
58 Copy,
59 WithIo,
60 CopyGit,
61 HashSha256,
62 Symlink,
63 Mkdir,
64 Ls,
65 Cwd,
66 Read,
67 ReadLine,
68 Write,
69 Append,
70 Expand,
71 AssertEq,
72 AssertContains,
73 Exit,
74 Async,
75 Timeout,
76 Sleep,
77 ListAppend,
78}
79
80pub const COMMANDS: &[Command] = &[
81 Command::InheritEnv,
82 Command::Workdir,
83 Command::Workspace,
84 Command::Env,
85 Command::Echo,
86 Command::Run,
87 Command::Copy,
88 Command::WithIo,
89 Command::CopyGit,
90 Command::HashSha256,
91 Command::Symlink,
92 Command::Mkdir,
93 Command::Ls,
94 Command::Cwd,
95 Command::Read,
96 Command::ReadLine,
97 Command::Write,
98 Command::Append,
99 Command::Expand,
100 Command::AssertEq,
101 Command::AssertContains,
102 Command::Exit,
103 Command::Timeout,
104 Command::Sleep,
105 Command::ListAppend,
106];
107
108pub const COMMAND_CATEGORIES: &[&str] =
113 &["INHERIT", "WITH", "COPY", "HASH", "READ", "ASSERT", "LIST"];
114
115impl Command {
116 pub const fn as_str(self) -> &'static str {
117 match self {
118 Command::InheritEnv => "INHERIT_ENV",
119 Command::Workdir => "WORKDIR",
120 Command::Workspace => "WORKSPACE",
121 Command::Env => "ENV",
122 Command::Echo => "ECHO",
123 Command::Run => "RUN",
124 Command::Copy => "COPY",
125 Command::WithIo => "WITH_IO",
126 Command::CopyGit => "COPY_GIT",
127 Command::HashSha256 => "HASH_SHA256",
128 Command::Symlink => "SYMLINK",
129 Command::Mkdir => "MKDIR",
130 Command::Ls => "LS",
131 Command::Cwd => "CWD",
132 Command::Read => "READ",
133 Command::ReadLine => "READ_LINE",
134 Command::Write => "WRITE",
135 Command::Append => "APPEND",
136 Command::Expand => "EXPAND",
137 Command::AssertEq => "ASSERT_EQ",
138 Command::AssertContains => "ASSERT_CONTAINS",
139 Command::Exit => "EXIT",
140 Command::Async => "ASYNC",
141 Command::Timeout => "TIMEOUT",
142 Command::Sleep => "SLEEP",
143 Command::ListAppend => "LIST_APPEND",
144 }
145 }
146
147 pub const fn syntax(self) -> &'static str {
148 match self {
149 Command::InheritEnv => "INHERIT_ENV [KEY1, KEY2, ...]",
150 Command::Workdir => "WORKDIR <path>",
151 Command::Workspace => "WORKSPACE SNAPSHOT|LOCAL",
152 Command::Env => "ENV KEY=value",
153 Command::Echo => "ECHO <message>",
154 Command::Run => "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
155 Command::Copy => "COPY [--from-current-workspace] <from> <to>",
156 Command::CopyGit => "COPY_GIT [--include-dirty] <rev> <src> <dst>",
157 Command::WithIo => "WITH_IO [bindings] [command | { block }]",
158 Command::HashSha256 => "HASH_SHA256 <path>",
159 Command::Symlink => "SYMLINK <from> <to>",
160 Command::Mkdir => "MKDIR <path>",
161 Command::Ls => "LS [<path>]",
162 Command::Cwd => "CWD",
163 Command::Read => "READ [<path>]",
164 Command::ReadLine => "READ_LINE $var",
165 Command::Write => "WRITE <path> [<contents>]",
166 Command::Append => "APPEND <path> [<contents>]",
167 Command::Expand => "EXPAND [<path>] [<KEY=val> ...]",
168 Command::AssertEq => "ASSERT_EQ [--hash <sha256>] <actual> <expected>",
169 Command::AssertContains => "ASSERT_CONTAINS <haystack> <needle>",
170 Command::Exit => "EXIT <code>",
171 Command::Async => "ASYNC <command...> | ASYNC { <commands> }",
172 Command::Timeout => {
173 "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> }"
174 }
175 Command::Sleep => "SLEEP <duration>",
176 Command::ListAppend => "LIST_APPEND $list <item>",
177 }
178 }
179
180 pub const fn expects_inner_command(self) -> bool {
181 matches!(self, Command::WithIo | Command::Async | Command::Timeout)
182 }
183
184 pub fn parse(s: &str) -> Option<Self> {
185 match s {
186 "INHERIT_ENV" => Some(Command::InheritEnv),
187 "WORKDIR" => Some(Command::Workdir),
188 "WORKSPACE" => Some(Command::Workspace),
189 "ENV" => Some(Command::Env),
190 "ECHO" => Some(Command::Echo),
191 "RUN" => Some(Command::Run),
192 "COPY" => Some(Command::Copy),
193 "WITH_IO" => Some(Command::WithIo),
194 "COPY_GIT" => Some(Command::CopyGit),
195 "HASH_SHA256" => Some(Command::HashSha256),
196 "SYMLINK" => Some(Command::Symlink),
197 "MKDIR" => Some(Command::Mkdir),
198 "LS" => Some(Command::Ls),
199 "CWD" => Some(Command::Cwd),
200 "READ" => Some(Command::Read),
201 "READ_LINE" => Some(Command::ReadLine),
202 "WRITE" => Some(Command::Write),
203 "APPEND" => Some(Command::Append),
204 "EXPAND" => Some(Command::Expand),
205 "ASSERT_EQ" => Some(Command::AssertEq),
206 "ASSERT_CONTAINS" => Some(Command::AssertContains),
207 "EXIT" => Some(Command::Exit),
208 "ASYNC" => Some(Command::Async),
209 "TIMEOUT" => Some(Command::Timeout),
210 "SLEEP" => Some(Command::Sleep),
211 "LIST_APPEND" => Some(Command::ListAppend),
212 _ => None,
213 }
214 }
215
216 pub(crate) fn is_statement_keyword(s: &str) -> bool {
221 Command::parse(s).is_some() || STRUCTURAL_KEYWORDS.contains(&s)
222 }
223}
224
225pub const STRUCTURAL_KEYWORDS: &[&str] = &[
231 KEYWORD_LET,
232 KEYWORD_FOR,
233 KEYWORD_IF,
234 KEYWORD_ELSE,
235 KEYWORD_ASYNC,
236 KEYWORD_AWAIT,
237 KEYWORD_CANCEL,
238 KEYWORD_FUNC,
239 KEYWORD_RETURN,
240 KEYWORD_WHILE,
241 KEYWORD_BREAK,
242 KEYWORD_CONTINUE,
243 KEYWORD_IMPORT,
244 KEYWORD_EXPORT,
245];
246
247pub const CLAUSE_KEYWORDS: &[&str] = &["IN"];
252
253#[derive(Copy, Clone, Debug, Eq, PartialEq)]
254pub enum PlatformGuard {
255 Unix,
256 Windows,
257 Macos,
258 Linux,
259}
260
261#[derive(Debug, Clone, Eq, PartialEq)]
262pub enum Guard {
263 Platform { target: PlatformGuard },
264 EnvExists { key: String },
265 EnvEquals { key: String, value: String },
266 StaticBool { value: String },
267}
268
269#[derive(Debug, Clone, Eq, PartialEq)]
270pub enum GuardExpr {
271 Predicate(Guard),
272 All(Vec<GuardExpr>),
273 Or(Vec<GuardExpr>),
274 Not(Box<GuardExpr>),
275}
276
277impl GuardExpr {
278 pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
279 let mut flat = Vec::new();
280 for expr in exprs {
281 match expr {
282 GuardExpr::All(children) => flat.extend(children),
283 other => flat.push(other),
284 }
285 }
286 match flat.len() {
287 0 => panic!("GuardExpr::all requires at least one expression"),
288 1 => flat.into_iter().next().unwrap(),
289 _ => GuardExpr::All(flat),
290 }
291 }
292
293 pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
294 let mut flat = Vec::new();
295 for expr in exprs {
296 match expr {
297 GuardExpr::Or(children) => flat.extend(children),
298 other => flat.push(other),
299 }
300 }
301 match flat.len() {
302 0 => panic!("GuardExpr::or requires at least one expression"),
303 1 => flat.into_iter().next().unwrap(),
304 _ => GuardExpr::Or(flat),
305 }
306 }
307
308 pub fn invert(expr: GuardExpr) -> GuardExpr {
309 match expr {
310 GuardExpr::Not(inner) => *inner,
311 other => GuardExpr::Not(Box::new(other)),
312 }
313 }
314}
315
316impl std::ops::Not for GuardExpr {
317 type Output = GuardExpr;
318
319 fn not(self) -> GuardExpr {
320 match self {
321 GuardExpr::Not(inner) => *inner,
322 other => GuardExpr::Not(Box::new(other)),
323 }
324 }
325}
326
327impl From<Guard> for GuardExpr {
328 fn from(guard: Guard) -> Self {
329 GuardExpr::Predicate(guard)
330 }
331}
332
333#[derive(Debug, Clone, PartialEq)]
335pub enum Arg {
336 String(String, bool),
340 Expr(Expr),
342 Parts(Vec<ArgPart>),
346}
347
348#[derive(Debug, Clone, PartialEq)]
350pub enum ArgPart {
351 Text(String, bool),
354 Expr(Expr),
356}
357
358impl Arg {
359 pub fn as_str(&self) -> &str {
360 match self {
361 Arg::String(s, _) => s,
362 Arg::Expr(_) | Arg::Parts(_) => "",
365 }
366 }
367
368 pub fn render(&self) -> String {
372 match self {
373 Arg::String(s, _) => s.clone(),
374 Arg::Expr(e) => e.to_string(),
375 Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
376 }
377 }
378
379 pub fn is_quoted(&self) -> bool {
380 matches!(self, Arg::String(_, true))
381 }
382}
383
384impl ArgPart {
385 pub fn render(&self) -> String {
386 match self {
387 ArgPart::Text(s, _) => s.clone(),
388 ArgPart::Expr(e) => e.to_string(),
389 }
390 }
391}
392
393impl From<String> for Arg {
394 fn from(s: String) -> Self {
395 Arg::String(s, false)
396 }
397}
398
399impl From<&str> for Arg {
400 fn from(s: &str) -> Self {
401 Arg::String(s.to_string(), false)
402 }
403}
404
405impl std::fmt::Display for Arg {
406 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407 match self {
408 Arg::String(s, _) => write!(f, "{}", s),
409 Arg::Expr(e) => write!(f, "{}", e),
410 Arg::Parts(parts) => {
411 for part in parts {
412 match part {
413 ArgPart::Text(s, _) => write!(f, "{}", s)?,
414 ArgPart::Expr(e) => write!(f, "{}", e)?,
415 }
416 }
417 Ok(())
418 }
419 }
420 }
421}
422
423impl AsRef<str> for Arg {
424 fn as_ref(&self) -> &str {
425 self.as_str()
426 }
427}
428
429impl PartialEq<str> for Arg {
430 fn eq(&self, other: &str) -> bool {
431 self.as_str() == other
432 }
433}
434
435impl PartialEq<&str> for Arg {
436 fn eq(&self, other: &&str) -> bool {
437 self.as_str() == *other
438 }
439}
440
441#[derive(Debug, Clone, Copy, Eq, PartialEq)]
442pub enum IoStream {
443 Stdin,
444 Stdout,
445 Stderr,
446}
447
448#[derive(Debug, Clone, Eq, PartialEq)]
449pub struct IoBinding {
450 pub stream: IoStream,
451 pub pipe: Option<PipeTarget>,
452}
453
454#[derive(Debug, Clone, Eq, PartialEq)]
457pub enum PipeTarget {
458 Var(String),
459}
460
461pub use crate::value::{
465 OxDockType, TypeDescriptor, Value, ValuePayload, clone_boxed, clone_copy, clone_shared,
466 drop_boxed, drop_noop, drop_shared, eq_boxed, eq_inline, eq_shared, fmt_boxed, fmt_inline,
467 fmt_shared, load_inline, startup_descriptors, store_inline, type_anchor, unshare_boxed,
468 unshare_inline, unshare_shared,
469};
470
471#[derive(Debug, Clone, Copy, Eq, PartialEq)]
472pub enum CompareOp {
473 Eq,
474 Ne,
475 Lt,
476 Le,
477 Gt,
478 Ge,
479}
480
481#[derive(Debug, Clone, Copy, Eq, PartialEq)]
482pub enum ArithOp {
483 Add,
484 Sub,
485 Mul,
486 Div,
487}
488
489#[derive(Debug, Clone, PartialEq)]
498pub enum MathOp {
499 PushConst(Value),
500 LoadVar(String),
501 LoadEnv(String),
502 LoadKeyPath { base: String, keys: Vec<String> },
503 Call { name: String, arity: usize },
504 Inspect(String),
505 Neg,
506 Add,
507 Sub,
508 Mul,
509 Div,
510 Lt,
511 Le,
512 Gt,
513 Ge,
514 Eq,
515 Ne,
516}
517
518#[derive(Debug, Clone, Copy, Eq, PartialEq)]
519pub enum LogicalOp {
520 And,
521 Or,
522}
523
524#[derive(Debug, Clone, PartialEq)]
525pub enum Expr {
526 Literal(Value),
527 Var(String),
528 Env(String),
531 KeyPath {
532 base: String,
533 keys: Vec<String>,
534 },
535 List(Vec<Expr>),
536 Map(Vec<(String, Expr)>),
537 Block(Vec<Step>),
542 Call {
543 name: String,
544 args: Vec<Expr>,
545 },
546 Inspect(String),
551 Compare {
552 op: CompareOp,
553 left: Box<Expr>,
554 right: Box<Expr>,
555 },
556 Arithmetic {
557 op: ArithOp,
558 left: Box<Expr>,
559 right: Box<Expr>,
560 },
561 CompiledMath(Vec<MathOp>),
564 FreshPipe,
570 UnsignedIntBoundary(u64),
574 Not(Box<Expr>),
575 Logical {
576 op: LogicalOp,
577 left: Box<Expr>,
578 right: Box<Expr>,
579 },
580}
581
582#[derive(Debug, Clone, PartialEq)]
583pub struct Step {
584 pub guard: Option<GuardExpr>,
585 pub kind: StepKind,
586 pub scope_enter: usize,
587 pub scope_exit: usize,
588}
589
590#[derive(Debug, Clone, Eq, PartialEq)]
591pub enum WorkspaceTarget {
592 Snapshot,
593 Local,
594}
595
596fn platform_matches(target: PlatformGuard) -> bool {
597 #[allow(clippy::disallowed_macros)]
598 match target {
599 PlatformGuard::Unix => cfg!(unix),
600 PlatformGuard::Windows => cfg!(windows),
601 PlatformGuard::Macos => cfg!(target_os = "macos"),
602 PlatformGuard::Linux => cfg!(target_os = "linux"),
603 }
604}
605
606pub trait EnvLookup {
607 fn get_env(&self, key: &str) -> Option<&str>;
608}
609
610impl EnvLookup for HashMap<String, String> {
611 fn get_env(&self, key: &str) -> Option<&str> {
612 self.get(key).map(|s| s.as_str())
613 }
614}
615
616impl EnvLookup for Arc<HashMap<String, String>> {
617 fn get_env(&self, key: &str) -> Option<&str> {
618 (**self).get_env(key)
619 }
620}
621
622pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
623 match guard {
624 Guard::Platform { target } => platform_matches(*target),
625 Guard::EnvExists { key } => env.get_env(key).map(|v| !v.is_empty()).unwrap_or(false),
626 Guard::EnvEquals { key, value } => env
627 .get_env(key)
628 .map(|v| v == value.as_str())
629 .unwrap_or(false),
630 Guard::StaticBool { value } => value.parse::<bool>().unwrap_or(false),
631 }
632}
633
634pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
635 match expr {
636 GuardExpr::Predicate(guard) => guard_allows(guard, env),
637 GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
638 GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
639 GuardExpr::Not(child) => !guard_expr_allows(child, env),
640 }
641}
642
643pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
644 match expr {
645 Some(e) => guard_expr_allows(e, env),
646 None => true,
647 }
648}
649
650impl fmt::Display for PlatformGuard {
651 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
652 match self {
653 PlatformGuard::Unix => write!(f, "unix"),
654 PlatformGuard::Windows => write!(f, "windows"),
655 PlatformGuard::Macos => write!(f, "macos"),
656 PlatformGuard::Linux => write!(f, "linux"),
657 }
658 }
659}
660
661impl fmt::Display for Guard {
662 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
663 match self {
664 Guard::Platform { target } => write!(f, "{}", target),
665 Guard::EnvExists { key } => write!(f, "env:{}", key),
666 Guard::EnvEquals { key, value } => write!(f, "eq(env:{}, {})", key, value),
667 Guard::StaticBool { value } => write!(f, "bool:{}", value),
668 }
669 }
670}
671
672impl fmt::Display for WorkspaceTarget {
673 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
674 match self {
675 WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
676 WorkspaceTarget::Local => write!(f, "LOCAL"),
677 }
678 }
679}
680
681impl fmt::Display for Expr {
682 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
683 match self {
684 Expr::Literal(v) => write!(f, "{}", v),
685 Expr::Var(name) => write!(f, "${}", name),
686 Expr::Env(key) => write!(f, "env:{}", key),
687 Expr::KeyPath { base, keys } => {
688 write!(f, "${}", base)?;
689 for key in keys {
690 write!(f, ".{}", key)?;
691 }
692 Ok(())
693 }
694 Expr::Call { name, args } => {
695 write!(f, "{}(", name)?;
696 for (i, arg) in args.iter().enumerate() {
697 if i > 0 {
698 write!(f, ", ")?;
699 }
700 write!(f, "{}", arg)?;
701 }
702 write!(f, ")")
703 }
704 Expr::Inspect(var) => write!(f, "INSPECT(${})", var),
705 Expr::List(items) => {
706 write!(f, "[")?;
707 for (i, item) in items.iter().enumerate() {
708 if i > 0 {
709 write!(f, ", ")?;
710 }
711 write!(f, "{}", item)?;
712 }
713 write!(f, "]")
714 }
715 Expr::Map(entries) => {
716 write!(f, "{{")?;
717 for (i, (key, val)) in entries.iter().enumerate() {
718 if i > 0 {
719 write!(f, ", ")?;
720 }
721 write!(f, "\"{}\": {}", key, val)?;
722 }
723 write!(f, "}}")
724 }
725 Expr::Block(steps) => {
726 write!(f, "{{ ")?;
727 for (i, step) in steps.iter().enumerate() {
728 if i > 0 {
729 write!(f, "; ")?;
730 }
731 write!(f, "{}", step.kind)?;
732 }
733 write!(f, " }}")
734 }
735 Expr::Compare { op, left, right } => {
736 write!(f, "{} {} {}", left, op, right)
737 }
738 Expr::Arithmetic { op, left, right } => {
739 write!(f, "({} {} {})", left, op, right)
740 }
741 Expr::CompiledMath(ops) => {
742 write!(f, "{}", format_compiled_math(ops))
743 }
744 Expr::FreshPipe => write!(f, "<fresh pipe>"),
748 Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
749 Expr::Not(inner) => {
750 match inner.as_ref() {
753 Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
754 write!(f, "!({})", inner)
755 }
756 _ => write!(f, "!{}", inner),
757 }
758 }
759 Expr::Logical { op, left, right } => {
760 write!(f, "({} {} {})", left, op, right)
761 }
762 }
763 }
764}
765
766impl fmt::Display for CompareOp {
767 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
768 match self {
769 CompareOp::Eq => write!(f, "=="),
770 CompareOp::Ne => write!(f, "!="),
771 CompareOp::Lt => write!(f, "<"),
772 CompareOp::Le => write!(f, "<="),
773 CompareOp::Gt => write!(f, ">"),
774 CompareOp::Ge => write!(f, ">="),
775 }
776 }
777}
778
779impl fmt::Display for ArithOp {
780 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
781 match self {
782 ArithOp::Add => write!(f, "+"),
783 ArithOp::Sub => write!(f, "-"),
784 ArithOp::Mul => write!(f, "*"),
785 ArithOp::Div => write!(f, "/"),
786 }
787 }
788}
789
790fn format_compiled_math(ops: &[MathOp]) -> String {
795 let mut stack: Vec<String> = Vec::new();
796 for op in ops {
797 match op {
798 MathOp::PushConst(v) => stack.push(format!("{}", v)),
799 MathOp::LoadVar(name) => stack.push(format!("${}", name)),
800 MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
801 MathOp::LoadKeyPath { base, keys } => {
802 let mut s = format!("${}", base);
803 for key in keys {
804 s.push('.');
805 s.push_str(key);
806 }
807 stack.push(s);
808 }
809 MathOp::Call { name, arity } => {
810 let mut args = Vec::new();
811 for _ in 0..*arity {
812 args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
813 }
814 args.reverse();
815 stack.push(format!("{}({})", name, args.join(", ")));
816 }
817 MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
818 MathOp::Neg => {
819 let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
820 stack.push(format!("(-{})", inner));
821 }
822 MathOp::Add => push_bin(&mut stack, "+"),
823 MathOp::Sub => push_bin(&mut stack, "-"),
824 MathOp::Mul => push_bin(&mut stack, "*"),
825 MathOp::Div => push_bin(&mut stack, "/"),
826 MathOp::Lt => push_bin(&mut stack, "<"),
827 MathOp::Le => push_bin(&mut stack, "<="),
828 MathOp::Gt => push_bin(&mut stack, ">"),
829 MathOp::Ge => push_bin(&mut stack, ">="),
830 MathOp::Eq => push_bin(&mut stack, "=="),
831 MathOp::Ne => push_bin(&mut stack, "!="),
832 }
833 }
834 if stack.len() == 1 {
835 let mut items = stack;
836 items.pop().unwrap_or_else(|| "<empty>".to_string())
837 } else {
838 stack.join(" ")
839 }
840}
841
842fn push_bin(stack: &mut Vec<String>, op: &str) {
843 let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
844 let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
845 stack.push(format!("({} {} {})", left, op, right));
846}
847
848impl fmt::Display for LogicalOp {
849 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
850 match self {
851 LogicalOp::And => write!(f, "&&"),
852 LogicalOp::Or => write!(f, "||"),
853 }
854 }
855}
856
857enum GuardDisplayContext {
858 Root,
859 InAnyArg,
860 InNot,
861 InAll,
862}
863
864impl GuardExpr {
865 fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
866 match self {
867 GuardExpr::Predicate(guard) => write!(f, "{}", guard),
868 GuardExpr::All(children) => {
869 let wrap = matches!(
870 ctx,
871 GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
872 ) && children.len() > 1;
873 if wrap {
874 write!(f, "(")?;
875 }
876 for (i, child) in children.iter().enumerate() {
877 if i > 0 {
878 write!(f, ", ")?;
879 }
880 child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
881 }
882 if wrap {
883 write!(f, ")")?;
884 }
885 Ok(())
886 }
887 GuardExpr::Or(children) => {
888 write!(f, "any(")?;
889 for (i, child) in children.iter().enumerate() {
890 if i > 0 {
891 write!(f, ", ")?;
892 }
893 child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
894 }
895 write!(f, ")")
896 }
897 GuardExpr::Not(child) => {
898 write!(f, "not(")?;
899 child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
900 write!(f, ")")
901 }
902 }
903 }
904}
905
906impl fmt::Display for GuardExpr {
907 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
908 self.fmt_with_ctx(f, GuardDisplayContext::Root)
909 }
910}
911
912impl fmt::Display for Step {
913 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
914 if let Some(expr) = &self.guard {
915 write!(f, "[{}] ", expr)?;
916 }
917 write!(f, "{}", self.kind)
918 }
919}
920
921#[cfg(test)]
922mod tests {
923 use super::*;
924 #[test]
937 fn statement_keywords_match_pest_grammar() {
938 use pest_meta::{ast::Expr, parser};
939 use std::collections::{HashMap, HashSet};
940
941 let pest_src = include_str!("dsl.pest");
942 let pairs = parser::parse(parser::Rule::grammar_rules, pest_src)
943 .expect("dsl.pest must parse as a pest grammar");
944 let rules = parser::consume_rules(pairs).expect("dsl.pest rules must consume");
945 let by_name: HashMap<&str, &Expr> = rules
946 .iter()
947 .map(|rule| (rule.name.as_str(), &rule.expr))
948 .collect();
949
950 let mut all_literals = HashSet::new();
951 for rule in &rules {
952 for node in rule.expr.iter_top_down() {
953 match node {
954 Expr::Str(literal) | Expr::Insens(literal) => {
955 all_literals.insert(literal.clone());
956 }
957 _ => {}
958 }
959 }
960 }
961 for kw in STRUCTURAL_KEYWORDS {
962 assert!(
963 all_literals.contains(*kw),
964 "keyword {kw} in STRUCTURAL_KEYWORDS missing from dsl.pest"
965 );
966 }
967
968 let mut seen = HashSet::new();
969 let mut stack = vec!["element".to_string(), "block_element".to_string()];
970 let mut stmt_literals = HashSet::new();
971 while let Some(name) = stack.pop() {
972 if !seen.insert(name.clone()) {
973 continue;
974 }
975 let Some(expr) = by_name.get(name.as_str()) else {
976 continue;
977 };
978 for node in expr.iter_top_down() {
979 match node {
980 Expr::Str(literal) | Expr::Insens(literal) => {
981 stmt_literals.insert(literal.clone());
982 }
983 Expr::Ident(dependency) => stack.push(dependency.clone()),
984 _ => {}
985 }
986 }
987 }
988 assert!(
989 seen.contains("element") && seen.contains("block_element"),
990 "grammar must define element and block_element instruction rules"
991 );
992 for kw in stmt_literals {
993 if kw.len() > 1 && kw.chars().all(|c| c.is_ascii_uppercase()) {
994 assert!(
995 crate::Command::is_statement_keyword(&kw)
996 || CLAUSE_KEYWORDS.contains(&kw.as_str()),
997 "uppercase literal \"{kw}\" reachable from dsl.pest instruction rules is not a registered statement or clause keyword"
998 );
999 }
1000 }
1001 }
1002
1003 #[test]
1007 fn command_names_group_by_category() {
1008 for command in COMMANDS {
1009 let name = command.as_str();
1010 let Some((prefix, _)) = name.split_once('_') else {
1011 continue;
1012 };
1013 assert!(
1014 COMMAND_CATEGORIES.contains(&prefix),
1015 "command {name} introduces unregistered category {prefix}; add it to COMMAND_CATEGORIES"
1016 );
1017 }
1018 }
1019}