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