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/// Core command vocabulary: every statement keyword the grammar accepts.
44/// Multi-word commands group by domain prefix (`CATEGORY_ACTION`):
45/// `LIST_APPEND`, `READ_LINE`, `ASSERT_EQ`, `COPY_GIT`. The first
46/// underscore-separated segment is the category; introducing a command
47/// under a new category means adding its prefix to
48/// `COMMAND_CATEGORIES` below, so new groupings stay deliberate.
49/// Single-word commands carry no category. Frozen names never change.
50#[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
108/// Registered multi-word command categories (first underscore-separated
109/// segment). A new `CATEGORY_ACTION` command registers its prefix here;
110/// reusing an existing category needs no change. Single-word commands
111/// carry no category and are unaffected.
112pub 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    /// Whether a bare word opens a new statement in either parse pathway.
219    /// Covers plain commands plus [`STRUCTURAL_KEYWORDS`]. Single source
220    /// of truth for `Display` quoting and token-stream line splitting, which
221    /// must agree or round-trips break.
222    pub(crate) fn is_statement_keyword(s: &str) -> bool {
223        Command::parse(s).is_some() || STRUCTURAL_KEYWORDS.contains(&s)
224    }
225}
226
227/// Statement starters parsed by PEG rules rather than command lowering
228/// (`dsl.pest`, token-walker branches), living outside the [`Command`]
229/// enum. Canonical registry backing `Command::is_statement_keyword`;
230/// iterate this (plus [`crate::all_metadata`] names) instead of
231/// hardcoding keyword lists elsewhere.
232pub 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
249/// Clause keywords that open no statement and need no `Display` quoting
250/// (`IN` only heads `FOR` iterations). Declared alongside
251/// [`STRUCTURAL_KEYWORDS`] so grammar-conformance tests never hardcode
252/// exception lists of their own.
253pub 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/// A command argument : either an expandable string or an expression.
336#[derive(Debug, Clone, PartialEq)]
337pub enum Arg {
338    /// Expandable string. The `bool` indicates whether the argument was
339    /// quoted in the source (`true`) or unquoted (`false`). Quoted arguments
340    /// that start with `--` are positional, not flags.
341    String(String, bool),
342    /// Expression : resolved at runtime via evaluate_expr.
343    Expr(Expr),
344    /// Mixed literal/expression value (e.g. `KEY={{ $x }} tail`). Fragments
345    /// resolve independently at runtime and concatenate with no added
346    /// separator : inter-fragment gaps are already materialized as `Text`.
347    Parts(Vec<ArgPart>),
348}
349
350/// One fragment of a mixed [`Arg::Parts`] value.
351#[derive(Debug, Clone, PartialEq)]
352pub enum ArgPart {
353    /// Literal text. The `bool` marks source-quoted regions (exact bytes);
354    /// unquoted text carries single-space-normalized gaps.
355    Text(String, bool),
356    /// Typed expression : resolved via evaluate_expr, never stringified.
357    Expr(Expr),
358}
359
360impl Arg {
361    pub fn as_str(&self) -> &str {
362        match self {
363            Arg::String(s, _) => s,
364            // Expressions and mixed values have no single borrowed string;
365            // use `render()` for an owned display form.
366            Arg::Expr(_) | Arg::Parts(_) => "",
367        }
368    }
369
370    /// Owned display form: `String` verbatim, `Expr` as source (`$x`),
371    /// `Parts` as fragment concatenation. Used for diagnostics and Display;
372    /// runtime resolution must match on variants instead (see resolve_arg).
373    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/// A pipe endpoint for a `WITH_IO` binding: a `$var` holding a `PIPE`
457/// value, resolved against the live variable scope when the step runs.
458#[derive(Debug, Clone, Eq, PartialEq)]
459pub enum PipeTarget {
460    Var(String),
461}
462
463/// Value-model re-exports: the word, its payload, type descriptors, and the
464/// export hook live in [`crate::value`], the single representation for
465/// every type.
466pub 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/// Flat stack-machine op for expression-local arithmetic/comparison.
492///
493/// Lowering folds constant subtrees to `Expr::Literal` and compiles dynamic
494/// arithmetic/comparison subtrees to post-order `Vec<MathOp>` so the runtime
495/// executes a single instruction loop instead of recursive `Box` walking.
496/// `Call` covers value-semantics functions only (`INT`, `FLOAT`, `GLOB`,
497/// `LOAD_TOML`, `LOAD_JSON`); `INSPECT($var)` uses `Inspect` to preserve the
498/// variable identifier (pre-evaluating to `Value` would lose the name).
499#[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    /// Environment read (`env:KEY`): resolves against the script
531    /// environment at evaluation time.
532    Env(String),
533    KeyPath {
534        base: String,
535        keys: Vec<String>,
536    },
537    List(Vec<Expr>),
538    Map(Vec<(String, Expr)>),
539    /// Inline block (`LET $a: STRING = { RETURN "hi" }`): runs its steps in
540    /// a fresh scope when evaluated and yields the `RETURN` value
541    /// (fallthrough yields `""`, mirroring a zero-arg function body).
542    /// Parsed only where `map_literal` fails, so `{k: v}` stays a map.
543    Block(Vec<Step>),
544    Call {
545        name: String,
546        args: Vec<Expr>,
547    },
548    /// Variable inspection (`INSPECT($var)`): carries the variable name
549    /// unevaluated so evaluation can snapshot the binding. Produced only by
550    /// the parser for the exact `INSPECT` name; evaluators match on this
551    /// variant and never on a function-name string.
552    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    /// Lowering-optimized form: folded literals stay `Literal`, dynamic
564    /// arithmetic/comparison subtrees arrive here as flat RPN.
565    CompiledMath(Vec<MathOp>),
566    /// Fresh anonymous pipe backend (`LET $p: PIPE` with no initializer).
567    /// Evaluates to a pipe value keyed by a generated name that no
568    /// `pipe:` literal can spell, so bare declarations never collide
569    /// with named pipes. Transitional representation until backends
570    /// become owned handles.
571    FreshPipe,
572    /// Lowering-only intermediate staging `9223372036854775808` (the unsigned
573    /// half of `i64::MIN`). Valid only as the direct child of unary `-`;
574    /// any instance reaching lowering completion bails integer overflow.
575    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            // No expression syntax produces this (bare `LET` is a
752            // statement): render loudly non-round-trippable so a stray
753            // use fails at re-parse instead of aliasing a named pipe.
754            Expr::FreshPipe => write!(f, "<fresh pipe>"),
755            Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
756            Expr::Not(inner) => {
757                // Parenthesize compound operands so Display round-trips:
758                // `!(a == b)` must not render as `!a == b` (= `(!a) == b`).
759                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
797/// Render flat RPN back to parenthesized infix so `Display` round-trips
798/// through the parser with identical semantics. Parentheses are emitted
799/// unconditionally around binary/unary ops; redundant parens parse to the
800/// same tree, which is what round-trip requires.
801fn 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    /// Bidirectional lock between `dsl.pest` and the keyword registry.
932    /// Fails CI if a statement keyword is renamed or deleted on either side.
933    /// The grammar is parsed with `pest_meta` (no line-format or rule-name
934    /// conventions): every [`STRUCTURAL_KEYWORDS`] entry must occur as a
935    /// string literal somewhere in the grammar, and every uppercase literal
936    /// reachable from the top-level instruction rules (`element`,
937    /// `block_element`, following rule references transitively) must
938    /// classify as a statement starter ([`Command::is_statement_keyword`])
939    /// or a clause keyword ([`CLAUSE_KEYWORDS`]). Type tags, argument
940    /// enums, and value-level function names validate at lowering time via
941    /// generic ident rules, so they are outside this test's scope by design
942    /// rather than via synthetic registries.
943    #[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    /// Category convention lock: every multi-word command groups under a
1011    /// registered `COMMAND_CATEGORIES` prefix. Adding a command under a
1012    /// new category fails here until the prefix registers (one line).
1013    #[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}