Skip to main content

oxdock_parser/
ast.rs

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/// One module's function surface for parse-time call resolution: the base
13/// names it exports. RPN eligibility needs no table: calls compile
14/// generically and the runtime registry gates evaluation per entry.
15#[derive(Debug, Clone, Default)]
16pub struct ModuleFuncs {
17    /// Base function names exported by the module.
18    pub functions: HashSet<String>,
19}
20
21/// Parse-time function provenance: module name to surface. A `None` entry
22/// marks an opaque module (declared but membership unknown, e.g. the
23/// `oxdock!` macro's `modules:` prefix): qualified calls pass through for
24/// runtime checking, and a lone opaque import determines bare-call targets.
25#[derive(Debug, Clone, Default)]
26pub struct ModuleTable {
27    pub modules: HashMap<String, Option<ModuleFuncs>>,
28}
29
30impl ModuleTable {
31    /// Base names exported by every known (non-opaque) module. Backs the
32    /// `FUNC` shadow check: a script definition colliding with any of these
33    /// fails at parse time, exactly like the old flat reserved set.
34    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)]
44pub enum Command {
45    InheritEnv,
46    Workdir,
47    Workspace,
48    Env,
49    Echo,
50    Run,
51    Copy,
52    WithIo,
53    CopyGit,
54    HashSha256,
55    Symlink,
56    Mkdir,
57    Ls,
58    Cwd,
59    Read,
60    ReadLine,
61    Write,
62    Append,
63    Expand,
64    AssertEq,
65    AssertContains,
66    Exit,
67    Async,
68    Timeout,
69    Sleep,
70}
71
72pub const COMMANDS: &[Command] = &[
73    Command::InheritEnv,
74    Command::Workdir,
75    Command::Workspace,
76    Command::Env,
77    Command::Echo,
78    Command::Run,
79    Command::Copy,
80    Command::WithIo,
81    Command::CopyGit,
82    Command::HashSha256,
83    Command::Symlink,
84    Command::Mkdir,
85    Command::Ls,
86    Command::Cwd,
87    Command::Read,
88    Command::ReadLine,
89    Command::Write,
90    Command::Append,
91    Command::Expand,
92    Command::AssertEq,
93    Command::AssertContains,
94    Command::Exit,
95    Command::Timeout,
96    Command::Sleep,
97];
98
99impl Command {
100    pub const fn as_str(self) -> &'static str {
101        match self {
102            Command::InheritEnv => "INHERIT_ENV",
103            Command::Workdir => "WORKDIR",
104            Command::Workspace => "WORKSPACE",
105            Command::Env => "ENV",
106            Command::Echo => "ECHO",
107            Command::Run => "RUN",
108            Command::Copy => "COPY",
109            Command::WithIo => "WITH_IO",
110            Command::CopyGit => "COPY_GIT",
111            Command::HashSha256 => "HASH_SHA256",
112            Command::Symlink => "SYMLINK",
113            Command::Mkdir => "MKDIR",
114            Command::Ls => "LS",
115            Command::Cwd => "CWD",
116            Command::Read => "READ",
117            Command::ReadLine => "READ_LINE",
118            Command::Write => "WRITE",
119            Command::Append => "APPEND",
120            Command::Expand => "EXPAND",
121            Command::AssertEq => "ASSERT_EQ",
122            Command::AssertContains => "ASSERT_CONTAINS",
123            Command::Exit => "EXIT",
124            Command::Async => "ASYNC",
125            Command::Timeout => "TIMEOUT",
126            Command::Sleep => "SLEEP",
127        }
128    }
129
130    pub const fn syntax(self) -> &'static str {
131        match self {
132            Command::InheritEnv => "INHERIT_ENV [KEY1, KEY2, ...]",
133            Command::Workdir => "WORKDIR <path>",
134            Command::Workspace => "WORKSPACE SNAPSHOT|LOCAL",
135            Command::Env => "ENV KEY=value",
136            Command::Echo => "ECHO <message>",
137            Command::Run => "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
138            Command::Copy => "COPY [--from-current-workspace] <from> <to>",
139            Command::CopyGit => "COPY_GIT [--include-dirty] <rev> <src> <dst>",
140            Command::WithIo => "WITH_IO [bindings] [command | { block }]",
141            Command::HashSha256 => "HASH_SHA256 <path>",
142            Command::Symlink => "SYMLINK <from> <to>",
143            Command::Mkdir => "MKDIR <path>",
144            Command::Ls => "LS [<path>]",
145            Command::Cwd => "CWD",
146            Command::Read => "READ [<path>]",
147            Command::ReadLine => "READ_LINE $var",
148            Command::Write => "WRITE <path> [<contents>]",
149            Command::Append => "APPEND <path> [<contents>]",
150            Command::Expand => "EXPAND [<path>] [<KEY=val> ...]",
151            Command::AssertEq => "ASSERT_EQ [--hash <sha256>] <actual> <expected>",
152            Command::AssertContains => "ASSERT_CONTAINS <haystack> <needle>",
153            Command::Exit => "EXIT <code>",
154            Command::Async => "ASYNC <command...> | ASYNC { <commands> }",
155            Command::Timeout => {
156                "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> }"
157            }
158            Command::Sleep => "SLEEP <duration>",
159        }
160    }
161
162    pub const fn expects_inner_command(self) -> bool {
163        matches!(self, Command::WithIo | Command::Async | Command::Timeout)
164    }
165
166    pub fn parse(s: &str) -> Option<Self> {
167        match s {
168            "INHERIT_ENV" => Some(Command::InheritEnv),
169            "WORKDIR" => Some(Command::Workdir),
170            "WORKSPACE" => Some(Command::Workspace),
171            "ENV" => Some(Command::Env),
172            "ECHO" => Some(Command::Echo),
173            "RUN" => Some(Command::Run),
174            "COPY" => Some(Command::Copy),
175            "WITH_IO" => Some(Command::WithIo),
176            "COPY_GIT" => Some(Command::CopyGit),
177            "HASH_SHA256" => Some(Command::HashSha256),
178            "SYMLINK" => Some(Command::Symlink),
179            "MKDIR" => Some(Command::Mkdir),
180            "LS" => Some(Command::Ls),
181            "CWD" => Some(Command::Cwd),
182            "READ" => Some(Command::Read),
183            "READ_LINE" => Some(Command::ReadLine),
184            "WRITE" => Some(Command::Write),
185            "APPEND" => Some(Command::Append),
186            "EXPAND" => Some(Command::Expand),
187            "ASSERT_EQ" => Some(Command::AssertEq),
188            "ASSERT_CONTAINS" => Some(Command::AssertContains),
189            "EXIT" => Some(Command::Exit),
190            "ASYNC" => Some(Command::Async),
191            "TIMEOUT" => Some(Command::Timeout),
192            "SLEEP" => Some(Command::Sleep),
193            _ => None,
194        }
195    }
196
197    /// Whether a bare word opens a new statement in either parse pathway.
198    /// Covers plain commands plus [`STRUCTURAL_KEYWORDS`]. Single source
199    /// of truth for `Display` quoting and token-stream line splitting, which
200    /// must agree or round-trips break.
201    pub(crate) fn is_statement_keyword(s: &str) -> bool {
202        Command::parse(s).is_some() || STRUCTURAL_KEYWORDS.contains(&s)
203    }
204}
205
206/// Statement starters parsed by PEG rules rather than command lowering
207/// (`dsl.pest`, token-walker branches), living outside the [`Command`]
208/// enum. Canonical registry backing `Command::is_statement_keyword`;
209/// iterate this (plus [`crate::all_metadata`] names) instead of
210/// hardcoding keyword lists elsewhere.
211pub const STRUCTURAL_KEYWORDS: &[&str] = &[
212    KEYWORD_LET,
213    KEYWORD_FOR,
214    KEYWORD_IF,
215    KEYWORD_ELSE,
216    KEYWORD_ASYNC,
217    KEYWORD_AWAIT,
218    KEYWORD_CANCEL,
219    KEYWORD_FUNC,
220    KEYWORD_RETURN,
221    KEYWORD_WHILE,
222    KEYWORD_BREAK,
223    KEYWORD_CONTINUE,
224    KEYWORD_IMPORT,
225    KEYWORD_EXPORT,
226];
227
228/// Clause keywords that open no statement and need no `Display` quoting
229/// (`IN` only heads `FOR` iterations). Declared alongside
230/// [`STRUCTURAL_KEYWORDS`] so grammar-conformance tests never hardcode
231/// exception lists of their own.
232pub const CLAUSE_KEYWORDS: &[&str] = &["IN"];
233
234#[derive(Copy, Clone, Debug, Eq, PartialEq)]
235pub enum PlatformGuard {
236    Unix,
237    Windows,
238    Macos,
239    Linux,
240}
241
242#[derive(Debug, Clone, Eq, PartialEq)]
243pub enum Guard {
244    Platform { target: PlatformGuard },
245    EnvExists { key: String },
246    EnvEquals { key: String, value: String },
247    StaticBool { value: String },
248}
249
250#[derive(Debug, Clone, Eq, PartialEq)]
251pub enum GuardExpr {
252    Predicate(Guard),
253    All(Vec<GuardExpr>),
254    Or(Vec<GuardExpr>),
255    Not(Box<GuardExpr>),
256}
257
258impl GuardExpr {
259    pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
260        let mut flat = Vec::new();
261        for expr in exprs {
262            match expr {
263                GuardExpr::All(children) => flat.extend(children),
264                other => flat.push(other),
265            }
266        }
267        match flat.len() {
268            0 => panic!("GuardExpr::all requires at least one expression"),
269            1 => flat.into_iter().next().unwrap(),
270            _ => GuardExpr::All(flat),
271        }
272    }
273
274    pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
275        let mut flat = Vec::new();
276        for expr in exprs {
277            match expr {
278                GuardExpr::Or(children) => flat.extend(children),
279                other => flat.push(other),
280            }
281        }
282        match flat.len() {
283            0 => panic!("GuardExpr::or requires at least one expression"),
284            1 => flat.into_iter().next().unwrap(),
285            _ => GuardExpr::Or(flat),
286        }
287    }
288
289    pub fn invert(expr: GuardExpr) -> GuardExpr {
290        match expr {
291            GuardExpr::Not(inner) => *inner,
292            other => GuardExpr::Not(Box::new(other)),
293        }
294    }
295}
296
297impl std::ops::Not for GuardExpr {
298    type Output = GuardExpr;
299
300    fn not(self) -> GuardExpr {
301        match self {
302            GuardExpr::Not(inner) => *inner,
303            other => GuardExpr::Not(Box::new(other)),
304        }
305    }
306}
307
308impl From<Guard> for GuardExpr {
309    fn from(guard: Guard) -> Self {
310        GuardExpr::Predicate(guard)
311    }
312}
313
314/// A command argument — either an expandable string or an expression.
315#[derive(Debug, Clone, PartialEq)]
316pub enum Arg {
317    /// Expandable string. The `bool` indicates whether the argument was
318    /// quoted in the source (`true`) or unquoted (`false`). Quoted arguments
319    /// that start with `--` are positional, not flags.
320    String(String, bool),
321    /// Expression — resolved at runtime via evaluate_expr.
322    Expr(Expr),
323    /// Mixed literal/expression value (e.g. `KEY={{ $x }} tail`). Fragments
324    /// resolve independently at runtime and concatenate with no added
325    /// separator — inter-fragment gaps are already materialized as `Text`.
326    Parts(Vec<ArgPart>),
327}
328
329/// One fragment of a mixed [`Arg::Parts`] value.
330#[derive(Debug, Clone, PartialEq)]
331pub enum ArgPart {
332    /// Literal text. The `bool` marks source-quoted regions (exact bytes);
333    /// unquoted text carries single-space-normalized gaps.
334    Text(String, bool),
335    /// Typed expression — resolved via evaluate_expr, never stringified.
336    Expr(Expr),
337}
338
339impl Arg {
340    pub fn as_str(&self) -> &str {
341        match self {
342            Arg::String(s, _) => s,
343            // Expressions and mixed values have no single borrowed string;
344            // use `render()` for an owned display form.
345            Arg::Expr(_) | Arg::Parts(_) => "",
346        }
347    }
348
349    /// Owned display form: `String` verbatim, `Expr` as source (`$x`),
350    /// `Parts` as fragment concatenation. Used for diagnostics and Display;
351    /// runtime resolution must match on variants instead (see resolve_arg).
352    pub fn render(&self) -> String {
353        match self {
354            Arg::String(s, _) => s.clone(),
355            Arg::Expr(e) => e.to_string(),
356            Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
357        }
358    }
359
360    pub fn is_quoted(&self) -> bool {
361        matches!(self, Arg::String(_, true))
362    }
363}
364
365impl ArgPart {
366    pub fn render(&self) -> String {
367        match self {
368            ArgPart::Text(s, _) => s.clone(),
369            ArgPart::Expr(e) => e.to_string(),
370        }
371    }
372}
373
374impl From<String> for Arg {
375    fn from(s: String) -> Self {
376        Arg::String(s, false)
377    }
378}
379
380impl From<&str> for Arg {
381    fn from(s: &str) -> Self {
382        Arg::String(s.to_string(), false)
383    }
384}
385
386impl std::fmt::Display for Arg {
387    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388        match self {
389            Arg::String(s, _) => write!(f, "{}", s),
390            Arg::Expr(e) => write!(f, "{}", e),
391            Arg::Parts(parts) => {
392                for part in parts {
393                    match part {
394                        ArgPart::Text(s, _) => write!(f, "{}", s)?,
395                        ArgPart::Expr(e) => write!(f, "{}", e)?,
396                    }
397                }
398                Ok(())
399            }
400        }
401    }
402}
403
404impl AsRef<str> for Arg {
405    fn as_ref(&self) -> &str {
406        self.as_str()
407    }
408}
409
410impl PartialEq<str> for Arg {
411    fn eq(&self, other: &str) -> bool {
412        self.as_str() == other
413    }
414}
415
416impl PartialEq<&str> for Arg {
417    fn eq(&self, other: &&str) -> bool {
418        self.as_str() == *other
419    }
420}
421
422#[derive(Debug, Clone, Copy, Eq, PartialEq)]
423pub enum IoStream {
424    Stdin,
425    Stdout,
426    Stderr,
427}
428
429#[derive(Debug, Clone, Eq, PartialEq)]
430pub struct IoBinding {
431    pub stream: IoStream,
432    pub pipe: Option<PipeTarget>,
433}
434
435/// A pipe endpoint for a `WITH_IO` binding: either a literal `pipe:name`
436/// or a `$var` holding a `PIPE` value, resolved against the live pipe
437/// registry when the step runs.
438#[derive(Debug, Clone, Eq, PartialEq)]
439pub enum PipeTarget {
440    Name(String),
441    Var(String),
442}
443
444/// Value-model re-exports: the word, its payload, type descriptors, and the
445/// export hook live in [`crate::value`], the single representation for
446/// every type.
447pub use crate::value::{
448    OxDockType, TypeDescriptor, Value, ValuePayload, clone_boxed, clone_copy, clone_shared,
449    drop_boxed, drop_noop, drop_shared, eq_boxed, eq_inline, eq_shared, fmt_boxed, fmt_inline,
450    fmt_shared, load_inline, startup_descriptors, store_inline, type_anchor, unshare_boxed,
451    unshare_inline, unshare_shared,
452};
453
454#[derive(Debug, Clone, Copy, Eq, PartialEq)]
455pub enum CompareOp {
456    Eq,
457    Ne,
458    Lt,
459    Le,
460    Gt,
461    Ge,
462}
463
464#[derive(Debug, Clone, Copy, Eq, PartialEq)]
465pub enum ArithOp {
466    Add,
467    Sub,
468    Mul,
469    Div,
470}
471
472/// Flat stack-machine op for expression-local arithmetic/comparison.
473///
474/// Lowering folds constant subtrees to `Expr::Literal` and compiles dynamic
475/// arithmetic/comparison subtrees to post-order `Vec<MathOp>` so the runtime
476/// executes a single instruction loop instead of recursive `Box` walking.
477/// `Call` covers value-semantics functions only (`INT`, `FLOAT`, `GLOB`,
478/// `LOAD_TOML`, `LOAD_JSON`); `INSPECT($var)` uses `Inspect` to preserve the
479/// variable identifier (pre-evaluating to `Value` would lose the name).
480#[derive(Debug, Clone, PartialEq)]
481pub enum MathOp {
482    PushConst(Value),
483    LoadVar(String),
484    LoadEnv(String),
485    LoadKeyPath { base: String, keys: Vec<String> },
486    Call { name: String, arity: usize },
487    Inspect(String),
488    Neg,
489    Add,
490    Sub,
491    Mul,
492    Div,
493    Lt,
494    Le,
495    Gt,
496    Ge,
497    Eq,
498    Ne,
499}
500
501#[derive(Debug, Clone, Copy, Eq, PartialEq)]
502pub enum LogicalOp {
503    And,
504    Or,
505}
506
507#[derive(Debug, Clone, PartialEq)]
508pub enum Expr {
509    Literal(Value),
510    Var(String),
511    /// Environment read (`env:KEY`): resolves against the script
512    /// environment at evaluation time.
513    Env(String),
514    KeyPath {
515        base: String,
516        keys: Vec<String>,
517    },
518    List(Vec<Expr>),
519    Map(Vec<(String, Expr)>),
520    Call {
521        name: String,
522        args: Vec<Expr>,
523    },
524    /// Variable inspection (`INSPECT($var)`): carries the variable name
525    /// unevaluated so evaluation can snapshot the binding. Produced only by
526    /// the parser for the exact `INSPECT` name; evaluators match on this
527    /// variant and never on a function-name string.
528    Inspect(String),
529    Compare {
530        op: CompareOp,
531        left: Box<Expr>,
532        right: Box<Expr>,
533    },
534    Arithmetic {
535        op: ArithOp,
536        left: Box<Expr>,
537        right: Box<Expr>,
538    },
539    /// Lowering-optimized form: folded literals stay `Literal`, dynamic
540    /// arithmetic/comparison subtrees arrive here as flat RPN.
541    CompiledMath(Vec<MathOp>),
542    /// Lowering-only intermediate staging `9223372036854775808` (the unsigned
543    /// half of `i64::MIN`). Valid only as the direct child of unary `-`;
544    /// any instance reaching lowering completion bails integer overflow.
545    UnsignedIntBoundary(u64),
546    Not(Box<Expr>),
547    Logical {
548        op: LogicalOp,
549        left: Box<Expr>,
550        right: Box<Expr>,
551    },
552}
553
554#[derive(Debug, Clone, PartialEq)]
555pub struct Step {
556    pub guard: Option<GuardExpr>,
557    pub kind: StepKind,
558    pub scope_enter: usize,
559    pub scope_exit: usize,
560}
561
562#[derive(Debug, Clone, Eq, PartialEq)]
563pub enum WorkspaceTarget {
564    Snapshot,
565    Local,
566}
567
568fn platform_matches(target: PlatformGuard) -> bool {
569    #[allow(clippy::disallowed_macros)]
570    match target {
571        PlatformGuard::Unix => cfg!(unix),
572        PlatformGuard::Windows => cfg!(windows),
573        PlatformGuard::Macos => cfg!(target_os = "macos"),
574        PlatformGuard::Linux => cfg!(target_os = "linux"),
575    }
576}
577
578pub trait EnvLookup {
579    fn get_env(&self, key: &str) -> Option<&str>;
580}
581
582impl EnvLookup for HashMap<String, String> {
583    fn get_env(&self, key: &str) -> Option<&str> {
584        self.get(key).map(|s| s.as_str())
585    }
586}
587
588impl EnvLookup for Arc<HashMap<String, String>> {
589    fn get_env(&self, key: &str) -> Option<&str> {
590        (**self).get_env(key)
591    }
592}
593
594pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
595    match guard {
596        Guard::Platform { target } => platform_matches(*target),
597        Guard::EnvExists { key } => env.get_env(key).map(|v| !v.is_empty()).unwrap_or(false),
598        Guard::EnvEquals { key, value } => env
599            .get_env(key)
600            .map(|v| v == value.as_str())
601            .unwrap_or(false),
602        Guard::StaticBool { value } => value.parse::<bool>().unwrap_or(false),
603    }
604}
605
606pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
607    match expr {
608        GuardExpr::Predicate(guard) => guard_allows(guard, env),
609        GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
610        GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
611        GuardExpr::Not(child) => !guard_expr_allows(child, env),
612    }
613}
614
615pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
616    match expr {
617        Some(e) => guard_expr_allows(e, env),
618        None => true,
619    }
620}
621
622impl fmt::Display for PlatformGuard {
623    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
624        match self {
625            PlatformGuard::Unix => write!(f, "unix"),
626            PlatformGuard::Windows => write!(f, "windows"),
627            PlatformGuard::Macos => write!(f, "macos"),
628            PlatformGuard::Linux => write!(f, "linux"),
629        }
630    }
631}
632
633impl fmt::Display for Guard {
634    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
635        match self {
636            Guard::Platform { target } => write!(f, "{}", target),
637            Guard::EnvExists { key } => write!(f, "env:{}", key),
638            Guard::EnvEquals { key, value } => write!(f, "eq(env:{}, {})", key, value),
639            Guard::StaticBool { value } => write!(f, "bool:{}", value),
640        }
641    }
642}
643
644impl fmt::Display for WorkspaceTarget {
645    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
646        match self {
647            WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
648            WorkspaceTarget::Local => write!(f, "LOCAL"),
649        }
650    }
651}
652
653impl fmt::Display for Expr {
654    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
655        match self {
656            Expr::Literal(v) => write!(f, "{}", v),
657            Expr::Var(name) => write!(f, "${}", name),
658            Expr::Env(key) => write!(f, "env:{}", key),
659            Expr::KeyPath { base, keys } => {
660                write!(f, "${}", base)?;
661                for key in keys {
662                    write!(f, ".{}", key)?;
663                }
664                Ok(())
665            }
666            Expr::Call { name, args } => {
667                write!(f, "{}(", name)?;
668                for (i, arg) in args.iter().enumerate() {
669                    if i > 0 {
670                        write!(f, ", ")?;
671                    }
672                    write!(f, "{}", arg)?;
673                }
674                write!(f, ")")
675            }
676            Expr::Inspect(var) => write!(f, "INSPECT(${})", var),
677            Expr::List(items) => {
678                write!(f, "[")?;
679                for (i, item) in items.iter().enumerate() {
680                    if i > 0 {
681                        write!(f, ", ")?;
682                    }
683                    write!(f, "{}", item)?;
684                }
685                write!(f, "]")
686            }
687            Expr::Map(entries) => {
688                write!(f, "{{")?;
689                for (i, (key, val)) in entries.iter().enumerate() {
690                    if i > 0 {
691                        write!(f, ", ")?;
692                    }
693                    write!(f, "\"{}\": {}", key, val)?;
694                }
695                write!(f, "}}")
696            }
697            Expr::Compare { op, left, right } => {
698                write!(f, "{} {} {}", left, op, right)
699            }
700            Expr::Arithmetic { op, left, right } => {
701                write!(f, "({} {} {})", left, op, right)
702            }
703            Expr::CompiledMath(ops) => {
704                write!(f, "{}", format_compiled_math(ops))
705            }
706            Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
707            Expr::Not(inner) => {
708                // Parenthesize compound operands so Display round-trips:
709                // `!(a == b)` must not render as `!a == b` (= `(!a) == b`).
710                match inner.as_ref() {
711                    Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
712                        write!(f, "!({})", inner)
713                    }
714                    _ => write!(f, "!{}", inner),
715                }
716            }
717            Expr::Logical { op, left, right } => {
718                write!(f, "({} {} {})", left, op, right)
719            }
720        }
721    }
722}
723
724impl fmt::Display for CompareOp {
725    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
726        match self {
727            CompareOp::Eq => write!(f, "=="),
728            CompareOp::Ne => write!(f, "!="),
729            CompareOp::Lt => write!(f, "<"),
730            CompareOp::Le => write!(f, "<="),
731            CompareOp::Gt => write!(f, ">"),
732            CompareOp::Ge => write!(f, ">="),
733        }
734    }
735}
736
737impl fmt::Display for ArithOp {
738    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
739        match self {
740            ArithOp::Add => write!(f, "+"),
741            ArithOp::Sub => write!(f, "-"),
742            ArithOp::Mul => write!(f, "*"),
743            ArithOp::Div => write!(f, "/"),
744        }
745    }
746}
747
748/// Render flat RPN back to parenthesized infix so `Display` round-trips
749/// through the parser with identical semantics. Parentheses are emitted
750/// unconditionally around binary/unary ops; redundant parens parse to the
751/// same tree, which is what round-trip requires.
752fn format_compiled_math(ops: &[MathOp]) -> String {
753    let mut stack: Vec<String> = Vec::new();
754    for op in ops {
755        match op {
756            MathOp::PushConst(v) => stack.push(format!("{}", v)),
757            MathOp::LoadVar(name) => stack.push(format!("${}", name)),
758            MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
759            MathOp::LoadKeyPath { base, keys } => {
760                let mut s = format!("${}", base);
761                for key in keys {
762                    s.push('.');
763                    s.push_str(key);
764                }
765                stack.push(s);
766            }
767            MathOp::Call { name, arity } => {
768                let mut args = Vec::new();
769                for _ in 0..*arity {
770                    args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
771                }
772                args.reverse();
773                stack.push(format!("{}({})", name, args.join(", ")));
774            }
775            MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
776            MathOp::Neg => {
777                let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
778                stack.push(format!("(-{})", inner));
779            }
780            MathOp::Add => push_bin(&mut stack, "+"),
781            MathOp::Sub => push_bin(&mut stack, "-"),
782            MathOp::Mul => push_bin(&mut stack, "*"),
783            MathOp::Div => push_bin(&mut stack, "/"),
784            MathOp::Lt => push_bin(&mut stack, "<"),
785            MathOp::Le => push_bin(&mut stack, "<="),
786            MathOp::Gt => push_bin(&mut stack, ">"),
787            MathOp::Ge => push_bin(&mut stack, ">="),
788            MathOp::Eq => push_bin(&mut stack, "=="),
789            MathOp::Ne => push_bin(&mut stack, "!="),
790        }
791    }
792    if stack.len() == 1 {
793        let mut items = stack;
794        items.pop().unwrap_or_else(|| "<empty>".to_string())
795    } else {
796        stack.join(" ")
797    }
798}
799
800fn push_bin(stack: &mut Vec<String>, op: &str) {
801    let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
802    let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
803    stack.push(format!("({} {} {})", left, op, right));
804}
805
806impl fmt::Display for LogicalOp {
807    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
808        match self {
809            LogicalOp::And => write!(f, "&&"),
810            LogicalOp::Or => write!(f, "||"),
811        }
812    }
813}
814
815enum GuardDisplayContext {
816    Root,
817    InAnyArg,
818    InNot,
819    InAll,
820}
821
822impl GuardExpr {
823    fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
824        match self {
825            GuardExpr::Predicate(guard) => write!(f, "{}", guard),
826            GuardExpr::All(children) => {
827                let wrap = matches!(
828                    ctx,
829                    GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
830                ) && children.len() > 1;
831                if wrap {
832                    write!(f, "(")?;
833                }
834                for (i, child) in children.iter().enumerate() {
835                    if i > 0 {
836                        write!(f, ", ")?;
837                    }
838                    child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
839                }
840                if wrap {
841                    write!(f, ")")?;
842                }
843                Ok(())
844            }
845            GuardExpr::Or(children) => {
846                write!(f, "any(")?;
847                for (i, child) in children.iter().enumerate() {
848                    if i > 0 {
849                        write!(f, ", ")?;
850                    }
851                    child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
852                }
853                write!(f, ")")
854            }
855            GuardExpr::Not(child) => {
856                write!(f, "not(")?;
857                child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
858                write!(f, ")")
859            }
860        }
861    }
862}
863
864impl fmt::Display for GuardExpr {
865    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
866        self.fmt_with_ctx(f, GuardDisplayContext::Root)
867    }
868}
869
870impl fmt::Display for Step {
871    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
872        if let Some(expr) = &self.guard {
873            write!(f, "[{}] ", expr)?;
874        }
875        write!(f, "{}", self.kind)
876    }
877}
878
879#[cfg(test)]
880mod tests {
881    use super::*;
882    /// Bidirectional lock between `dsl.pest` and the keyword registry.
883    /// Fails CI if a statement keyword is renamed or deleted on either side.
884    /// The grammar is parsed with `pest_meta` (no line-format or rule-name
885    /// conventions): every [`STRUCTURAL_KEYWORDS`] entry must occur as a
886    /// string literal somewhere in the grammar, and every uppercase literal
887    /// reachable from the top-level instruction rules (`element`,
888    /// `block_element`, following rule references transitively) must
889    /// classify as a statement starter ([`Command::is_statement_keyword`])
890    /// or a clause keyword ([`CLAUSE_KEYWORDS`]). Type tags, argument
891    /// enums, and value-level function names validate at lowering time via
892    /// generic ident rules, so they are outside this test's scope by design
893    /// rather than via synthetic registries.
894    #[test]
895    fn statement_keywords_match_pest_grammar() {
896        use pest_meta::{ast::Expr, parser};
897        use std::collections::{HashMap, HashSet};
898
899        let pest_src = include_str!("dsl.pest");
900        let pairs = parser::parse(parser::Rule::grammar_rules, pest_src)
901            .expect("dsl.pest must parse as a pest grammar");
902        let rules = parser::consume_rules(pairs).expect("dsl.pest rules must consume");
903        let by_name: HashMap<&str, &Expr> = rules
904            .iter()
905            .map(|rule| (rule.name.as_str(), &rule.expr))
906            .collect();
907
908        let mut all_literals = HashSet::new();
909        for rule in &rules {
910            for node in rule.expr.iter_top_down() {
911                match node {
912                    Expr::Str(literal) | Expr::Insens(literal) => {
913                        all_literals.insert(literal.clone());
914                    }
915                    _ => {}
916                }
917            }
918        }
919        for kw in STRUCTURAL_KEYWORDS {
920            assert!(
921                all_literals.contains(*kw),
922                "keyword {kw} in STRUCTURAL_KEYWORDS missing from dsl.pest"
923            );
924        }
925
926        let mut seen = HashSet::new();
927        let mut stack = vec!["element".to_string(), "block_element".to_string()];
928        let mut stmt_literals = HashSet::new();
929        while let Some(name) = stack.pop() {
930            if !seen.insert(name.clone()) {
931                continue;
932            }
933            let Some(expr) = by_name.get(name.as_str()) else {
934                continue;
935            };
936            for node in expr.iter_top_down() {
937                match node {
938                    Expr::Str(literal) | Expr::Insens(literal) => {
939                        stmt_literals.insert(literal.clone());
940                    }
941                    Expr::Ident(dependency) => stack.push(dependency.clone()),
942                    _ => {}
943                }
944            }
945        }
946        assert!(
947            seen.contains("element") && seen.contains("block_element"),
948            "grammar must define element and block_element instruction rules"
949        );
950        for kw in stmt_literals {
951            if kw.len() > 1 && kw.chars().all(|c| c.is_ascii_uppercase()) {
952                assert!(
953                    crate::Command::is_statement_keyword(&kw)
954                        || CLAUSE_KEYWORDS.contains(&kw.as_str()),
955                    "uppercase literal \"{kw}\" reachable from dsl.pest instruction rules is not a registered statement or clause keyword"
956                );
957            }
958        }
959    }
960}