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