Skip to main content

oxdock_parser/
ast.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4pub use crate::commands::{AssertTarget, StepKind};
5
6#[derive(Copy, Clone, Debug, Eq, PartialEq)]
7pub enum Command {
8    InheritEnv,
9    Workdir,
10    Workspace,
11    Env,
12    Echo,
13    Run,
14    Copy,
15    WithIo,
16    CopyGit,
17    HashSha256,
18    Symlink,
19    Mkdir,
20    Ls,
21    Cwd,
22    Read,
23    ReadLine,
24    Write,
25    Append,
26    Expand,
27    AssertEq,
28    AssertContains,
29    Exit,
30    Async,
31    Timeout,
32    Sleep,
33}
34
35pub const COMMANDS: &[Command] = &[
36    Command::InheritEnv,
37    Command::Workdir,
38    Command::Workspace,
39    Command::Env,
40    Command::Echo,
41    Command::Run,
42    Command::Copy,
43    Command::WithIo,
44    Command::CopyGit,
45    Command::HashSha256,
46    Command::Symlink,
47    Command::Mkdir,
48    Command::Ls,
49    Command::Cwd,
50    Command::Read,
51    Command::ReadLine,
52    Command::Write,
53    Command::Append,
54    Command::Expand,
55    Command::AssertEq,
56    Command::AssertContains,
57    Command::Exit,
58    Command::Timeout,
59    Command::Sleep,
60];
61
62impl Command {
63    pub const fn as_str(self) -> &'static str {
64        match self {
65            Command::InheritEnv => "INHERIT_ENV",
66            Command::Workdir => "WORKDIR",
67            Command::Workspace => "WORKSPACE",
68            Command::Env => "ENV",
69            Command::Echo => "ECHO",
70            Command::Run => "RUN",
71            Command::Copy => "COPY",
72            Command::WithIo => "WITH_IO",
73            Command::CopyGit => "COPY_GIT",
74            Command::HashSha256 => "HASH_SHA256",
75            Command::Symlink => "SYMLINK",
76            Command::Mkdir => "MKDIR",
77            Command::Ls => "LS",
78            Command::Cwd => "CWD",
79            Command::Read => "READ",
80            Command::ReadLine => "READ_LINE",
81            Command::Write => "WRITE",
82            Command::Append => "APPEND",
83            Command::Expand => "EXPAND",
84            Command::AssertEq => "ASSERT_EQ",
85            Command::AssertContains => "ASSERT_CONTAINS",
86            Command::Exit => "EXIT",
87            Command::Async => "ASYNC",
88            Command::Timeout => "TIMEOUT",
89            Command::Sleep => "SLEEP",
90        }
91    }
92
93    pub const fn syntax(self) -> &'static str {
94        match self {
95            Command::InheritEnv => "INHERIT_ENV [KEY1, KEY2, ...]",
96            Command::Workdir => "WORKDIR <path>",
97            Command::Workspace => "WORKSPACE SNAPSHOT|LOCAL",
98            Command::Env => "ENV KEY=value",
99            Command::Echo => "ECHO <message>",
100            Command::Run => "RUN <command...> | RUN [\"exe\", \"arg\", ...]",
101            Command::Copy => "COPY [--from-current-workspace] <from> <to>",
102            Command::CopyGit => "COPY_GIT [--include-dirty] <rev> <src> <dst>",
103            Command::WithIo => "WITH_IO [bindings] [command | { block }]",
104            Command::HashSha256 => "HASH_SHA256 <path>",
105            Command::Symlink => "SYMLINK <from> <to>",
106            Command::Mkdir => "MKDIR <path>",
107            Command::Ls => "LS [<path>]",
108            Command::Cwd => "CWD",
109            Command::Read => "READ [<path>]",
110            Command::ReadLine => "READ_LINE $var",
111            Command::Write => "WRITE <path> [<contents>]",
112            Command::Append => "APPEND <path> [<contents>]",
113            Command::Expand => "EXPAND [<path>] [<KEY=val> ...]",
114            Command::AssertEq => "ASSERT_EQ [--hash <sha256>] <actual> <expected>",
115            Command::AssertContains => "ASSERT_CONTAINS <haystack> <needle>",
116            Command::Exit => "EXIT <code>",
117            Command::Async => "ASYNC <command...> | ASYNC { <commands> }",
118            Command::Timeout => {
119                "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> }"
120            }
121            Command::Sleep => "SLEEP <duration>",
122        }
123    }
124
125    pub const fn expects_inner_command(self) -> bool {
126        matches!(self, Command::WithIo | Command::Async | Command::Timeout)
127    }
128
129    pub fn parse(s: &str) -> Option<Self> {
130        match s {
131            "INHERIT_ENV" => Some(Command::InheritEnv),
132            "WORKDIR" => Some(Command::Workdir),
133            "WORKSPACE" => Some(Command::Workspace),
134            "ENV" => Some(Command::Env),
135            "ECHO" => Some(Command::Echo),
136            "RUN" => Some(Command::Run),
137            "COPY" => Some(Command::Copy),
138            "WITH_IO" => Some(Command::WithIo),
139            "COPY_GIT" => Some(Command::CopyGit),
140            "HASH_SHA256" => Some(Command::HashSha256),
141            "SYMLINK" => Some(Command::Symlink),
142            "MKDIR" => Some(Command::Mkdir),
143            "LS" => Some(Command::Ls),
144            "CWD" => Some(Command::Cwd),
145            "READ" => Some(Command::Read),
146            "READ_LINE" => Some(Command::ReadLine),
147            "WRITE" => Some(Command::Write),
148            "APPEND" => Some(Command::Append),
149            "EXPAND" => Some(Command::Expand),
150            "ASSERT_EQ" => Some(Command::AssertEq),
151            "ASSERT_CONTAINS" => Some(Command::AssertContains),
152            "EXIT" => Some(Command::Exit),
153            "ASYNC" => Some(Command::Async),
154            "TIMEOUT" => Some(Command::Timeout),
155            "SLEEP" => Some(Command::Sleep),
156            _ => None,
157        }
158    }
159
160    /// Whether a bare word opens a new statement in either parse pathway.
161    /// Covers plain commands plus [`STRUCTURAL_KEYWORDS`]. Single source
162    /// of truth for `Display` quoting and token-stream line splitting, which
163    /// must agree or round-trips break.
164    pub(crate) fn is_statement_keyword(s: &str) -> bool {
165        Command::parse(s).is_some() || STRUCTURAL_KEYWORDS.contains(&s)
166    }
167}
168
169/// Statement starters parsed by PEG rules rather than command lowering
170/// (`dsl.pest`, token-walker branches), living outside the [`Command`]
171/// enum. Canonical registry backing `Command::is_statement_keyword`;
172/// iterate this (plus [`crate::all_metadata`] names) instead of
173/// hardcoding keyword lists elsewhere.
174pub const STRUCTURAL_KEYWORDS: &[&str] = &[
175    "LET", "FOR", "IF", "ELSE", "ASYNC", "AWAIT", "CANCEL", "FUNC", "CALL", "RETURN", "WHILE",
176    "BREAK", "CONTINUE",
177];
178
179/// Clause keywords that open no statement and need no `Display` quoting
180/// (`IN` only heads `FOR` iterations). Declared alongside
181/// [`STRUCTURAL_KEYWORDS`] so grammar-conformance tests never hardcode
182/// exception lists of their own.
183pub const CLAUSE_KEYWORDS: &[&str] = &["IN"];
184
185#[derive(Copy, Clone, Debug, Eq, PartialEq)]
186pub enum PlatformGuard {
187    Unix,
188    Windows,
189    Macos,
190    Linux,
191}
192
193#[derive(Debug, Clone, Eq, PartialEq)]
194pub enum Guard {
195    Platform { target: PlatformGuard },
196    EnvExists { key: String },
197    EnvEquals { key: String, value: String },
198    StaticBool { value: String },
199}
200
201#[derive(Debug, Clone, Eq, PartialEq)]
202pub enum GuardExpr {
203    Predicate(Guard),
204    All(Vec<GuardExpr>),
205    Or(Vec<GuardExpr>),
206    Not(Box<GuardExpr>),
207}
208
209impl GuardExpr {
210    pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
211        let mut flat = Vec::new();
212        for expr in exprs {
213            match expr {
214                GuardExpr::All(children) => flat.extend(children),
215                other => flat.push(other),
216            }
217        }
218        match flat.len() {
219            0 => panic!("GuardExpr::all requires at least one expression"),
220            1 => flat.into_iter().next().unwrap(),
221            _ => GuardExpr::All(flat),
222        }
223    }
224
225    pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
226        let mut flat = Vec::new();
227        for expr in exprs {
228            match expr {
229                GuardExpr::Or(children) => flat.extend(children),
230                other => flat.push(other),
231            }
232        }
233        match flat.len() {
234            0 => panic!("GuardExpr::or requires at least one expression"),
235            1 => flat.into_iter().next().unwrap(),
236            _ => GuardExpr::Or(flat),
237        }
238    }
239
240    pub fn invert(expr: GuardExpr) -> GuardExpr {
241        match expr {
242            GuardExpr::Not(inner) => *inner,
243            other => GuardExpr::Not(Box::new(other)),
244        }
245    }
246}
247
248impl std::ops::Not for GuardExpr {
249    type Output = GuardExpr;
250
251    fn not(self) -> GuardExpr {
252        match self {
253            GuardExpr::Not(inner) => *inner,
254            other => GuardExpr::Not(Box::new(other)),
255        }
256    }
257}
258
259impl From<Guard> for GuardExpr {
260    fn from(guard: Guard) -> Self {
261        GuardExpr::Predicate(guard)
262    }
263}
264
265/// A command argument — either an expandable string or an expression.
266#[derive(Debug, Clone, PartialEq)]
267pub enum Arg {
268    /// Expandable string. The `bool` indicates whether the argument was
269    /// quoted in the source (`true`) or unquoted (`false`). Quoted arguments
270    /// that start with `--` are positional, not flags.
271    String(String, bool),
272    /// Expression — resolved at runtime via evaluate_expr.
273    Expr(Expr),
274    /// Mixed literal/expression value (e.g. `KEY={{ $x }} tail`). Fragments
275    /// resolve independently at runtime and concatenate with no added
276    /// separator — inter-fragment gaps are already materialized as `Text`.
277    Parts(Vec<ArgPart>),
278}
279
280/// One fragment of a mixed [`Arg::Parts`] value.
281#[derive(Debug, Clone, PartialEq)]
282pub enum ArgPart {
283    /// Literal text. The `bool` marks source-quoted regions (exact bytes);
284    /// unquoted text carries single-space-normalized gaps.
285    Text(String, bool),
286    /// Typed expression — resolved via evaluate_expr, never stringified.
287    Expr(Expr),
288}
289
290impl Arg {
291    pub fn as_str(&self) -> &str {
292        match self {
293            Arg::String(s, _) => s,
294            // Expressions and mixed values have no single borrowed string;
295            // use `render()` for an owned display form.
296            Arg::Expr(_) | Arg::Parts(_) => "",
297        }
298    }
299
300    /// Owned display form: `String` verbatim, `Expr` as source (`$x`),
301    /// `Parts` as fragment concatenation. Used for diagnostics and Display;
302    /// runtime resolution must match on variants instead (see resolve_arg).
303    pub fn render(&self) -> String {
304        match self {
305            Arg::String(s, _) => s.clone(),
306            Arg::Expr(e) => e.to_string(),
307            Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
308        }
309    }
310
311    pub fn is_quoted(&self) -> bool {
312        matches!(self, Arg::String(_, true))
313    }
314}
315
316impl ArgPart {
317    pub fn render(&self) -> String {
318        match self {
319            ArgPart::Text(s, _) => s.clone(),
320            ArgPart::Expr(e) => e.to_string(),
321        }
322    }
323}
324
325impl From<String> for Arg {
326    fn from(s: String) -> Self {
327        Arg::String(s, false)
328    }
329}
330
331impl From<&str> for Arg {
332    fn from(s: &str) -> Self {
333        Arg::String(s.to_string(), false)
334    }
335}
336
337impl std::fmt::Display for Arg {
338    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
339        match self {
340            Arg::String(s, _) => write!(f, "{}", s),
341            Arg::Expr(e) => write!(f, "{}", e),
342            Arg::Parts(parts) => {
343                for part in parts {
344                    match part {
345                        ArgPart::Text(s, _) => write!(f, "{}", s)?,
346                        ArgPart::Expr(e) => write!(f, "{}", e)?,
347                    }
348                }
349                Ok(())
350            }
351        }
352    }
353}
354
355impl AsRef<str> for Arg {
356    fn as_ref(&self) -> &str {
357        self.as_str()
358    }
359}
360
361impl PartialEq<str> for Arg {
362    fn eq(&self, other: &str) -> bool {
363        self.as_str() == other
364    }
365}
366
367impl PartialEq<&str> for Arg {
368    fn eq(&self, other: &&str) -> bool {
369        self.as_str() == *other
370    }
371}
372
373#[derive(Debug, Clone, Copy, Eq, PartialEq)]
374pub enum IoStream {
375    Stdin,
376    Stdout,
377    Stderr,
378}
379
380#[derive(Debug, Clone, Eq, PartialEq)]
381pub struct IoBinding {
382    pub stream: IoStream,
383    pub pipe: Option<PipeTarget>,
384}
385
386/// A pipe endpoint for a `WITH_IO` binding: either a literal `pipe:name`
387/// or a `$var` holding a `PIPE` value, resolved against the live pipe
388/// registry when the step runs.
389#[derive(Debug, Clone, Eq, PartialEq)]
390pub enum PipeTarget {
391    Name(String),
392    Var(String),
393}
394
395#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
396pub enum TypeKind {
397    String,
398    Int,
399    Float,
400    Bool,
401    Pipe,
402    List,
403    Map,
404    Handle,
405    Duration,
406    Path,
407}
408
409impl TypeKind {
410    pub const CANONICAL: &[TypeKind] = &[
411        TypeKind::String,
412        TypeKind::Int,
413        TypeKind::Float,
414        TypeKind::Bool,
415        TypeKind::Pipe,
416        TypeKind::List,
417        TypeKind::Map,
418        TypeKind::Handle,
419        TypeKind::Duration,
420        TypeKind::Path,
421    ];
422
423    /// Canonical display name. This is the single source of truth for the
424    /// type vocabulary: anchors, doc titles, `FromStr`, and the `ArgType` /
425    /// `FlagValueType` display labels all derive from these strings.
426    pub fn label(&self) -> &'static str {
427        match self {
428            TypeKind::String => "STRING",
429            TypeKind::Int => "INT",
430            TypeKind::Float => "FLOAT",
431            TypeKind::Bool => "BOOL",
432            TypeKind::Pipe => "PIPE",
433            TypeKind::List => "LIST",
434            TypeKind::Map => "MAP",
435            TypeKind::Handle => "HANDLE",
436            TypeKind::Duration => "DURATION",
437            TypeKind::Path => "PATH",
438        }
439    }
440
441    /// Reference body for the canonical types. The title derives from
442    /// [`label`](Self::label); only the prose body is stored per variant.
443    pub fn doc(&self) -> Option<(String, &'static str)> {
444        let body = match self {
445            TypeKind::String => {
446                "Arbitrary text. Quotes keep exact bytes, lone `$var` evaluates, `{{ ... }}` interpolates."
447            }
448            TypeKind::Int => "64-bit signed integer, e.g. an exit code.",
449            TypeKind::Float => "64-bit float, e.g. a ratio.",
450            TypeKind::Bool => "Boolean `true` or `false`.",
451            TypeKind::Pipe => {
452                "Named script pipe. Validity is checked against the pipe registry at coercion time."
453            }
454            TypeKind::List => "Ordered list of values.",
455            TypeKind::Map => "String-keyed map of values.",
456            TypeKind::Handle => "Background ASYNC task handle for AWAIT/CANCEL.",
457            TypeKind::Duration => {
458                "Positive time span: `500ms`, `10s`, `2m`, `1h`; bare number means seconds."
459            }
460            TypeKind::Path => "Workspace path, resolved against cwd and guarded against escape.",
461        };
462        Some((format!("Value type: {}", self.label()), body))
463    }
464
465    /// Anchor of the type's reference section, derived from
466    /// [`label`](Self::label) the way the Markdown slugger would derive it
467    /// from the doc title.
468    pub fn anchor(&self) -> String {
469        format!("value-type-{}", self.label().to_lowercase())
470    }
471}
472
473impl std::str::FromStr for TypeKind {
474    type Err = anyhow::Error;
475    fn from_str(s: &str) -> Result<Self, Self::Err> {
476        if let Some(kind) = Self::CANONICAL.iter().find(|k| k.label() == s) {
477            return Ok(*kind);
478        }
479        let inventory = Self::CANONICAL
480            .iter()
481            .map(|k| k.label())
482            .collect::<Vec<_>>()
483            .join(", ");
484        anyhow::bail!("unknown type `{s}`; expected one of {inventory}")
485    }
486}
487
488impl std::fmt::Display for TypeKind {
489    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
490        write!(f, "{}", self.label())
491    }
492}
493
494#[derive(Debug, Clone, PartialEq)]
495pub enum Value {
496    String(String),
497    Int(i64),
498    Float(f64),
499    List(Vec<Value>),
500    Map(std::collections::BTreeMap<String, Value>),
501    Bool(bool),
502    Pipe(String), // holds pipe name; validity checked against PipeRegistry
503    Duration(std::time::Duration),
504    // Narrow exception: the PATH slot carries an already-resolved path value.
505    // All guard checks still run through oxdock-fs at coercion/use time.
506    #[allow(clippy::disallowed_types)]
507    Path(std::path::PathBuf),
508    /// Handle to a background ASYNC task. The `u64` is the task ID
509    /// used to look up the handle in `ExecState.named_tasks`.
510    TaskHandle(u64),
511}
512
513#[derive(Debug, Clone, Copy, Eq, PartialEq)]
514pub enum CompareOp {
515    Eq,
516    Ne,
517    Lt,
518    Le,
519    Gt,
520    Ge,
521}
522
523#[derive(Debug, Clone, Copy, Eq, PartialEq)]
524pub enum ArithOp {
525    Add,
526    Sub,
527    Mul,
528    Div,
529}
530
531/// Flat stack-machine op for expression-local arithmetic/comparison.
532///
533/// Lowering folds constant subtrees to `Expr::Literal` and compiles dynamic
534/// arithmetic/comparison subtrees to post-order `Vec<MathOp>` so the runtime
535/// executes a single instruction loop instead of recursive `Box` walking.
536/// `Call` covers value-semantics functions only (`INT`, `FLOAT`, `GLOB`,
537/// `LOAD_TOML`, `LOAD_JSON`); `INSPECT($var)` uses `Inspect` to preserve the
538/// variable identifier (pre-evaluating to `Value` would lose the name).
539#[derive(Debug, Clone, PartialEq)]
540pub enum MathOp {
541    PushConst(Value),
542    LoadVar(String),
543    LoadEnv(String),
544    LoadKeyPath { base: String, keys: Vec<String> },
545    Call { name: String, arity: usize },
546    Inspect(String),
547    Neg,
548    Add,
549    Sub,
550    Mul,
551    Div,
552    Lt,
553    Le,
554    Gt,
555    Ge,
556    Eq,
557    Ne,
558}
559
560#[derive(Debug, Clone, Copy, Eq, PartialEq)]
561pub enum LogicalOp {
562    And,
563    Or,
564}
565
566#[derive(Debug, Clone, PartialEq)]
567pub enum Expr {
568    Literal(Value),
569    Var(String),
570    /// Environment read (`env:KEY`): resolves against the script
571    /// environment at evaluation time.
572    Env(String),
573    KeyPath {
574        base: String,
575        keys: Vec<String>,
576    },
577    List(Vec<Expr>),
578    Map(Vec<(String, Expr)>),
579    Call {
580        name: String,
581        args: Vec<Expr>,
582    },
583    Compare {
584        op: CompareOp,
585        left: Box<Expr>,
586        right: Box<Expr>,
587    },
588    Arithmetic {
589        op: ArithOp,
590        left: Box<Expr>,
591        right: Box<Expr>,
592    },
593    /// Lowering-optimized form: folded literals stay `Literal`, dynamic
594    /// arithmetic/comparison subtrees arrive here as flat RPN.
595    CompiledMath(Vec<MathOp>),
596    /// Lowering-only intermediate staging `9223372036854775808` (the unsigned
597    /// half of `i64::MIN`). Valid only as the direct child of unary `-`;
598    /// any instance reaching lowering completion bails integer overflow.
599    UnsignedIntBoundary(u64),
600    Not(Box<Expr>),
601    Logical {
602        op: LogicalOp,
603        left: Box<Expr>,
604        right: Box<Expr>,
605    },
606}
607
608#[derive(Debug, Clone, PartialEq)]
609pub struct Step {
610    pub guard: Option<GuardExpr>,
611    pub kind: StepKind,
612    pub scope_enter: usize,
613    pub scope_exit: usize,
614}
615
616#[derive(Debug, Clone, Eq, PartialEq)]
617pub enum WorkspaceTarget {
618    Snapshot,
619    Local,
620}
621
622fn platform_matches(target: PlatformGuard) -> bool {
623    #[allow(clippy::disallowed_macros)]
624    match target {
625        PlatformGuard::Unix => cfg!(unix),
626        PlatformGuard::Windows => cfg!(windows),
627        PlatformGuard::Macos => cfg!(target_os = "macos"),
628        PlatformGuard::Linux => cfg!(target_os = "linux"),
629    }
630}
631
632pub trait EnvLookup {
633    fn get_env(&self, key: &str) -> Option<&str>;
634}
635
636impl EnvLookup for HashMap<String, String> {
637    fn get_env(&self, key: &str) -> Option<&str> {
638        self.get(key).map(|s| s.as_str())
639    }
640}
641
642impl EnvLookup for Arc<HashMap<String, String>> {
643    fn get_env(&self, key: &str) -> Option<&str> {
644        (**self).get_env(key)
645    }
646}
647
648pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
649    match guard {
650        Guard::Platform { target } => platform_matches(*target),
651        Guard::EnvExists { key } => env.get_env(key).map(|v| !v.is_empty()).unwrap_or(false),
652        Guard::EnvEquals { key, value } => env
653            .get_env(key)
654            .map(|v| v == value.as_str())
655            .unwrap_or(false),
656        Guard::StaticBool { value } => value.parse::<bool>().unwrap_or(false),
657    }
658}
659
660pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
661    match expr {
662        GuardExpr::Predicate(guard) => guard_allows(guard, env),
663        GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
664        GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
665        GuardExpr::Not(child) => !guard_expr_allows(child, env),
666    }
667}
668
669pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
670    match expr {
671        Some(e) => guard_expr_allows(e, env),
672        None => true,
673    }
674}
675
676use std::fmt;
677
678impl fmt::Display for PlatformGuard {
679    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
680        match self {
681            PlatformGuard::Unix => write!(f, "unix"),
682            PlatformGuard::Windows => write!(f, "windows"),
683            PlatformGuard::Macos => write!(f, "macos"),
684            PlatformGuard::Linux => write!(f, "linux"),
685        }
686    }
687}
688
689impl fmt::Display for Guard {
690    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
691        match self {
692            Guard::Platform { target } => write!(f, "{}", target),
693            Guard::EnvExists { key } => write!(f, "env:{}", key),
694            Guard::EnvEquals { key, value } => write!(f, "eq(env:{}, {})", key, value),
695            Guard::StaticBool { value } => write!(f, "bool:{}", value),
696        }
697    }
698}
699
700impl fmt::Display for WorkspaceTarget {
701    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
702        match self {
703            WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
704            WorkspaceTarget::Local => write!(f, "LOCAL"),
705        }
706    }
707}
708
709impl fmt::Display for Value {
710    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
711        match self {
712            Value::String(s) => write!(f, "\"{}\"", s),
713            Value::Int(i) => write!(f, "{}", i),
714            Value::Float(v) => write!(f, "{}", v),
715            Value::Pipe(n) => write!(f, "pipe:{}", n),
716            Value::Duration(d) => write!(f, "{}", crate::command::format_duration(d)),
717            Value::Path(p) => write!(f, "{}", p.display()),
718            Value::List(items) => {
719                write!(f, "[")?;
720                for (i, item) in items.iter().enumerate() {
721                    if i > 0 {
722                        write!(f, ", ")?;
723                    }
724                    write!(f, "{}", item)?;
725                }
726                write!(f, "]")
727            }
728            Value::Map(map) => {
729                write!(f, "{{")?;
730                for (i, (k, v)) in map.iter().enumerate() {
731                    if i > 0 {
732                        write!(f, ", ")?;
733                    }
734                    write!(f, "{}: {}", k, v)?;
735                }
736                write!(f, "}}")
737            }
738            Value::Bool(b) => write!(f, "{}", b),
739            Value::TaskHandle(id) => write!(f, "task#{}", id),
740        }
741    }
742}
743
744impl fmt::Display for Expr {
745    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
746        match self {
747            Expr::Literal(v) => write!(f, "{}", v),
748            Expr::Var(name) => write!(f, "${}", name),
749            Expr::Env(key) => write!(f, "env:{}", key),
750            Expr::KeyPath { base, keys } => {
751                write!(f, "${}", base)?;
752                for key in keys {
753                    write!(f, ".{}", key)?;
754                }
755                Ok(())
756            }
757            Expr::Call { name, args } => {
758                write!(f, "{}(", name)?;
759                for (i, arg) in args.iter().enumerate() {
760                    if i > 0 {
761                        write!(f, ", ")?;
762                    }
763                    write!(f, "{}", arg)?;
764                }
765                write!(f, ")")
766            }
767            Expr::List(items) => {
768                write!(f, "[")?;
769                for (i, item) in items.iter().enumerate() {
770                    if i > 0 {
771                        write!(f, ", ")?;
772                    }
773                    write!(f, "{}", item)?;
774                }
775                write!(f, "]")
776            }
777            Expr::Map(entries) => {
778                write!(f, "{{")?;
779                for (i, (key, val)) in entries.iter().enumerate() {
780                    if i > 0 {
781                        write!(f, ", ")?;
782                    }
783                    write!(f, "\"{}\": {}", key, val)?;
784                }
785                write!(f, "}}")
786            }
787            Expr::Compare { op, left, right } => {
788                write!(f, "{} {} {}", left, op, right)
789            }
790            Expr::Arithmetic { op, left, right } => {
791                write!(f, "({} {} {})", left, op, right)
792            }
793            Expr::CompiledMath(ops) => {
794                write!(f, "{}", format_compiled_math(ops))
795            }
796            Expr::UnsignedIntBoundary(n) => write!(f, "{}", n),
797            Expr::Not(inner) => {
798                // Parenthesize compound operands so Display round-trips:
799                // `!(a == b)` must not render as `!a == b` (= `(!a) == b`).
800                match inner.as_ref() {
801                    Expr::Compare { .. } | Expr::Arithmetic { .. } | Expr::CompiledMath(_) => {
802                        write!(f, "!({})", inner)
803                    }
804                    _ => write!(f, "!{}", inner),
805                }
806            }
807            Expr::Logical { op, left, right } => {
808                write!(f, "({} {} {})", left, op, right)
809            }
810        }
811    }
812}
813
814impl fmt::Display for CompareOp {
815    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
816        match self {
817            CompareOp::Eq => write!(f, "=="),
818            CompareOp::Ne => write!(f, "!="),
819            CompareOp::Lt => write!(f, "<"),
820            CompareOp::Le => write!(f, "<="),
821            CompareOp::Gt => write!(f, ">"),
822            CompareOp::Ge => write!(f, ">="),
823        }
824    }
825}
826
827impl fmt::Display for ArithOp {
828    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
829        match self {
830            ArithOp::Add => write!(f, "+"),
831            ArithOp::Sub => write!(f, "-"),
832            ArithOp::Mul => write!(f, "*"),
833            ArithOp::Div => write!(f, "/"),
834        }
835    }
836}
837
838/// Render flat RPN back to parenthesized infix so `Display` round-trips
839/// through the parser with identical semantics. Parentheses are emitted
840/// unconditionally around binary/unary ops; redundant parens parse to the
841/// same tree, which is what round-trip requires.
842fn format_compiled_math(ops: &[MathOp]) -> String {
843    let mut stack: Vec<String> = Vec::new();
844    for op in ops {
845        match op {
846            MathOp::PushConst(v) => stack.push(format!("{}", v)),
847            MathOp::LoadVar(name) => stack.push(format!("${}", name)),
848            MathOp::LoadEnv(key) => stack.push(format!("env:{}", key)),
849            MathOp::LoadKeyPath { base, keys } => {
850                let mut s = format!("${}", base);
851                for key in keys {
852                    s.push('.');
853                    s.push_str(key);
854                }
855                stack.push(s);
856            }
857            MathOp::Call { name, arity } => {
858                let mut args = Vec::new();
859                for _ in 0..*arity {
860                    args.push(stack.pop().unwrap_or_else(|| "<underflow>".to_string()));
861                }
862                args.reverse();
863                stack.push(format!("{}({})", name, args.join(", ")));
864            }
865            MathOp::Inspect(name) => stack.push(format!("INSPECT(${})", name)),
866            MathOp::Neg => {
867                let inner = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
868                stack.push(format!("(-{})", inner));
869            }
870            MathOp::Add => push_bin(&mut stack, "+"),
871            MathOp::Sub => push_bin(&mut stack, "-"),
872            MathOp::Mul => push_bin(&mut stack, "*"),
873            MathOp::Div => push_bin(&mut stack, "/"),
874            MathOp::Lt => push_bin(&mut stack, "<"),
875            MathOp::Le => push_bin(&mut stack, "<="),
876            MathOp::Gt => push_bin(&mut stack, ">"),
877            MathOp::Ge => push_bin(&mut stack, ">="),
878            MathOp::Eq => push_bin(&mut stack, "=="),
879            MathOp::Ne => push_bin(&mut stack, "!="),
880        }
881    }
882    if stack.len() == 1 {
883        let mut items = stack;
884        items.pop().unwrap_or_else(|| "<empty>".to_string())
885    } else {
886        stack.join(" ")
887    }
888}
889
890fn push_bin(stack: &mut Vec<String>, op: &str) {
891    let right = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
892    let left = stack.pop().unwrap_or_else(|| "<underflow>".to_string());
893    stack.push(format!("({} {} {})", left, op, right));
894}
895
896impl fmt::Display for LogicalOp {
897    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
898        match self {
899            LogicalOp::And => write!(f, "&&"),
900            LogicalOp::Or => write!(f, "||"),
901        }
902    }
903}
904
905enum GuardDisplayContext {
906    Root,
907    InAnyArg,
908    InNot,
909    InAll,
910}
911
912impl GuardExpr {
913    fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
914        match self {
915            GuardExpr::Predicate(guard) => write!(f, "{}", guard),
916            GuardExpr::All(children) => {
917                let wrap = matches!(
918                    ctx,
919                    GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
920                ) && children.len() > 1;
921                if wrap {
922                    write!(f, "(")?;
923                }
924                for (i, child) in children.iter().enumerate() {
925                    if i > 0 {
926                        write!(f, ", ")?;
927                    }
928                    child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
929                }
930                if wrap {
931                    write!(f, ")")?;
932                }
933                Ok(())
934            }
935            GuardExpr::Or(children) => {
936                write!(f, "any(")?;
937                for (i, child) in children.iter().enumerate() {
938                    if i > 0 {
939                        write!(f, ", ")?;
940                    }
941                    child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
942                }
943                write!(f, ")")
944            }
945            GuardExpr::Not(child) => {
946                write!(f, "not(")?;
947                child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
948                write!(f, ")")
949            }
950        }
951    }
952}
953
954impl fmt::Display for GuardExpr {
955    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
956        self.fmt_with_ctx(f, GuardDisplayContext::Root)
957    }
958}
959
960impl fmt::Display for Step {
961    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
962        if let Some(expr) = &self.guard {
963            write!(f, "[{}] ", expr)?;
964        }
965        write!(f, "{}", self.kind)
966    }
967}
968
969#[cfg(test)]
970mod tests {
971    use super::*;
972    /// Bidirectional lock between `dsl.pest` and the keyword registry.
973    /// Fails CI if a statement keyword is renamed or deleted on either side.
974    /// The grammar is parsed with `pest_meta` (no line-format or rule-name
975    /// conventions): every [`STRUCTURAL_KEYWORDS`] entry must occur as a
976    /// string literal somewhere in the grammar, and every uppercase literal
977    /// reachable from the top-level instruction rules (`element`,
978    /// `block_element`, following rule references transitively) must
979    /// classify as a statement starter ([`Command::is_statement_keyword`])
980    /// or a clause keyword ([`CLAUSE_KEYWORDS`]). Type tags, argument
981    /// enums, and value-level function names validate at lowering time via
982    /// generic ident rules, so they are outside this test's scope by design
983    /// rather than via synthetic registries.
984    #[test]
985    fn statement_keywords_match_pest_grammar() {
986        use pest_meta::{ast::Expr, parser};
987        use std::collections::{HashMap, HashSet};
988
989        let pest_src = include_str!("dsl.pest");
990        let pairs = parser::parse(parser::Rule::grammar_rules, pest_src)
991            .expect("dsl.pest must parse as a pest grammar");
992        let rules = parser::consume_rules(pairs).expect("dsl.pest rules must consume");
993        let by_name: HashMap<&str, &Expr> = rules
994            .iter()
995            .map(|rule| (rule.name.as_str(), &rule.expr))
996            .collect();
997
998        let mut all_literals = HashSet::new();
999        for rule in &rules {
1000            for node in rule.expr.iter_top_down() {
1001                match node {
1002                    Expr::Str(literal) | Expr::Insens(literal) => {
1003                        all_literals.insert(literal.clone());
1004                    }
1005                    _ => {}
1006                }
1007            }
1008        }
1009        for kw in STRUCTURAL_KEYWORDS {
1010            assert!(
1011                all_literals.contains(*kw),
1012                "keyword {kw} in STRUCTURAL_KEYWORDS missing from dsl.pest"
1013            );
1014        }
1015
1016        let mut seen = HashSet::new();
1017        let mut stack = vec!["element".to_string(), "block_element".to_string()];
1018        let mut stmt_literals = HashSet::new();
1019        while let Some(name) = stack.pop() {
1020            if !seen.insert(name.clone()) {
1021                continue;
1022            }
1023            let Some(expr) = by_name.get(name.as_str()) else {
1024                continue;
1025            };
1026            for node in expr.iter_top_down() {
1027                match node {
1028                    Expr::Str(literal) | Expr::Insens(literal) => {
1029                        stmt_literals.insert(literal.clone());
1030                    }
1031                    Expr::Ident(dependency) => stack.push(dependency.clone()),
1032                    _ => {}
1033                }
1034            }
1035        }
1036        assert!(
1037            seen.contains("element") && seen.contains("block_element"),
1038            "grammar must define element and block_element instruction rules"
1039        );
1040        for kw in stmt_literals {
1041            if kw.len() > 1 && kw.chars().all(|c| c.is_ascii_uppercase()) {
1042                assert!(
1043                    crate::Command::is_statement_keyword(&kw)
1044                        || CLAUSE_KEYWORDS.contains(&kw.as_str()),
1045                    "uppercase literal \"{kw}\" reachable from dsl.pest instruction rules is not a registered statement or clause keyword"
1046                );
1047            }
1048        }
1049    }
1050}