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_TYPE, 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    KEYWORD_TYPE,
249];
250
251/// Clause keywords that open no statement and need no `Display` quoting
252/// (`IN` only heads `FOR` iterations). Declared alongside
253/// [`STRUCTURAL_KEYWORDS`] so grammar-conformance tests never hardcode
254/// exception lists of their own.
255pub const CLAUSE_KEYWORDS: &[&str] = &["IN"];
256
257/// One namespaced guard predicate. Every guard in the language is a
258/// namespace plus an optional subject plus an optional value; the
259/// single disjointness rule compares the whole triple, so no domain
260/// ever needs its own table.
261///
262/// Construction (fixed by the parser, never hand-built):
263/// `os`/`bool` carry the value (`[os:linux]`), `env` carries the
264/// variable as subject (`[env:MODE]`, `[env:MODE=dev]`).
265/// `family:unix` and `family:windows` are accepted aliases lowered
266/// at parse to `any(os:macos, os:linux)` and `os:windows`
267/// respectively, so every platform check executes uniformly under
268/// the `os:` namespace.
269#[derive(Debug, Clone, Eq, PartialEq)]
270pub enum Guard {
271    Attr {
272        ns: Ns,
273        key: Option<String>,
274        val: Option<String>,
275    },
276}
277
278/// Guard namespaces: the closed set the parser accepts. Adding a
279/// namespace forces compile errors at every consumer (parse,
280/// display, evaluation) — never a silent `_` fallback.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
282pub enum Ns {
283    Os,
284    Arch,
285    Bool,
286    Env,
287}
288
289impl fmt::Display for Ns {
290    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
291        match self {
292            Ns::Os => write!(f, "os"),
293            Ns::Arch => write!(f, "arch"),
294            Ns::Bool => write!(f, "bool"),
295            Ns::Env => write!(f, "env"),
296        }
297    }
298}
299
300/// Every documented `std::env::consts::ARCH` value: the closed
301/// `arch:` domain. Shared by parser validation and coverage
302/// reasoning so the two can never disagree on membership.
303pub const ARCH_VALUES: &[&str] = &[
304    "aarch64",
305    "arm",
306    "arm64ec",
307    "avr",
308    "bpf",
309    "csky",
310    "hexagon",
311    "loongarch32",
312    "loongarch64",
313    "mips",
314    "mips32r6",
315    "mips64",
316    "mips64r6",
317    "msp430",
318    "nvptx64",
319    "powerpc",
320    "powerpc64",
321    "riscv32",
322    "riscv64",
323    "s390x",
324    "sparc",
325    "sparc64",
326    "wasm32",
327    "wasm64",
328    "x86",
329    "x86_64",
330    "xtensa",
331];
332
333#[derive(Debug, Clone, Eq, PartialEq)]
334pub enum GuardExpr {
335    Predicate(Guard),
336    All(Vec<GuardExpr>),
337    Or(Vec<GuardExpr>),
338    Not(Box<GuardExpr>),
339}
340
341impl GuardExpr {
342    pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
343        let mut flat = Vec::new();
344        for expr in exprs {
345            match expr {
346                GuardExpr::All(children) => flat.extend(children),
347                other => flat.push(other),
348            }
349        }
350        match flat.len() {
351            0 => panic!("GuardExpr::all requires at least one expression"),
352            1 => flat.into_iter().next().unwrap(),
353            _ => GuardExpr::All(flat),
354        }
355    }
356
357    pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
358        let mut flat = Vec::new();
359        for expr in exprs {
360            match expr {
361                GuardExpr::Or(children) => flat.extend(children),
362                other => flat.push(other),
363            }
364        }
365        match flat.len() {
366            0 => panic!("GuardExpr::or requires at least one expression"),
367            1 => flat.into_iter().next().unwrap(),
368            _ => GuardExpr::Or(flat),
369        }
370    }
371
372    pub fn invert(expr: GuardExpr) -> GuardExpr {
373        match expr {
374            GuardExpr::Not(inner) => *inner,
375            other => GuardExpr::Not(Box::new(other)),
376        }
377    }
378}
379
380impl std::ops::Not for GuardExpr {
381    type Output = GuardExpr;
382
383    fn not(self) -> GuardExpr {
384        match self {
385            GuardExpr::Not(inner) => *inner,
386            other => GuardExpr::Not(Box::new(other)),
387        }
388    }
389}
390
391impl From<Guard> for GuardExpr {
392    fn from(guard: Guard) -> Self {
393        GuardExpr::Predicate(guard)
394    }
395}
396
397/// A command argument : either an expandable string or an expression.
398#[derive(Debug, Clone, PartialEq)]
399pub enum Arg {
400    /// Expandable string. The `bool` indicates whether the argument was
401    /// quoted in the source (`true`) or unquoted (`false`). Quoted arguments
402    /// that start with `--` are positional, not flags.
403    String(String, bool),
404    /// Expression : resolved at runtime via evaluate_expr.
405    Expr(Expr),
406    /// Mixed literal/expression value (e.g. `KEY={{ $x }} tail`). Fragments
407    /// resolve independently at runtime and concatenate with no added
408    /// separator : inter-fragment gaps are already materialized as `Text`.
409    Parts(Vec<ArgPart>),
410}
411
412/// One fragment of a mixed [`Arg::Parts`] value.
413#[derive(Debug, Clone, PartialEq)]
414pub enum ArgPart {
415    /// Literal text. The `bool` marks source-quoted regions (exact bytes);
416    /// unquoted text carries single-space-normalized gaps.
417    Text(String, bool),
418    /// Typed expression : resolved via evaluate_expr, never stringified.
419    Expr(Expr),
420}
421
422impl Arg {
423    pub fn as_str(&self) -> &str {
424        match self {
425            Arg::String(s, _) => s,
426            // Expressions and mixed values have no single borrowed string;
427            // use `render()` for an owned display form.
428            Arg::Expr(_) | Arg::Parts(_) => "",
429        }
430    }
431
432    /// Owned display form: `String` verbatim, `Expr` as source (`$x`),
433    /// `Parts` as fragment concatenation. Used for diagnostics and Display;
434    /// runtime resolution must match on variants instead (see resolve_arg).
435    pub fn render(&self) -> String {
436        match self {
437            Arg::String(s, _) => s.clone(),
438            Arg::Expr(e) => e.to_string(),
439            Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
440        }
441    }
442
443    pub fn is_quoted(&self) -> bool {
444        matches!(self, Arg::String(_, true))
445    }
446}
447
448impl ArgPart {
449    pub fn render(&self) -> String {
450        match self {
451            ArgPart::Text(s, _) => s.clone(),
452            ArgPart::Expr(e) => e.to_string(),
453        }
454    }
455}
456
457impl From<String> for Arg {
458    fn from(s: String) -> Self {
459        Arg::String(s, false)
460    }
461}
462
463impl From<&str> for Arg {
464    fn from(s: &str) -> Self {
465        Arg::String(s.to_string(), false)
466    }
467}
468
469impl std::fmt::Display for Arg {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        match self {
472            Arg::String(s, _) => write!(f, "{}", s),
473            Arg::Expr(e) => write!(f, "{}", e),
474            Arg::Parts(parts) => {
475                for part in parts {
476                    match part {
477                        ArgPart::Text(s, _) => write!(f, "{}", s)?,
478                        ArgPart::Expr(e) => write!(f, "{}", e)?,
479                    }
480                }
481                Ok(())
482            }
483        }
484    }
485}
486
487impl AsRef<str> for Arg {
488    fn as_ref(&self) -> &str {
489        self.as_str()
490    }
491}
492
493impl PartialEq<str> for Arg {
494    fn eq(&self, other: &str) -> bool {
495        self.as_str() == other
496    }
497}
498
499impl PartialEq<&str> for Arg {
500    fn eq(&self, other: &&str) -> bool {
501        self.as_str() == *other
502    }
503}
504
505#[derive(Debug, Clone, Copy, Eq, PartialEq)]
506pub enum IoStream {
507    Stdin,
508    Stdout,
509    Stderr,
510}
511
512#[derive(Debug, Clone, Eq, PartialEq)]
513pub struct IoBinding {
514    pub stream: IoStream,
515    pub pipe: Option<PipeTarget>,
516}
517
518/// A pipe endpoint for a `WITH_IO` binding: a `$var` holding a `PIPE`
519/// value, resolved against the live variable scope when the step runs.
520#[derive(Debug, Clone, Eq, PartialEq)]
521pub enum PipeTarget {
522    Var(String),
523}
524
525/// Value-model re-exports: the word, its payload, type descriptors, and the
526/// export hook live in [`crate::value`], the single representation for
527/// every type.
528pub use crate::value::{
529    OxDockType, SemaphoreState, TypeDescriptor, Value, ValuePayload, clone_boxed, clone_copy,
530    clone_shared, drop_boxed, drop_noop, drop_shared, eq_boxed, eq_inline, eq_shared, fmt_boxed,
531    fmt_inline, fmt_shared, load_inline, startup_descriptors, store_inline, type_anchor,
532    unshare_boxed, unshare_inline, unshare_shared,
533};
534
535#[derive(Debug, Clone, Copy, Eq, PartialEq)]
536pub enum CompareOp {
537    Eq,
538    Ne,
539    Lt,
540    Le,
541    Gt,
542    Ge,
543}
544
545#[derive(Debug, Clone, Copy, Eq, PartialEq)]
546pub enum ArithOp {
547    Add,
548    Sub,
549    Mul,
550    Div,
551}
552
553/// Flat stack-machine op for expression-local arithmetic/comparison.
554///
555/// Lowering folds constant subtrees to `Expr::Literal` and compiles dynamic
556/// arithmetic/comparison subtrees to post-order `Vec<MathOp>` so the runtime
557/// executes a single instruction loop instead of recursive `Box` walking.
558/// `Call` covers value-semantics functions only (`INT`, `FLOAT`, `GLOB`,
559/// `LOAD_TOML`, `LOAD_JSON`); `INSPECT($var)` uses `Inspect` to preserve the
560/// variable identifier (pre-evaluating to `Value` would lose the name).
561#[derive(Debug, Clone, PartialEq)]
562pub enum MathOp {
563    PushConst(Value),
564    LoadVar(String),
565    LoadEnv(String),
566    LoadKeyPath { base: String, keys: Vec<String> },
567    Call { name: String, arity: usize },
568    Inspect(String),
569    Neg,
570    Add,
571    Sub,
572    Mul,
573    Div,
574    Lt,
575    Le,
576    Gt,
577    Ge,
578    Eq,
579    Ne,
580}
581
582#[derive(Debug, Clone, Copy, Eq, PartialEq)]
583pub enum LogicalOp {
584    And,
585    Or,
586}
587
588#[derive(Debug, Clone, PartialEq)]
589pub enum Expr {
590    Literal(Value),
591    Var(String),
592    /// Environment read (`env:KEY`): resolves against the script
593    /// environment at evaluation time.
594    Env(String),
595    KeyPath {
596        base: String,
597        keys: Vec<String>,
598    },
599    List(Vec<Expr>),
600    Map(Vec<(String, Expr)>),
601    /// Inline block (`LET $a: STRING = { RETURN "hi" }`): runs its steps in
602    /// a fresh scope when evaluated and yields the `RETURN` value
603    /// (fallthrough yields `""`, mirroring a zero-arg function body).
604    /// Parsed only where `map_literal` fails, so `{k: v}` stays a map.
605    Block(Vec<Step>),
606    Call {
607        name: String,
608        args: Vec<Expr>,
609    },
610    /// Variable inspection (`INSPECT($var)`): carries the variable name
611    /// unevaluated so evaluation can snapshot the binding. Produced only by
612    /// the parser for the exact `INSPECT` name; evaluators match on this
613    /// variant and never on a function-name string.
614    Inspect(String),
615    Compare {
616        op: CompareOp,
617        left: Box<Expr>,
618        right: Box<Expr>,
619    },
620    Arithmetic {
621        op: ArithOp,
622        left: Box<Expr>,
623        right: Box<Expr>,
624    },
625    /// Lowering-optimized form: folded literals stay `Literal`, dynamic
626    /// arithmetic/comparison subtrees arrive here as flat RPN.
627    CompiledMath(Vec<MathOp>),
628    /// Fresh anonymous pipe backend (`LET $p: PIPE` with no initializer).
629    /// Evaluates to a pipe value keyed by a generated name that no
630    /// `pipe:` literal can spell, so bare declarations never collide
631    /// with named pipes. Transitional representation until backends
632    /// become owned handles.
633    FreshPipe,
634    /// Lowering-only intermediate staging `9223372036854775808` (the unsigned
635    /// half of `i64::MIN`). Valid only as the direct child of unary `-`;
636    /// any instance reaching lowering completion bails integer overflow.
637    UnsignedIntBoundary(u64),
638    Not(Box<Expr>),
639    Logical {
640        op: LogicalOp,
641        left: Box<Expr>,
642        right: Box<Expr>,
643    },
644}
645
646#[derive(Debug, Clone, PartialEq)]
647pub struct Step {
648    pub guard: Option<GuardExpr>,
649    pub kind: StepKind,
650    pub scope_enter: usize,
651    pub scope_exit: usize,
652}
653
654impl Expr {
655    /// Visit this expression and every descendant, depth first. `Block`
656    /// steps recurse through [`StepKind::walk_step_exprs`](crate::commands::StepKind::walk_step_exprs),
657    /// so inline bodies are covered; `CompiledMath` ops are opaque here
658    /// (match `MathOp::Call` in the visitor when call names matter).
659    /// Exhaustive over variants: adding an `Expr` shape fails compilation
660    /// here, never silently.
661    pub fn walk(&self, f: &mut impl FnMut(&Expr)) {
662        f(self);
663        match self {
664            Expr::Literal(_)
665            | Expr::Var(_)
666            | Expr::Env(_)
667            | Expr::KeyPath { .. }
668            | Expr::Inspect(_)
669            | Expr::FreshPipe
670            | Expr::UnsignedIntBoundary(_) => {}
671            Expr::Call { args, .. } => {
672                for arg in args {
673                    arg.walk(f);
674                }
675            }
676            Expr::List(items) => {
677                for item in items {
678                    item.walk(f);
679                }
680            }
681            Expr::Map(entries) => {
682                for (_, value) in entries {
683                    value.walk(f);
684                }
685            }
686            Expr::Block(steps) => {
687                for step in steps {
688                    step.kind.walk_step_exprs(f);
689                }
690            }
691            Expr::Compare { left, right, .. }
692            | Expr::Arithmetic { left, right, .. }
693            | Expr::Logical { left, right, .. } => {
694                left.walk(f);
695                right.walk(f);
696            }
697            Expr::CompiledMath(_) => {}
698            Expr::Not(inner) => inner.walk(f),
699        }
700    }
701}
702
703#[derive(Debug, Clone, Eq, PartialEq)]
704pub enum WorkspaceTarget {
705    Snapshot,
706    Local,
707    Cache { local: bool },
708    System,
709}
710
711pub trait EnvLookup {
712    fn get_env(&self, key: &str) -> Option<&str>;
713}
714
715impl EnvLookup for HashMap<String, String> {
716    fn get_env(&self, key: &str) -> Option<&str> {
717        self.get(key).map(|s| s.as_str())
718    }
719}
720
721impl EnvLookup for Arc<HashMap<String, String>> {
722    fn get_env(&self, key: &str) -> Option<&str> {
723        (**self).get_env(key)
724    }
725}
726
727pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
728    let Guard::Attr { ns, key, val } = guard;
729    match ns {
730        Ns::Os => match val.as_deref() {
731            Some("macos") => platform_is_macos(),
732            Some("linux") => platform_is_linux(),
733            Some("windows") => platform_is_windows(),
734            _ => false,
735        },
736        Ns::Arch => match val.as_deref() {
737            Some(want) => ARCH_TARGET == want,
738            None => false,
739        },
740        Ns::Bool => val
741            .as_deref()
742            .map(|v| v.parse::<bool>().unwrap_or(false))
743            .unwrap_or(false),
744        Ns::Env => match key.as_deref() {
745            Some(var) => match val {
746                None => env.get_env(var).map(|v| !v.is_empty()).unwrap_or(false),
747                Some(want) => env
748                    .get_env(var)
749                    .map(|v| v == want.as_str())
750                    .unwrap_or(false),
751            },
752            // Parser always supplies the variable; defensive false.
753            None => false,
754        },
755    }
756}
757
758/// One `cfg!` per platform fact, each beside its namespace arm
759/// above. `#[allow]` lives on the helpers, not the dispatch.
760#[allow(clippy::disallowed_macros)]
761fn platform_is_windows() -> bool {
762    cfg!(windows)
763}
764
765/// See `platform_is_windows`.
766#[allow(clippy::disallowed_macros)]
767fn platform_is_macos() -> bool {
768    cfg!(target_os = "macos")
769}
770
771/// See `platform_is_windows`.
772#[allow(clippy::disallowed_macros)]
773fn platform_is_linux() -> bool {
774    cfg!(target_os = "linux")
775}
776
777/// Host target architecture behind `arch:` guards. A plain const
778/// read alongside the platform helpers above.
779const ARCH_TARGET: &str = std::env::consts::ARCH;
780
781pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
782    match expr {
783        GuardExpr::Predicate(guard) => guard_allows(guard, env),
784        GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
785        GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
786        GuardExpr::Not(child) => !guard_expr_allows(child, env),
787    }
788}
789
790pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
791    match expr {
792        Some(e) => guard_expr_allows(e, env),
793        None => true,
794    }
795}
796
797impl Guard {
798    /// The one disjointness rule for the entire guard language:
799    /// same namespace, same subject, both valued, different values.
800    /// Everything else overlaps — different namespaces are
801    /// independent axes, different subjects are independent
802    /// variables, and a missing value is a presence claim compatible
803    /// with any value. No per-domain tables: `os:macos` vs
804    /// `os:linux`, `env:MODE=dev` vs `env:MODE=prod`, and
805    /// `bool:true` vs `bool:false` all decide here.
806    pub fn is_disjoint_from(&self, other: &Self) -> bool {
807        match (self, other) {
808            (
809                Guard::Attr {
810                    ns: n1,
811                    key: k1,
812                    val: Some(v1),
813                },
814                Guard::Attr {
815                    ns: n2,
816                    key: k2,
817                    val: Some(v2),
818                },
819            ) => n1 == n2 && k1 == k2 && v1 != v2,
820            _ => false,
821        }
822    }
823}
824
825impl GuardExpr {
826    /// Structural disjointness over the full combinator language.
827    /// Distribution is exact in both directions (`Or` needs every
828    /// arm disjoint, `All` needs one), so this proves rather than
829    /// assumes; anything without a proof overlaps. Linear, no worlds,
830    /// no enumeration, no cap.
831    pub fn is_disjoint_from(&self, other: &Self) -> bool {
832        match (self, other) {
833            // Complements, either polarity: exact syntactic equality,
834            // or an unset negation against any positive claim on the
835            // same subject (`Not([ns:k])` excludes `[ns:k]` and
836            // `[ns:k=v]` alike — unset is compatible with nothing
837            // valued). Valued negations (`Not([ns:k=v])`) meet only
838            // their exact complement: the subject may hold some other
839            // value.
840            (x, GuardExpr::Not(inner)) | (GuardExpr::Not(inner), x) => {
841                x == inner.as_ref() || unset_excludes(x, inner.as_ref())
842            }
843            // Leaf predicates delegate to domain algebra.
844            (GuardExpr::Predicate(p1), GuardExpr::Predicate(p2)) => p1.is_disjoint_from(p2),
845            // Distribution over `Or`: every arm must exclude `other`.
846            (GuardExpr::Or(arms), other) | (other, GuardExpr::Or(arms)) => {
847                arms.iter().all(|arm| arm.is_disjoint_from(other))
848            }
849            // Distribution over `All`: one excluding arm suffices.
850            (GuardExpr::All(arms), other) | (other, GuardExpr::All(arms)) => {
851                arms.iter().any(|arm| arm.is_disjoint_from(other))
852            }
853        }
854    }
855}
856
857/// Top-level conjuncts of a guard: descend `All` only. The DNF
858/// expansion below distributes anything nested (`Or`/`Not`)
859/// exactly; this split is only the entry point.
860fn conjuncts(expr: &GuardExpr) -> Vec<&GuardExpr> {
861    match expr {
862        GuardExpr::All(items) => items.iter().collect(),
863        single => vec![single],
864    }
865}
866
867/// Closed value domains for coverage reasoning, driven by the
868/// parser's accepted values rather than per-value match arms in the
869/// analyzer: `bool` admits true/false, `os` admits the three known
870/// systems (`family:unix` already lowers to `any(os:macos,
871/// os:linux)`, so unix-family coverage is structural). `env` is
872/// open (any string), so it never exhausts.
873fn closed_domain_values(ns: Ns, key: Option<&str>) -> Option<&'static [&'static str]> {
874    match (ns, key) {
875        (Ns::Bool, None) => Some(&["true", "false"]),
876        (Ns::Os, None) => Some(&["macos", "linux", "windows"]),
877        (Ns::Arch, None) => Some(ARCH_VALUES),
878        _ => None,
879    }
880}
881
882/// One DNF literal: a predicate or its negation. Negations only
883/// ever sit on predicates after NNF pushdown.
884#[derive(Clone)]
885enum DnfLit {
886    Pos(Guard),
887    Neg(Guard),
888}
889
890impl DnfLit {
891    fn as_expr(&self) -> GuardExpr {
892        match self {
893            DnfLit::Pos(guard) => GuardExpr::Predicate(guard.clone()),
894            DnfLit::Neg(guard) => GuardExpr::Not(Box::new(GuardExpr::Predicate(guard.clone()))),
895        }
896    }
897}
898
899/// Cap on DNF disjuncts: guard formulas are a handful of atoms, so
900/// this never fires on real input. Exceeding it falls back to live
901/// (satisfiable), the sound direction — a missed proof only risks a
902/// false coverage error, never a missed one.
903const MAX_DNF_DISJUNCTS: usize = 64;
904
905/// Push one expression into DNF under a polarity: `true` keeps it,
906/// `false` negates it (De Morgan through `All`/`Or`, double
907/// negation cancels). Returns the disjunct list, or `None` when the
908/// cap trips.
909fn dnf_push(expr: &GuardExpr, polarity: bool, out: &mut Vec<Vec<DnfLit>>) -> bool {
910    match (expr, polarity) {
911        (GuardExpr::Predicate(guard), true) => {
912            for disjunct in out.iter_mut() {
913                disjunct.push(DnfLit::Pos(guard.clone()));
914            }
915            true
916        }
917        (GuardExpr::Predicate(guard), false) => {
918            for disjunct in out.iter_mut() {
919                disjunct.push(DnfLit::Neg(guard.clone()));
920            }
921            true
922        }
923        (GuardExpr::All(children), true) | (GuardExpr::Or(children), false) => {
924            // Conjunction: every child constrains every disjunct.
925            for child in children {
926                if !dnf_push(child, polarity, out) {
927                    return false;
928                }
929            }
930            true
931        }
932        (GuardExpr::Or(children), true) | (GuardExpr::All(children), false) => {
933            // Disjunction: each child forks every live disjunct.
934            let mut forked = Vec::new();
935            for disjunct in out.drain(..) {
936                for child in children {
937                    let mut branch = vec![disjunct.clone()];
938                    if !dnf_push(child, polarity, &mut branch) {
939                        return false;
940                    }
941                    forked.extend(branch);
942                    if forked.len() > MAX_DNF_DISJUNCTS {
943                        return false;
944                    }
945                }
946            }
947            *out = forked;
948            true
949        }
950        (GuardExpr::Not(inner), _) => dnf_push(inner, !polarity, out),
951    }
952}
953
954/// True when one DNF disjunct can fire: no statically-false
955/// literal, no conflicting pair, and no closed domain exhausted by
956/// negations.
957fn disjunct_satisfiable(lits: &[DnfLit]) -> bool {
958    // A statically-false positive (`bool:false`, or any non-`true`
959    // which `guard_allows` parses as false) kills the disjunct, as
960    // does the negation of `bool:true` (which always fires).
961    for lit in lits {
962        match lit {
963            DnfLit::Pos(Guard::Attr {
964                ns: Ns::Bool, val, ..
965            }) if val.as_deref() != Some("true") => return false,
966            DnfLit::Neg(Guard::Attr {
967                ns: Ns::Bool,
968                key: None,
969                val: Some(value),
970            }) if value == "true" => return false,
971            _ => {}
972        }
973    }
974    // Any conflicting pair kills the disjunct (complements, leaf
975    // disjointness, and `Or`/`All` distribution decide inside).
976    for (index, first) in lits.iter().enumerate() {
977        for second in &lits[index + 1..] {
978            if first.as_expr().is_disjoint_from(&second.as_expr()) {
979                return false;
980            }
981        }
982    }
983    // A closed domain with every value negated has nowhere left to
984    // fire (`not(os:macos)`, `not(os:linux)`, `not(os:windows)`
985    // jointly cover no host).
986    for lit in lits {
987        let (ns, key) = match lit {
988            DnfLit::Neg(Guard::Attr {
989                ns,
990                key,
991                val: Some(_),
992            }) => (*ns, key.as_deref()),
993            _ => continue,
994        };
995        let Some(domain) = closed_domain_values(ns, key) else {
996            continue;
997        };
998        let exhausted = domain.iter().all(|value| {
999            lits.iter().any(|other| match other {
1000                DnfLit::Neg(Guard::Attr {
1001                    ns: other_ns,
1002                    key: other_key,
1003                    val: Some(other_val),
1004                }) => *other_ns == ns && other_key.as_deref() == key && other_val == value,
1005                _ => false,
1006            })
1007        });
1008        if exhausted {
1009            return false;
1010        }
1011    }
1012    true
1013}
1014
1015/// True when a conjunction list can never fire: its exact DNF has
1016/// no satisfiable disjunct. This proves rather than assumes —
1017/// pairwise conflicts, `bool` falsehood, and complete closed-domain
1018/// partitions (`os:macos`/`os:linux`/`os:windows`,
1019/// `bool:true`/`bool:false`) all decide here with no per-domain
1020/// tables and no hardcoded platform pairs.
1021fn conjunction_dead(atoms: &[&GuardExpr]) -> bool {
1022    if atoms.is_empty() {
1023        return false;
1024    }
1025    let mut disjuncts = vec![Vec::new()];
1026    for atom in atoms {
1027        if !dnf_push(atom, true, &mut disjuncts) {
1028            return false;
1029        }
1030    }
1031    !disjuncts
1032        .iter()
1033        .any(|disjunct| disjunct_satisfiable(disjunct))
1034}
1035
1036/// True when a guard can fire. Anything not provably dead is live.
1037pub fn guard_satisfiable(expr: &GuardExpr) -> bool {
1038    !conjunction_dead(&conjuncts(expr))
1039}
1040
1041/// True when a guard fires unconditionally: a lone `true`, an
1042/// all-valid conjunction, or the negation of a dead conjunction.
1043pub fn guard_valid(expr: &GuardExpr) -> bool {
1044    match expr {
1045        GuardExpr::Predicate(Guard::Attr {
1046            ns: Ns::Bool,
1047            val: Some(value),
1048            ..
1049        }) => value.as_str() == "true",
1050        GuardExpr::All(items) => items.iter().all(guard_valid),
1051        GuardExpr::Not(inner) => !guard_satisfiable(inner),
1052        _ => false,
1053    }
1054}
1055
1056/// True when two declaration guards can fire together. Absent guards
1057/// are unconditional; anything without a provable conflict overlaps.
1058pub fn guards_overlap(a: Option<&GuardExpr>, b: Option<&GuardExpr>) -> bool {
1059    match (a, b) {
1060        (None, None) => true,
1061        (Some(x), None) | (None, Some(x)) => guard_satisfiable(x),
1062        (Some(x), Some(y)) => {
1063            let mut atoms = conjuncts(x);
1064            atoms.extend(conjuncts(y));
1065            !conjunction_dead(&atoms)
1066        }
1067    }
1068}
1069
1070/// Unset exclusion: `Not([ns:k])` (valueless) is disjoint from any
1071/// positive claim on the same subject, valued or not.
1072fn unset_excludes(x: &GuardExpr, inner: &GuardExpr) -> bool {
1073    match (x, inner) {
1074        (
1075            GuardExpr::Predicate(Guard::Attr {
1076                ns: n2, key: k2, ..
1077            }),
1078            GuardExpr::Predicate(Guard::Attr {
1079                ns: n1,
1080                key: k1,
1081                val: None,
1082            }),
1083        ) => n1 == n2 && k1 == k2,
1084        _ => false,
1085    }
1086}
1087
1088impl fmt::Display for Guard {
1089    /// Bare form without brackets (the step renderer adds them):
1090    /// `[os:linux]` prints `os:linux`, `[env:MODE=dev]`
1091    /// prints `eq(env:MODE, dev)`, `[env:MODE]` prints `env:MODE`.
1092    /// The `eq()` spelling roundtrips through the parser; the rest
1093    /// print verbatim. Roundtrips by construction.
1094    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1095        match self {
1096            Guard::Attr {
1097                ns: Ns::Env,
1098                key: Some(var),
1099                val: None,
1100            } => write!(f, "env:{var}"),
1101            Guard::Attr {
1102                ns: Ns::Env,
1103                key: Some(var),
1104                val: Some(want),
1105            } => write!(f, "eq(env:{var}, {want})"),
1106            Guard::Attr {
1107                ns,
1108                key: _,
1109                val: Some(value),
1110            } => write!(f, "{ns}:{value}"),
1111            Guard::Attr { ns, key, val: None } => match key {
1112                Some(subject) => write!(f, "{ns}:{subject}"),
1113                None => write!(f, "{ns}"),
1114            },
1115        }
1116    }
1117}
1118
1119impl fmt::Display for WorkspaceTarget {
1120    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1121        match self {
1122            WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
1123            WorkspaceTarget::Local => write!(f, "LOCAL"),
1124            WorkspaceTarget::Cache { local: false } => write!(f, "CACHE"),
1125            WorkspaceTarget::Cache { local: true } => write!(f, "CACHE --local"),
1126            WorkspaceTarget::System => write!(f, "SYSTEM"),
1127        }
1128    }
1129}
1130
1131impl fmt::Display for Expr {
1132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1133        match self {
1134            Expr::Literal(v) => write!(f, "{}", v),
1135            Expr::Var(name) => write!(f, "${}", name),
1136            Expr::Env(key) => write!(f, "env:{}", key),
1137            Expr::KeyPath { base, keys } => {
1138                write!(f, "${}", base)?;
1139                for key in keys {
1140                    write!(f, ".{}", key)?;
1141                }
1142                Ok(())
1143            }
1144            Expr::Call { name, args } => {
1145                write!(f, "{}(", name)?;
1146                for (i, arg) in args.iter().enumerate() {
1147                    if i > 0 {
1148                        write!(f, ", ")?;
1149                    }
1150                    write!(f, "{}", arg)?;
1151                }
1152                write!(f, ")")
1153            }
1154            Expr::Inspect(var) => write!(f, "INSPECT(${})", var),
1155            Expr::List(items) => {
1156                write!(f, "[")?;
1157                for (i, item) in items.iter().enumerate() {
1158                    if i > 0 {
1159                        write!(f, ", ")?;
1160                    }
1161                    write!(f, "{}", item)?;
1162                }
1163                write!(f, "]")
1164            }
1165            Expr::Map(entries) => {
1166                write!(f, "{{")?;
1167                for (i, (key, val)) in entries.iter().enumerate() {
1168                    if i > 0 {
1169                        write!(f, ", ")?;
1170                    }
1171                    write!(f, "\"{}\": {}", key, val)?;
1172                }
1173                write!(f, "}}")
1174            }
1175            Expr::Block(steps) => {
1176                write!(f, "{{ ")?;
1177                for (i, step) in steps.iter().enumerate() {
1178                    if i > 0 {
1179                        write!(f, "; ")?;
1180                    }
1181                    write!(f, "{}", step.kind)?;
1182                }
1183                write!(f, " }}")
1184            }
1185            Expr::Compare { op, left, right } => {
1186                write!(f, "{} {} {}", left, op, right)
1187            }
1188            Expr::Arithmetic { op, left, right } => {
1189                write!(f, "({} {} {})", left, op, right)
1190            }
1191            Expr::CompiledMath(ops) => {
1192                write!(f, "{}", format_compiled_math(ops))
1193            }
1194            // No expression syntax produces this (bare `LET` is a
1195            // statement): render loudly non-round-trippable so a stray
1196            // use fails at re-parse instead of aliasing a named pipe.
1197            Expr::FreshPipe => write!(f, "<fresh pipe>"),
1198            Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
1199            Expr::Not(inner) => {
1200                // Parenthesize compound operands so Display round-trips:
1201                // `!(a == b)` must not render as `!a == b` (= `(!a) == b`).
1202                match inner.as_ref() {
1203                    Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
1204                        write!(f, "!({})", inner)
1205                    }
1206                    _ => write!(f, "!{}", inner),
1207                }
1208            }
1209            Expr::Logical { op, left, right } => {
1210                write!(f, "({} {} {})", left, op, right)
1211            }
1212        }
1213    }
1214}
1215
1216impl fmt::Display for CompareOp {
1217    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1218        match self {
1219            CompareOp::Eq => write!(f, "=="),
1220            CompareOp::Ne => write!(f, "!="),
1221            CompareOp::Lt => write!(f, "<"),
1222            CompareOp::Le => write!(f, "<="),
1223            CompareOp::Gt => write!(f, ">"),
1224            CompareOp::Ge => write!(f, ">="),
1225        }
1226    }
1227}
1228
1229impl fmt::Display for ArithOp {
1230    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1231        match self {
1232            ArithOp::Add => write!(f, "+"),
1233            ArithOp::Sub => write!(f, "-"),
1234            ArithOp::Mul => write!(f, "*"),
1235            ArithOp::Div => write!(f, "/"),
1236        }
1237    }
1238}
1239
1240/// Render flat RPN back to parenthesized infix so `Display` round-trips
1241/// through the parser with identical semantics. Parentheses are emitted
1242/// unconditionally around binary/unary ops; redundant parens parse to the
1243/// same tree, which is what round-trip requires.
1244fn format_compiled_math(ops: &[MathOp]) -> String {
1245    let mut stack: Vec<String> = Vec::new();
1246    for op in ops {
1247        match op {
1248            MathOp::PushConst(v) => stack.push(format!("{}", v)),
1249            MathOp::LoadVar(name) => stack.push(format!("${}", name)),
1250            MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
1251            MathOp::LoadKeyPath { base, keys } => {
1252                let mut s = format!("${}", base);
1253                for key in keys {
1254                    s.push('.');
1255                    s.push_str(key);
1256                }
1257                stack.push(s);
1258            }
1259            MathOp::Call { name, arity } => {
1260                let mut args = Vec::new();
1261                for _ in 0..*arity {
1262                    args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
1263                }
1264                args.reverse();
1265                stack.push(format!("{}({})", name, args.join(", ")));
1266            }
1267            MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
1268            MathOp::Neg => {
1269                let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
1270                stack.push(format!("(-{})", inner));
1271            }
1272            MathOp::Add => push_bin(&mut stack, "+"),
1273            MathOp::Sub => push_bin(&mut stack, "-"),
1274            MathOp::Mul => push_bin(&mut stack, "*"),
1275            MathOp::Div => push_bin(&mut stack, "/"),
1276            MathOp::Lt => push_bin(&mut stack, "<"),
1277            MathOp::Le => push_bin(&mut stack, "<="),
1278            MathOp::Gt => push_bin(&mut stack, ">"),
1279            MathOp::Ge => push_bin(&mut stack, ">="),
1280            MathOp::Eq => push_bin(&mut stack, "=="),
1281            MathOp::Ne => push_bin(&mut stack, "!="),
1282        }
1283    }
1284    if stack.len() == 1 {
1285        let mut items = stack;
1286        items.pop().unwrap_or_else(|| "<empty>".to_string())
1287    } else {
1288        stack.join(" ")
1289    }
1290}
1291
1292fn push_bin(stack: &mut Vec<String>, op: &str) {
1293    let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
1294    let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
1295    stack.push(format!("({} {} {})", left, op, right));
1296}
1297
1298impl fmt::Display for LogicalOp {
1299    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1300        match self {
1301            LogicalOp::And => write!(f, "&&"),
1302            LogicalOp::Or => write!(f, "||"),
1303        }
1304    }
1305}
1306
1307enum GuardDisplayContext {
1308    Root,
1309    InAnyArg,
1310    InNot,
1311    InAll,
1312}
1313
1314impl GuardExpr {
1315    fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
1316        match self {
1317            GuardExpr::Predicate(guard) => write!(f, "{}", guard),
1318            GuardExpr::All(children) => {
1319                let wrap = matches!(
1320                    ctx,
1321                    GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
1322                ) && children.len() > 1;
1323                if wrap {
1324                    write!(f, "(")?;
1325                }
1326                for (i, child) in children.iter().enumerate() {
1327                    if i > 0 {
1328                        write!(f, ", ")?;
1329                    }
1330                    child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
1331                }
1332                if wrap {
1333                    write!(f, ")")?;
1334                }
1335                Ok(())
1336            }
1337            GuardExpr::Or(children) => {
1338                write!(f, "any(")?;
1339                for (i, child) in children.iter().enumerate() {
1340                    if i > 0 {
1341                        write!(f, ", ")?;
1342                    }
1343                    child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
1344                }
1345                write!(f, ")")
1346            }
1347            GuardExpr::Not(child) => {
1348                write!(f, "not(")?;
1349                child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
1350                write!(f, ")")
1351            }
1352        }
1353    }
1354}
1355
1356impl fmt::Display for GuardExpr {
1357    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1358        self.fmt_with_ctx(f, GuardDisplayContext::Root)
1359    }
1360}
1361
1362impl fmt::Display for Step {
1363    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1364        if let Some(expr) = &self.guard {
1365            write!(f, "[{}] ", expr)?;
1366        }
1367        write!(f, "{}", self.kind)
1368    }
1369}
1370
1371#[cfg(test)]
1372mod tests {
1373    use super::*;
1374    /// Bidirectional lock between `dsl.pest` and the keyword registry.
1375    /// Fails CI if a statement keyword is renamed or deleted on either side.
1376    /// The grammar is parsed with `pest_meta` (no line-format or rule-name
1377    /// conventions): every [`STRUCTURAL_KEYWORDS`] entry must occur as a
1378    /// string literal somewhere in the grammar, and every uppercase literal
1379    /// reachable from the top-level instruction rules (`element`,
1380    /// `block_element`, following rule references transitively) must
1381    /// classify as a statement starter ([`Command::is_statement_keyword`])
1382    /// or a clause keyword ([`CLAUSE_KEYWORDS`]). Type tags, argument
1383    /// enums, and value-level function names validate at lowering time via
1384    /// generic ident rules, so they are outside this test's scope by design
1385    /// rather than via synthetic registries.
1386    #[test]
1387    fn statement_keywords_match_pest_grammar() {
1388        use pest_meta::{ast::Expr, parser};
1389        use std::collections::{HashMap, HashSet};
1390
1391        let pest_src = include_str!("dsl.pest");
1392        let pairs = parser::parse(parser::Rule::grammar_rules, pest_src)
1393            .expect("dsl.pest must parse as a pest grammar");
1394        let rules = parser::consume_rules(pairs).expect("dsl.pest rules must consume");
1395        let by_name: HashMap<&str, &Expr> = rules
1396            .iter()
1397            .map(|rule| (rule.name.as_str(), &rule.expr))
1398            .collect();
1399
1400        let mut all_literals = HashSet::new();
1401        for rule in &rules {
1402            for node in rule.expr.iter_top_down() {
1403                match node {
1404                    Expr::Str(literal) | Expr::Insens(literal) => {
1405                        all_literals.insert(literal.clone());
1406                    }
1407                    _ => {}
1408                }
1409            }
1410        }
1411        for kw in STRUCTURAL_KEYWORDS {
1412            assert!(
1413                all_literals.contains(*kw),
1414                "keyword {kw} in STRUCTURAL_KEYWORDS missing from dsl.pest"
1415            );
1416        }
1417
1418        let mut seen = HashSet::new();
1419        let mut stack = vec!["element".to_string(), "block_element".to_string()];
1420        let mut stmt_literals = HashSet::new();
1421        while let Some(name) = stack.pop() {
1422            if !seen.insert(name.clone()) {
1423                continue;
1424            }
1425            let Some(expr) = by_name.get(name.as_str()) else {
1426                continue;
1427            };
1428            for node in expr.iter_top_down() {
1429                match node {
1430                    Expr::Str(literal) | Expr::Insens(literal) => {
1431                        stmt_literals.insert(literal.clone());
1432                    }
1433                    Expr::Ident(dependency) => stack.push(dependency.clone()),
1434                    _ => {}
1435                }
1436            }
1437        }
1438        assert!(
1439            seen.contains("element") && seen.contains("block_element"),
1440            "grammar must define element and block_element instruction rules"
1441        );
1442        for kw in stmt_literals {
1443            if kw.len() > 1 && kw.chars().all(|c| c.is_ascii_uppercase()) {
1444                assert!(
1445                    crate::Command::is_statement_keyword(&kw)
1446                        || CLAUSE_KEYWORDS.contains(&kw.as_str()),
1447                    "uppercase literal \"{kw}\" reachable from dsl.pest instruction rules is not a registered statement or clause keyword"
1448                );
1449            }
1450        }
1451    }
1452
1453    /// Category convention lock: every multi-word command groups under a
1454    /// registered `COMMAND_CATEGORIES` prefix. Adding a command under a
1455    /// new category fails here until the prefix registers (one line).
1456    #[test]
1457    fn command_names_group_by_category() {
1458        for command in COMMANDS {
1459            let name = command.as_str();
1460            let Some((prefix, _)) = name.split_once('_') else {
1461                continue;
1462            };
1463            assert!(
1464                COMMAND_CATEGORIES.contains(&prefix),
1465                "command {name} introduces unregistered category {prefix}; add it to COMMAND_CATEGORIES"
1466            );
1467        }
1468    }
1469}
1470
1471#[cfg(test)]
1472mod guard_analysis_tests {
1473    use super::*;
1474
1475    fn attr(ns: Ns, key: Option<&str>, val: Option<&str>) -> GuardExpr {
1476        GuardExpr::Predicate(Guard::Attr {
1477            ns,
1478            key: key.map(str::to_string),
1479            val: val.map(str::to_string),
1480        })
1481    }
1482
1483    fn os(value: &str) -> GuardExpr {
1484        attr(Ns::Os, None, Some(value))
1485    }
1486
1487    fn arch(value: &str) -> GuardExpr {
1488        attr(Ns::Arch, None, Some(value))
1489    }
1490
1491    /// The `family:unix` parse alias, desugared exactly as the
1492    /// parser lowers it.
1493    fn unix() -> GuardExpr {
1494        GuardExpr::Or(vec![os("macos"), os("linux")])
1495    }
1496
1497    fn windows() -> GuardExpr {
1498        os("windows")
1499    }
1500
1501    fn env_eq(var: &str, value: &str) -> GuardExpr {
1502        attr(Ns::Env, Some(var), Some(value))
1503    }
1504
1505    fn not(expr: GuardExpr) -> GuardExpr {
1506        GuardExpr::Not(Box::new(expr))
1507    }
1508
1509    /// Closed domains exhaust: negating every `os:` value (or both
1510    /// `bool:` values) covers no host. Open domains (`env:`) never
1511    /// exhaust — some unmentioned value may hold.
1512    #[test]
1513    fn partition_negations_are_jointly_dead() {
1514        let os_exhausted = GuardExpr::All(vec![not(os("macos")), not(os("linux")), not(windows())]);
1515        assert!(
1516            !guard_satisfiable(&os_exhausted),
1517            "no host is outside macos/linux/windows"
1518        );
1519        // Partial negations stay live.
1520        let os_partial = GuardExpr::All(vec![not(os("macos")), not(os("linux"))]);
1521        assert!(guard_satisfiable(&os_partial), "windows is still open");
1522        // The bool domain exhausts the same way.
1523        let bool_exhausted = GuardExpr::All(vec![
1524            not(attr(Ns::Bool, None, Some("true"))),
1525            not(attr(Ns::Bool, None, Some("false"))),
1526        ]);
1527        assert!(!guard_satisfiable(&bool_exhausted));
1528        // Open env domains never exhaust.
1529        let env_open = GuardExpr::All(vec![
1530            not(env_eq("MODE", "dev")),
1531            not(env_eq("MODE", "prod")),
1532        ]);
1533        assert!(guard_satisfiable(&env_open), "MODE may hold a third value");
1534        // Single negations are always live.
1535        assert!(guard_satisfiable(&not(unix())));
1536    }
1537
1538    /// The desugared unix alias behaves as a disjunction: it meets
1539    /// windows nowhere and covers macos/linux deployment reads.
1540    #[test]
1541    fn unix_alias_is_os_disjunction() {
1542        assert!(!guards_overlap(Some(&unix()), Some(&windows())));
1543        assert!(guards_overlap(Some(&unix()), Some(&os("macos"))));
1544        // Coverage through the alias: unix + windows declarations
1545        // cover every host, so their joint negation is dead.
1546        let covered = GuardExpr::All(vec![GuardExpr::Not(Box::new(unix())), not(windows())]);
1547        assert!(!guard_satisfiable(&covered));
1548    }
1549
1550    /// `arch:` decides by value like every namespace; it overlaps
1551    /// `os:` (independent axes: an aarch64 mac exists). Negating
1552    /// the whole closed `ARCH_VALUES` domain covers no host.
1553    #[test]
1554    fn arch_guards_decide_by_value() {
1555        assert!(!guards_overlap(
1556            Some(&arch("x86_64")),
1557            Some(&arch("aarch64"))
1558        ));
1559        assert!(guards_overlap(Some(&arch("x86_64")), Some(&arch("x86_64"))));
1560        assert!(guards_overlap(Some(&arch("aarch64")), Some(&os("macos"))));
1561        let exhausted = GuardExpr::All(ARCH_VALUES.iter().map(|value| not(arch(value))).collect());
1562        assert!(!guard_satisfiable(&exhausted));
1563        // Runtime anchor: this host's own architecture fires here.
1564        let host = attr(Ns::Arch, None, Some(ARCH_TARGET));
1565        assert!(guard_expr_allows(&host, &HashMap::new()));
1566    }
1567
1568    /// The full platform matrix, asserted exhaustively. All three
1569    /// `os:` values are pairwise disjoint; identical values
1570    /// overlap.
1571    #[test]
1572    fn platform_overlap_matrix_is_exhaustive() {
1573        let cases = [
1574            ("macos", "macos", true),
1575            ("macos", "linux", false),
1576            ("macos", "windows", false),
1577            ("linux", "linux", true),
1578            ("linux", "windows", false),
1579            ("windows", "windows", true),
1580        ];
1581        for (v1, v2, expected) in cases {
1582            let left = os(v1);
1583            let right = os(v2);
1584            assert_eq!(
1585                guards_overlap(Some(&left), Some(&right)),
1586                expected,
1587                "os:{v1} vs os:{v2}"
1588            );
1589        }
1590    }
1591
1592    /// Runtime anchor, portable across CI hosts: `os:macos` and
1593    /// `os:windows` never co-fire on the machine running the test.
1594    #[test]
1595    fn disjoint_pairs_never_cofire_here() {
1596        let macos = os("macos");
1597        let holds = |expr: &GuardExpr| guard_expr_allows(expr, &HashMap::new());
1598        assert!(
1599            !(holds(&macos) && holds(&windows())),
1600            "os:macos and os:windows co-fired"
1601        );
1602    }
1603
1604    #[test]
1605    fn overlap_follows_namespace_semantics() {
1606        // Same namespace, distinct values: disjoint.
1607        assert!(!guards_overlap(Some(&windows()), Some(&unix())));
1608        assert!(!guards_overlap(Some(&os("macos")), Some(&os("linux"))));
1609        // Absent guards are unconditional.
1610        assert!(guards_overlap(None, Some(&windows())));
1611        assert!(guards_overlap(None, None));
1612        // Unsatisfiable conjunctions overlap nothing.
1613        let dead = GuardExpr::All(vec![os("macos"), windows()]);
1614        assert!(!guard_satisfiable(&dead));
1615        assert!(!guards_overlap(Some(&dead), Some(&os("macos"))));
1616        assert!(guard_valid(&GuardExpr::Predicate(Guard::Attr {
1617            ns: Ns::Bool,
1618            key: None,
1619            val: Some("true".to_string()),
1620        })));
1621        assert!(!guard_valid(&unix()));
1622    }
1623
1624    #[test]
1625    fn env_value_conflicts_decide_without_enumeration() {
1626        let fast = env_eq("MODE", "fast");
1627        let slow = env_eq("MODE", "slow");
1628        assert!(
1629            !guards_overlap(Some(&fast), Some(&slow)),
1630            "same key distinct values are disjoint"
1631        );
1632        let also_fast = env_eq("MODE", "fast");
1633        assert!(guards_overlap(Some(&fast), Some(&also_fast)));
1634        // Bare presence overlaps any value on the same key.
1635        let bare = attr(Ns::Env, Some("MODE"), None);
1636        assert!(guards_overlap(Some(&bare), Some(&fast)));
1637    }
1638}
1639
1640#[cfg(test)]
1641mod guard_algebra_tests {
1642    use super::*;
1643
1644    fn os(value: &str) -> GuardExpr {
1645        GuardExpr::Predicate(Guard::Attr {
1646            ns: Ns::Os,
1647            key: None,
1648            val: Some(value.to_string()),
1649        })
1650    }
1651
1652    #[test]
1653    fn or_distributes_over_disjointness() {
1654        fn env_eq(var: &str, value: &str) -> GuardExpr {
1655            GuardExpr::Predicate(Guard::Attr {
1656                ns: Ns::Env,
1657                key: Some(var.to_string()),
1658                val: Some(value.to_string()),
1659            })
1660        }
1661        // Every arm must exclude `other`: one overlapping arm keeps
1662        // the whole disjunction live.
1663        let mixed = GuardExpr::Or(vec![env_eq("MODE", "dev"), env_eq("MODE", "prod")]);
1664        assert!(!mixed.is_disjoint_from(&env_eq("MODE", "dev")));
1665        // All arms disjoint: the disjunction is dead against it.
1666        assert!(mixed.is_disjoint_from(&env_eq("MODE", "test")));
1667        // Cross-namespace arms never exclude: different axes overlap.
1668        let cross = GuardExpr::Or(vec![env_eq("MODE", "dev")]);
1669        let platform = os("windows");
1670        assert!(!cross.is_disjoint_from(&platform));
1671    }
1672
1673    #[test]
1674    fn all_distributes_over_disjointness() {
1675        let stacked = GuardExpr::All(vec![
1676            os("macos"),
1677            GuardExpr::Or(vec![os("macos"), os("linux")]),
1678            os("windows"),
1679        ]);
1680        assert!(stacked.is_disjoint_from(&os("macos")));
1681        // One live arm keeps the conjunction overlapping: neither
1682        // arm excludes macos here.
1683        let live = GuardExpr::All(vec![
1684            os("macos"),
1685            GuardExpr::Predicate(Guard::Attr {
1686                ns: Ns::Env,
1687                key: Some("MODE".to_string()),
1688                val: Some("dev".to_string()),
1689            }),
1690        ]);
1691        assert!(!live.is_disjoint_from(&os("macos")));
1692    }
1693
1694    #[test]
1695    fn negation_is_structural_not_syntactic() {
1696        let not_win = GuardExpr::Not(Box::new(os("windows")));
1697        assert!(not_win.is_disjoint_from(&os("windows")));
1698        assert!(os("windows").is_disjoint_from(&not_win));
1699        assert!(!not_win.is_disjoint_from(&os("macos")));
1700    }
1701}