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