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