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