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_REMOTE, 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_REMOTE,
246 KEYWORD_IMPORT,
247 KEYWORD_EXPORT,
248];
249
250pub const CLAUSE_KEYWORDS: &[&str] = &["IN"];
255
256#[derive(Copy, Clone, Debug, Eq, PartialEq)]
257pub enum PlatformGuard {
258 Unix,
259 Windows,
260 Macos,
261 Linux,
262}
263
264#[derive(Debug, Clone, Eq, PartialEq)]
265pub enum Guard {
266 Platform { target: PlatformGuard },
267 EnvExists { key: String },
268 EnvEquals { key: String, value: String },
269 StaticBool { value: String },
270}
271
272#[derive(Debug, Clone, Eq, PartialEq)]
273pub enum GuardExpr {
274 Predicate(Guard),
275 All(Vec<GuardExpr>),
276 Or(Vec<GuardExpr>),
277 Not(Box<GuardExpr>),
278}
279
280impl GuardExpr {
281 pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
282 let mut flat = Vec::new();
283 for expr in exprs {
284 match expr {
285 GuardExpr::All(children) => flat.extend(children),
286 other => flat.push(other),
287 }
288 }
289 match flat.len() {
290 0 => panic!("GuardExpr::all requires at least one expression"),
291 1 => flat.into_iter().next().unwrap(),
292 _ => GuardExpr::All(flat),
293 }
294 }
295
296 pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
297 let mut flat = Vec::new();
298 for expr in exprs {
299 match expr {
300 GuardExpr::Or(children) => flat.extend(children),
301 other => flat.push(other),
302 }
303 }
304 match flat.len() {
305 0 => panic!("GuardExpr::or requires at least one expression"),
306 1 => flat.into_iter().next().unwrap(),
307 _ => GuardExpr::Or(flat),
308 }
309 }
310
311 pub fn invert(expr: GuardExpr) -> GuardExpr {
312 match expr {
313 GuardExpr::Not(inner) => *inner,
314 other => GuardExpr::Not(Box::new(other)),
315 }
316 }
317}
318
319impl std::ops::Not for GuardExpr {
320 type Output = GuardExpr;
321
322 fn not(self) -> GuardExpr {
323 match self {
324 GuardExpr::Not(inner) => *inner,
325 other => GuardExpr::Not(Box::new(other)),
326 }
327 }
328}
329
330impl From<Guard> for GuardExpr {
331 fn from(guard: Guard) -> Self {
332 GuardExpr::Predicate(guard)
333 }
334}
335
336#[derive(Debug, Clone, PartialEq)]
338pub enum Arg {
339 String(String, bool),
343 Expr(Expr),
345 Parts(Vec<ArgPart>),
349}
350
351#[derive(Debug, Clone, PartialEq)]
353pub enum ArgPart {
354 Text(String, bool),
357 Expr(Expr),
359}
360
361impl Arg {
362 pub fn as_str(&self) -> &str {
363 match self {
364 Arg::String(s, _) => s,
365 Arg::Expr(_) | Arg::Parts(_) => "",
368 }
369 }
370
371 pub fn render(&self) -> String {
375 match self {
376 Arg::String(s, _) => s.clone(),
377 Arg::Expr(e) => e.to_string(),
378 Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
379 }
380 }
381
382 pub fn is_quoted(&self) -> bool {
383 matches!(self, Arg::String(_, true))
384 }
385}
386
387impl ArgPart {
388 pub fn render(&self) -> String {
389 match self {
390 ArgPart::Text(s, _) => s.clone(),
391 ArgPart::Expr(e) => e.to_string(),
392 }
393 }
394}
395
396impl From<String> for Arg {
397 fn from(s: String) -> Self {
398 Arg::String(s, false)
399 }
400}
401
402impl From<&str> for Arg {
403 fn from(s: &str) -> Self {
404 Arg::String(s.to_string(), false)
405 }
406}
407
408impl std::fmt::Display for Arg {
409 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
410 match self {
411 Arg::String(s, _) => write!(f, "{}", s),
412 Arg::Expr(e) => write!(f, "{}", e),
413 Arg::Parts(parts) => {
414 for part in parts {
415 match part {
416 ArgPart::Text(s, _) => write!(f, "{}", s)?,
417 ArgPart::Expr(e) => write!(f, "{}", e)?,
418 }
419 }
420 Ok(())
421 }
422 }
423 }
424}
425
426impl AsRef<str> for Arg {
427 fn as_ref(&self) -> &str {
428 self.as_str()
429 }
430}
431
432impl PartialEq<str> for Arg {
433 fn eq(&self, other: &str) -> bool {
434 self.as_str() == other
435 }
436}
437
438impl PartialEq<&str> for Arg {
439 fn eq(&self, other: &&str) -> bool {
440 self.as_str() == *other
441 }
442}
443
444#[derive(Debug, Clone, Copy, Eq, PartialEq)]
445pub enum IoStream {
446 Stdin,
447 Stdout,
448 Stderr,
449}
450
451#[derive(Debug, Clone, Eq, PartialEq)]
452pub struct IoBinding {
453 pub stream: IoStream,
454 pub pipe: Option<PipeTarget>,
455}
456
457#[derive(Debug, Clone, Eq, PartialEq)]
460pub enum PipeTarget {
461 Var(String),
462}
463
464pub use crate::value::{
468 OxDockType, TypeDescriptor, Value, ValuePayload, clone_boxed, clone_copy, clone_shared,
469 drop_boxed, drop_noop, drop_shared, eq_boxed, eq_inline, eq_shared, fmt_boxed, fmt_inline,
470 fmt_shared, load_inline, startup_descriptors, store_inline, type_anchor, unshare_boxed,
471 unshare_inline, unshare_shared,
472};
473
474#[derive(Debug, Clone, Copy, Eq, PartialEq)]
475pub enum CompareOp {
476 Eq,
477 Ne,
478 Lt,
479 Le,
480 Gt,
481 Ge,
482}
483
484#[derive(Debug, Clone, Copy, Eq, PartialEq)]
485pub enum ArithOp {
486 Add,
487 Sub,
488 Mul,
489 Div,
490}
491
492#[derive(Debug, Clone, PartialEq)]
501pub enum MathOp {
502 PushConst(Value),
503 LoadVar(String),
504 LoadEnv(String),
505 LoadKeyPath { base: String, keys: Vec<String> },
506 Call { name: String, arity: usize },
507 Inspect(String),
508 Neg,
509 Add,
510 Sub,
511 Mul,
512 Div,
513 Lt,
514 Le,
515 Gt,
516 Ge,
517 Eq,
518 Ne,
519}
520
521#[derive(Debug, Clone, Copy, Eq, PartialEq)]
522pub enum LogicalOp {
523 And,
524 Or,
525}
526
527#[derive(Debug, Clone, PartialEq)]
528pub enum Expr {
529 Literal(Value),
530 Var(String),
531 Env(String),
534 KeyPath {
535 base: String,
536 keys: Vec<String>,
537 },
538 List(Vec<Expr>),
539 Map(Vec<(String, Expr)>),
540 Block(Vec<Step>),
545 Call {
546 name: String,
547 args: Vec<Expr>,
548 },
549 Inspect(String),
554 Compare {
555 op: CompareOp,
556 left: Box<Expr>,
557 right: Box<Expr>,
558 },
559 Arithmetic {
560 op: ArithOp,
561 left: Box<Expr>,
562 right: Box<Expr>,
563 },
564 CompiledMath(Vec<MathOp>),
567 FreshPipe,
573 UnsignedIntBoundary(u64),
577 Not(Box<Expr>),
578 Logical {
579 op: LogicalOp,
580 left: Box<Expr>,
581 right: Box<Expr>,
582 },
583}
584
585#[derive(Debug, Clone, PartialEq)]
586pub struct Step {
587 pub guard: Option<GuardExpr>,
588 pub kind: StepKind,
589 pub scope_enter: usize,
590 pub scope_exit: usize,
591}
592
593impl Expr {
594 pub fn walk(&self, f: &mut impl FnMut(&Expr)) {
601 f(self);
602 match self {
603 Expr::Literal(_)
604 | Expr::Var(_)
605 | Expr::Env(_)
606 | Expr::KeyPath { .. }
607 | Expr::Inspect(_)
608 | Expr::FreshPipe
609 | Expr::UnsignedIntBoundary(_) => {}
610 Expr::Call { args, .. } => {
611 for arg in args {
612 arg.walk(f);
613 }
614 }
615 Expr::List(items) => {
616 for item in items {
617 item.walk(f);
618 }
619 }
620 Expr::Map(entries) => {
621 for (_, value) in entries {
622 value.walk(f);
623 }
624 }
625 Expr::Block(steps) => {
626 for step in steps {
627 step.kind.walk_step_exprs(f);
628 }
629 }
630 Expr::Compare { left, right, .. }
631 | Expr::Arithmetic { left, right, .. }
632 | Expr::Logical { left, right, .. } => {
633 left.walk(f);
634 right.walk(f);
635 }
636 Expr::CompiledMath(_) => {}
637 Expr::Not(inner) => inner.walk(f),
638 }
639 }
640}
641
642#[derive(Debug, Clone, Eq, PartialEq)]
643pub enum WorkspaceTarget {
644 Snapshot,
645 Local,
646 Cache { local: bool },
647 System,
648}
649
650fn platform_matches(target: PlatformGuard) -> bool {
651 #[allow(clippy::disallowed_macros)]
652 match target {
653 PlatformGuard::Unix => cfg!(unix),
654 PlatformGuard::Windows => cfg!(windows),
655 PlatformGuard::Macos => cfg!(target_os = "macos"),
656 PlatformGuard::Linux => cfg!(target_os = "linux"),
657 }
658}
659
660pub trait EnvLookup {
661 fn get_env(&self, key: &str) -> Option<&str>;
662}
663
664impl EnvLookup for HashMap<String, String> {
665 fn get_env(&self, key: &str) -> Option<&str> {
666 self.get(key).map(|s| s.as_str())
667 }
668}
669
670impl EnvLookup for Arc<HashMap<String, String>> {
671 fn get_env(&self, key: &str) -> Option<&str> {
672 (**self).get_env(key)
673 }
674}
675
676pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
677 match guard {
678 Guard::Platform { target } => platform_matches(*target),
679 Guard::EnvExists { key } => env.get_env(key).map(|v| !v.is_empty()).unwrap_or(false),
680 Guard::EnvEquals { key, value } => env
681 .get_env(key)
682 .map(|v| v == value.as_str())
683 .unwrap_or(false),
684 Guard::StaticBool { value } => value.parse::<bool>().unwrap_or(false),
685 }
686}
687
688pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
689 match expr {
690 GuardExpr::Predicate(guard) => guard_allows(guard, env),
691 GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
692 GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
693 GuardExpr::Not(child) => !guard_expr_allows(child, env),
694 }
695}
696
697pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
698 match expr {
699 Some(e) => guard_expr_allows(e, env),
700 None => true,
701 }
702}
703
704impl fmt::Display for PlatformGuard {
705 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
706 match self {
707 PlatformGuard::Unix => write!(f, "unix"),
708 PlatformGuard::Windows => write!(f, "windows"),
709 PlatformGuard::Macos => write!(f, "macos"),
710 PlatformGuard::Linux => write!(f, "linux"),
711 }
712 }
713}
714
715impl fmt::Display for Guard {
716 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
717 match self {
718 Guard::Platform { target } => write!(f, "{}", target),
719 Guard::EnvExists { key } => write!(f, "env:{}", key),
720 Guard::EnvEquals { key, value } => write!(f, "eq(env:{}, {})", key, value),
721 Guard::StaticBool { value } => write!(f, "bool:{}", value),
722 }
723 }
724}
725
726impl fmt::Display for WorkspaceTarget {
727 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
728 match self {
729 WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
730 WorkspaceTarget::Local => write!(f, "LOCAL"),
731 WorkspaceTarget::Cache { local: false } => write!(f, "CACHE"),
732 WorkspaceTarget::Cache { local: true } => write!(f, "CACHE --local"),
733 WorkspaceTarget::System => write!(f, "SYSTEM"),
734 }
735 }
736}
737
738impl fmt::Display for Expr {
739 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
740 match self {
741 Expr::Literal(v) => write!(f, "{}", v),
742 Expr::Var(name) => write!(f, "${}", name),
743 Expr::Env(key) => write!(f, "env:{}", key),
744 Expr::KeyPath { base, keys } => {
745 write!(f, "${}", base)?;
746 for key in keys {
747 write!(f, ".{}", key)?;
748 }
749 Ok(())
750 }
751 Expr::Call { name, args } => {
752 write!(f, "{}(", name)?;
753 for (i, arg) in args.iter().enumerate() {
754 if i > 0 {
755 write!(f, ", ")?;
756 }
757 write!(f, "{}", arg)?;
758 }
759 write!(f, ")")
760 }
761 Expr::Inspect(var) => write!(f, "INSPECT(${})", var),
762 Expr::List(items) => {
763 write!(f, "[")?;
764 for (i, item) in items.iter().enumerate() {
765 if i > 0 {
766 write!(f, ", ")?;
767 }
768 write!(f, "{}", item)?;
769 }
770 write!(f, "]")
771 }
772 Expr::Map(entries) => {
773 write!(f, "{{")?;
774 for (i, (key, val)) in entries.iter().enumerate() {
775 if i > 0 {
776 write!(f, ", ")?;
777 }
778 write!(f, "\"{}\": {}", key, val)?;
779 }
780 write!(f, "}}")
781 }
782 Expr::Block(steps) => {
783 write!(f, "{{ ")?;
784 for (i, step) in steps.iter().enumerate() {
785 if i > 0 {
786 write!(f, "; ")?;
787 }
788 write!(f, "{}", step.kind)?;
789 }
790 write!(f, " }}")
791 }
792 Expr::Compare { op, left, right } => {
793 write!(f, "{} {} {}", left, op, right)
794 }
795 Expr::Arithmetic { op, left, right } => {
796 write!(f, "({} {} {})", left, op, right)
797 }
798 Expr::CompiledMath(ops) => {
799 write!(f, "{}", format_compiled_math(ops))
800 }
801 Expr::FreshPipe => write!(f, "<fresh pipe>"),
805 Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
806 Expr::Not(inner) => {
807 match inner.as_ref() {
810 Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
811 write!(f, "!({})", inner)
812 }
813 _ => write!(f, "!{}", inner),
814 }
815 }
816 Expr::Logical { op, left, right } => {
817 write!(f, "({} {} {})", left, op, right)
818 }
819 }
820 }
821}
822
823impl fmt::Display for CompareOp {
824 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
825 match self {
826 CompareOp::Eq => write!(f, "=="),
827 CompareOp::Ne => write!(f, "!="),
828 CompareOp::Lt => write!(f, "<"),
829 CompareOp::Le => write!(f, "<="),
830 CompareOp::Gt => write!(f, ">"),
831 CompareOp::Ge => write!(f, ">="),
832 }
833 }
834}
835
836impl fmt::Display for ArithOp {
837 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
838 match self {
839 ArithOp::Add => write!(f, "+"),
840 ArithOp::Sub => write!(f, "-"),
841 ArithOp::Mul => write!(f, "*"),
842 ArithOp::Div => write!(f, "/"),
843 }
844 }
845}
846
847fn format_compiled_math(ops: &[MathOp]) -> String {
852 let mut stack: Vec<String> = Vec::new();
853 for op in ops {
854 match op {
855 MathOp::PushConst(v) => stack.push(format!("{}", v)),
856 MathOp::LoadVar(name) => stack.push(format!("${}", name)),
857 MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
858 MathOp::LoadKeyPath { base, keys } => {
859 let mut s = format!("${}", base);
860 for key in keys {
861 s.push('.');
862 s.push_str(key);
863 }
864 stack.push(s);
865 }
866 MathOp::Call { name, arity } => {
867 let mut args = Vec::new();
868 for _ in 0..*arity {
869 args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
870 }
871 args.reverse();
872 stack.push(format!("{}({})", name, args.join(", ")));
873 }
874 MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
875 MathOp::Neg => {
876 let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
877 stack.push(format!("(-{})", inner));
878 }
879 MathOp::Add => push_bin(&mut stack, "+"),
880 MathOp::Sub => push_bin(&mut stack, "-"),
881 MathOp::Mul => push_bin(&mut stack, "*"),
882 MathOp::Div => push_bin(&mut stack, "/"),
883 MathOp::Lt => push_bin(&mut stack, "<"),
884 MathOp::Le => push_bin(&mut stack, "<="),
885 MathOp::Gt => push_bin(&mut stack, ">"),
886 MathOp::Ge => push_bin(&mut stack, ">="),
887 MathOp::Eq => push_bin(&mut stack, "=="),
888 MathOp::Ne => push_bin(&mut stack, "!="),
889 }
890 }
891 if stack.len() == 1 {
892 let mut items = stack;
893 items.pop().unwrap_or_else(|| "<empty>".to_string())
894 } else {
895 stack.join(" ")
896 }
897}
898
899fn push_bin(stack: &mut Vec<String>, op: &str) {
900 let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
901 let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
902 stack.push(format!("({} {} {})", left, op, right));
903}
904
905impl fmt::Display for LogicalOp {
906 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
907 match self {
908 LogicalOp::And => write!(f, "&&"),
909 LogicalOp::Or => write!(f, "||"),
910 }
911 }
912}
913
914enum GuardDisplayContext {
915 Root,
916 InAnyArg,
917 InNot,
918 InAll,
919}
920
921impl GuardExpr {
922 fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
923 match self {
924 GuardExpr::Predicate(guard) => write!(f, "{}", guard),
925 GuardExpr::All(children) => {
926 let wrap = matches!(
927 ctx,
928 GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
929 ) && children.len() > 1;
930 if wrap {
931 write!(f, "(")?;
932 }
933 for (i, child) in children.iter().enumerate() {
934 if i > 0 {
935 write!(f, ", ")?;
936 }
937 child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
938 }
939 if wrap {
940 write!(f, ")")?;
941 }
942 Ok(())
943 }
944 GuardExpr::Or(children) => {
945 write!(f, "any(")?;
946 for (i, child) in children.iter().enumerate() {
947 if i > 0 {
948 write!(f, ", ")?;
949 }
950 child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
951 }
952 write!(f, ")")
953 }
954 GuardExpr::Not(child) => {
955 write!(f, "not(")?;
956 child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
957 write!(f, ")")
958 }
959 }
960 }
961}
962
963impl fmt::Display for GuardExpr {
964 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
965 self.fmt_with_ctx(f, GuardDisplayContext::Root)
966 }
967}
968
969impl fmt::Display for Step {
970 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
971 if let Some(expr) = &self.guard {
972 write!(f, "[{}] ", expr)?;
973 }
974 write!(f, "{}", self.kind)
975 }
976}
977
978#[cfg(test)]
979mod tests {
980 use super::*;
981 #[test]
994 fn statement_keywords_match_pest_grammar() {
995 use pest_meta::{ast::Expr, parser};
996 use std::collections::{HashMap, HashSet};
997
998 let pest_src = include_str!("dsl.pest");
999 let pairs = parser::parse(parser::Rule::grammar_rules, pest_src)
1000 .expect("dsl.pest must parse as a pest grammar");
1001 let rules = parser::consume_rules(pairs).expect("dsl.pest rules must consume");
1002 let by_name: HashMap<&str, &Expr> = rules
1003 .iter()
1004 .map(|rule| (rule.name.as_str(), &rule.expr))
1005 .collect();
1006
1007 let mut all_literals = HashSet::new();
1008 for rule in &rules {
1009 for node in rule.expr.iter_top_down() {
1010 match node {
1011 Expr::Str(literal) | Expr::Insens(literal) => {
1012 all_literals.insert(literal.clone());
1013 }
1014 _ => {}
1015 }
1016 }
1017 }
1018 for kw in STRUCTURAL_KEYWORDS {
1019 assert!(
1020 all_literals.contains(*kw),
1021 "keyword {kw} in STRUCTURAL_KEYWORDS missing from dsl.pest"
1022 );
1023 }
1024
1025 let mut seen = HashSet::new();
1026 let mut stack = vec!["element".to_string(), "block_element".to_string()];
1027 let mut stmt_literals = HashSet::new();
1028 while let Some(name) = stack.pop() {
1029 if !seen.insert(name.clone()) {
1030 continue;
1031 }
1032 let Some(expr) = by_name.get(name.as_str()) else {
1033 continue;
1034 };
1035 for node in expr.iter_top_down() {
1036 match node {
1037 Expr::Str(literal) | Expr::Insens(literal) => {
1038 stmt_literals.insert(literal.clone());
1039 }
1040 Expr::Ident(dependency) => stack.push(dependency.clone()),
1041 _ => {}
1042 }
1043 }
1044 }
1045 assert!(
1046 seen.contains("element") && seen.contains("block_element"),
1047 "grammar must define element and block_element instruction rules"
1048 );
1049 for kw in stmt_literals {
1050 if kw.len() > 1 && kw.chars().all(|c| c.is_ascii_uppercase()) {
1051 assert!(
1052 crate::Command::is_statement_keyword(&kw)
1053 || CLAUSE_KEYWORDS.contains(&kw.as_str()),
1054 "uppercase literal \"{kw}\" reachable from dsl.pest instruction rules is not a registered statement or clause keyword"
1055 );
1056 }
1057 }
1058 }
1059
1060 #[test]
1064 fn command_names_group_by_category() {
1065 for command in COMMANDS {
1066 let name = command.as_str();
1067 let Some((prefix, _)) = name.split_once('_') else {
1068 continue;
1069 };
1070 assert!(
1071 COMMAND_CATEGORIES.contains(&prefix),
1072 "command {name} introduces unregistered category {prefix}; add it to COMMAND_CATEGORIES"
1073 );
1074 }
1075 }
1076}