Skip to main content

oxdock_parser/
ast.rs

1use std::collections::HashMap;
2use std::sync::Arc;
3
4pub use crate::commands::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    AssertFile,
28    AssertDir,
29    AssertAbsent,
30    AssertStdout,
31    Exit,
32    Async,
33    Timeout,
34    Sleep,
35}
36
37pub const COMMANDS: &[Command] = &[
38    Command::InheritEnv,
39    Command::Workdir,
40    Command::Workspace,
41    Command::Env,
42    Command::Echo,
43    Command::Run,
44    Command::Copy,
45    Command::WithIo,
46    Command::CopyGit,
47    Command::HashSha256,
48    Command::Symlink,
49    Command::Mkdir,
50    Command::Ls,
51    Command::Cwd,
52    Command::Read,
53    Command::ReadLine,
54    Command::Write,
55    Command::Append,
56    Command::Expand,
57    Command::AssertFile,
58    Command::AssertDir,
59    Command::AssertAbsent,
60    Command::AssertStdout,
61    Command::Exit,
62    Command::Timeout,
63    Command::Sleep,
64];
65
66impl Command {
67    pub const fn as_str(self) -> &'static str {
68        match self {
69            Command::InheritEnv => "INHERIT_ENV",
70            Command::Workdir => "WORKDIR",
71            Command::Workspace => "WORKSPACE",
72            Command::Env => "ENV",
73            Command::Echo => "ECHO",
74            Command::Run => "RUN",
75            Command::Copy => "COPY",
76            Command::WithIo => "WITH_IO",
77            Command::CopyGit => "COPY_GIT",
78            Command::HashSha256 => "HASH_SHA256",
79            Command::Symlink => "SYMLINK",
80            Command::Mkdir => "MKDIR",
81            Command::Ls => "LS",
82            Command::Cwd => "CWD",
83            Command::Read => "READ",
84            Command::ReadLine => "READ_LINE",
85            Command::Write => "WRITE",
86            Command::Append => "APPEND",
87            Command::Expand => "EXPAND",
88            Command::AssertFile => "ASSERT_FILE",
89            Command::AssertDir => "ASSERT_DIR",
90            Command::AssertAbsent => "ASSERT_ABSENT",
91            Command::AssertStdout => "ASSERT_STDOUT",
92            Command::Exit => "EXIT",
93            Command::Async => "ASYNC",
94            Command::Timeout => "TIMEOUT",
95            Command::Sleep => "SLEEP",
96        }
97    }
98
99    pub const fn syntax(self) -> &'static str {
100        match self {
101            Command::InheritEnv => "INHERIT_ENV [KEY1, KEY2, ...]",
102            Command::Workdir => "WORKDIR <path>",
103            Command::Workspace => "WORKSPACE SNAPSHOT|LOCAL",
104            Command::Env => "ENV KEY=value",
105            Command::Echo => "ECHO <message>",
106            Command::Run => "RUN <command...>",
107            Command::Copy => "COPY [--from-current-workspace] <from> <to>",
108            Command::CopyGit => "COPY_GIT [--include-dirty] <rev> <src> <dst>",
109            Command::WithIo => "WITH_IO [bindings] [command | { block }]",
110            Command::HashSha256 => "HASH_SHA256 <path>",
111            Command::Symlink => "SYMLINK <from> <to>",
112            Command::Mkdir => "MKDIR <path>",
113            Command::Ls => "LS [<path>]",
114            Command::Cwd => "CWD",
115            Command::Read => "READ [<path>]",
116            Command::ReadLine => "READ_LINE $var",
117            Command::Write => "WRITE <path> [<contents>]",
118            Command::Append => "APPEND <path> [<contents>]",
119            Command::Expand => "EXPAND [<path>] [<KEY=val> ...]",
120            Command::AssertFile => "ASSERT_FILE [--hash <sha256>] <path> [<expected>]",
121            Command::AssertDir => "ASSERT_DIR <path>",
122            Command::AssertAbsent => "ASSERT_ABSENT <path>",
123            Command::AssertStdout => "ASSERT_STDOUT <substring>",
124            Command::Exit => "EXIT <code>",
125            Command::Async => "ASYNC <command...> | ASYNC { <commands> }",
126            Command::Timeout => {
127                "TIMEOUT <duration> <command...> | TIMEOUT <duration> { <commands> }"
128            }
129            Command::Sleep => "SLEEP <duration>",
130        }
131    }
132
133    pub const fn expects_inner_command(self) -> bool {
134        matches!(self, Command::WithIo | Command::Async | Command::Timeout)
135    }
136
137    pub fn parse(s: &str) -> Option<Self> {
138        match s {
139            "INHERIT_ENV" => Some(Command::InheritEnv),
140            "WORKDIR" => Some(Command::Workdir),
141            "WORKSPACE" => Some(Command::Workspace),
142            "ENV" => Some(Command::Env),
143            "ECHO" => Some(Command::Echo),
144            "RUN" => Some(Command::Run),
145            "COPY" => Some(Command::Copy),
146            "WITH_IO" => Some(Command::WithIo),
147            "COPY_GIT" => Some(Command::CopyGit),
148            "HASH_SHA256" => Some(Command::HashSha256),
149            "SYMLINK" => Some(Command::Symlink),
150            "MKDIR" => Some(Command::Mkdir),
151            "LS" => Some(Command::Ls),
152            "CWD" => Some(Command::Cwd),
153            "READ" => Some(Command::Read),
154            "READ_LINE" => Some(Command::ReadLine),
155            "WRITE" => Some(Command::Write),
156            "APPEND" => Some(Command::Append),
157            "EXPAND" => Some(Command::Expand),
158            "ASSERT_FILE" => Some(Command::AssertFile),
159            "ASSERT_DIR" => Some(Command::AssertDir),
160            "ASSERT_ABSENT" => Some(Command::AssertAbsent),
161            "ASSERT_STDOUT" => Some(Command::AssertStdout),
162            "EXIT" => Some(Command::Exit),
163            "ASYNC" => Some(Command::Async),
164            "TIMEOUT" => Some(Command::Timeout),
165            "SLEEP" => Some(Command::Sleep),
166            _ => None,
167        }
168    }
169}
170
171#[derive(Copy, Clone, Debug, Eq, PartialEq)]
172pub enum PlatformGuard {
173    Unix,
174    Windows,
175    Macos,
176    Linux,
177}
178
179#[derive(Debug, Clone, Eq, PartialEq)]
180pub enum Guard {
181    Platform { target: PlatformGuard },
182    EnvExists { key: String },
183    EnvEquals { key: String, value: String },
184    StaticBool { value: String },
185}
186
187#[derive(Debug, Clone, Eq, PartialEq)]
188pub enum GuardExpr {
189    Predicate(Guard),
190    All(Vec<GuardExpr>),
191    Or(Vec<GuardExpr>),
192    Not(Box<GuardExpr>),
193}
194
195impl GuardExpr {
196    pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
197        let mut flat = Vec::new();
198        for expr in exprs {
199            match expr {
200                GuardExpr::All(children) => flat.extend(children),
201                other => flat.push(other),
202            }
203        }
204        match flat.len() {
205            0 => panic!("GuardExpr::all requires at least one expression"),
206            1 => flat.into_iter().next().unwrap(),
207            _ => GuardExpr::All(flat),
208        }
209    }
210
211    pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
212        let mut flat = Vec::new();
213        for expr in exprs {
214            match expr {
215                GuardExpr::Or(children) => flat.extend(children),
216                other => flat.push(other),
217            }
218        }
219        match flat.len() {
220            0 => panic!("GuardExpr::or requires at least one expression"),
221            1 => flat.into_iter().next().unwrap(),
222            _ => GuardExpr::Or(flat),
223        }
224    }
225
226    pub fn invert(expr: GuardExpr) -> GuardExpr {
227        match expr {
228            GuardExpr::Not(inner) => *inner,
229            other => GuardExpr::Not(Box::new(other)),
230        }
231    }
232}
233
234impl std::ops::Not for GuardExpr {
235    type Output = GuardExpr;
236
237    fn not(self) -> GuardExpr {
238        match self {
239            GuardExpr::Not(inner) => *inner,
240            other => GuardExpr::Not(Box::new(other)),
241        }
242    }
243}
244
245impl From<Guard> for GuardExpr {
246    fn from(guard: Guard) -> Self {
247        GuardExpr::Predicate(guard)
248    }
249}
250
251/// A command argument — either an expandable string or an expression.
252#[derive(Debug, Clone, Eq, PartialEq)]
253pub enum Arg {
254    /// Expandable string. The `bool` indicates whether the argument was
255    /// quoted in the source (`true`) or unquoted (`false`). Quoted arguments
256    /// that start with `--` are positional, not flags.
257    String(String, bool),
258    /// Expression — resolved at runtime via evaluate_expr.
259    Expr(Expr),
260    /// Mixed literal/expression value (e.g. `KEY={{ $x }} tail`). Fragments
261    /// resolve independently at runtime and concatenate with no added
262    /// separator — inter-fragment gaps are already materialized as `Text`.
263    Parts(Vec<ArgPart>),
264}
265
266/// One fragment of a mixed [`Arg::Parts`] value.
267#[derive(Debug, Clone, Eq, PartialEq)]
268pub enum ArgPart {
269    /// Literal text. The `bool` marks source-quoted regions (exact bytes);
270    /// unquoted text carries single-space-normalized gaps.
271    Text(String, bool),
272    /// Typed expression — resolved via evaluate_expr, never stringified.
273    Expr(Expr),
274}
275
276impl Arg {
277    pub fn as_str(&self) -> &str {
278        match self {
279            Arg::String(s, _) => s,
280            // Expressions and mixed values have no single borrowed string;
281            // use `render()` for an owned display form.
282            Arg::Expr(_) | Arg::Parts(_) => "",
283        }
284    }
285
286    /// Owned display form: `String` verbatim, `Expr` as source (`$x`),
287    /// `Parts` as fragment concatenation. Used for diagnostics and Display;
288    /// runtime resolution must match on variants instead (see resolve_arg).
289    pub fn render(&self) -> String {
290        match self {
291            Arg::String(s, _) => s.clone(),
292            Arg::Expr(e) => e.to_string(),
293            Arg::Parts(parts) => parts.iter().map(ArgPart::render).collect(),
294        }
295    }
296
297    pub fn is_quoted(&self) -> bool {
298        matches!(self, Arg::String(_, true))
299    }
300}
301
302impl ArgPart {
303    pub fn render(&self) -> String {
304        match self {
305            ArgPart::Text(s, _) => s.clone(),
306            ArgPart::Expr(e) => e.to_string(),
307        }
308    }
309}
310
311impl From<String> for Arg {
312    fn from(s: String) -> Self {
313        Arg::String(s, false)
314    }
315}
316
317impl From<&str> for Arg {
318    fn from(s: &str) -> Self {
319        Arg::String(s.to_string(), false)
320    }
321}
322
323impl std::fmt::Display for Arg {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        match self {
326            Arg::String(s, _) => write!(f, "{}", s),
327            Arg::Expr(e) => write!(f, "{}", e),
328            Arg::Parts(parts) => {
329                for part in parts {
330                    match part {
331                        ArgPart::Text(s, _) => write!(f, "{}", s)?,
332                        ArgPart::Expr(e) => write!(f, "{}", e)?,
333                    }
334                }
335                Ok(())
336            }
337        }
338    }
339}
340
341impl AsRef<str> for Arg {
342    fn as_ref(&self) -> &str {
343        self.as_str()
344    }
345}
346
347impl PartialEq<str> for Arg {
348    fn eq(&self, other: &str) -> bool {
349        self.as_str() == other
350    }
351}
352
353impl PartialEq<&str> for Arg {
354    fn eq(&self, other: &&str) -> bool {
355        self.as_str() == *other
356    }
357}
358
359#[derive(Debug, Clone, Eq, PartialEq)]
360pub enum IoStream {
361    Stdin,
362    Stdout,
363    Stderr,
364}
365
366#[derive(Debug, Clone, Eq, PartialEq)]
367pub struct IoBinding {
368    pub stream: IoStream,
369    pub pipe: Option<String>,
370}
371
372#[derive(Debug, Clone, Eq, PartialEq)]
373pub enum Value {
374    String(String),
375    Int(i64),
376    List(Vec<Value>),
377    Map(std::collections::BTreeMap<String, Value>),
378    Bool(bool),
379    /// Handle to a background ASYNC task. The `u64` is the task ID
380    /// used to look up the handle in `ExecState.named_tasks`.
381    TaskHandle(u64),
382}
383
384#[derive(Debug, Clone, Copy, Eq, PartialEq)]
385pub enum CompareOp {
386    Eq,
387    Ne,
388}
389
390#[derive(Debug, Clone, Copy, Eq, PartialEq)]
391pub enum LogicalOp {
392    And,
393    Or,
394}
395
396#[derive(Debug, Clone, Eq, PartialEq)]
397pub enum Expr {
398    Literal(Value),
399    Var(String),
400    KeyPath {
401        base: String,
402        keys: Vec<String>,
403    },
404    List(Vec<Expr>),
405    Map(Vec<(String, Expr)>),
406    Call {
407        name: String,
408        args: Vec<Expr>,
409    },
410    Compare {
411        op: CompareOp,
412        left: Box<Expr>,
413        right: Box<Expr>,
414    },
415    Not(Box<Expr>),
416    Logical {
417        op: LogicalOp,
418        left: Box<Expr>,
419        right: Box<Expr>,
420    },
421}
422
423#[derive(Debug, Clone, Eq, PartialEq)]
424pub struct Step {
425    pub guard: Option<GuardExpr>,
426    pub kind: StepKind,
427    pub scope_enter: usize,
428    pub scope_exit: usize,
429}
430
431#[derive(Debug, Clone, Eq, PartialEq)]
432pub enum WorkspaceTarget {
433    Snapshot,
434    Local,
435}
436
437fn platform_matches(target: PlatformGuard) -> bool {
438    #[allow(clippy::disallowed_macros)]
439    match target {
440        PlatformGuard::Unix => cfg!(unix),
441        PlatformGuard::Windows => cfg!(windows),
442        PlatformGuard::Macos => cfg!(target_os = "macos"),
443        PlatformGuard::Linux => cfg!(target_os = "linux"),
444    }
445}
446
447pub trait EnvLookup {
448    fn get_env(&self, key: &str) -> Option<&str>;
449}
450
451impl EnvLookup for HashMap<String, String> {
452    fn get_env(&self, key: &str) -> Option<&str> {
453        self.get(key).map(|s| s.as_str())
454    }
455}
456
457impl EnvLookup for Arc<HashMap<String, String>> {
458    fn get_env(&self, key: &str) -> Option<&str> {
459        (**self).get_env(key)
460    }
461}
462
463pub fn guard_allows(guard: &Guard, env: &impl EnvLookup) -> bool {
464    match guard {
465        Guard::Platform { target } => platform_matches(*target),
466        Guard::EnvExists { key } => env.get_env(key).map(|v| !v.is_empty()).unwrap_or(false),
467        Guard::EnvEquals { key, value } => env
468            .get_env(key)
469            .map(|v| v == value.as_str())
470            .unwrap_or(false),
471        Guard::StaticBool { value } => value.parse::<bool>().unwrap_or(false),
472    }
473}
474
475pub fn guard_expr_allows(expr: &GuardExpr, env: &impl EnvLookup) -> bool {
476    match expr {
477        GuardExpr::Predicate(guard) => guard_allows(guard, env),
478        GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, env)),
479        GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, env)),
480        GuardExpr::Not(child) => !guard_expr_allows(child, env),
481    }
482}
483
484pub fn guard_option_allows(expr: Option<&GuardExpr>, env: &impl EnvLookup) -> bool {
485    match expr {
486        Some(e) => guard_expr_allows(e, env),
487        None => true,
488    }
489}
490
491use std::fmt;
492
493impl fmt::Display for PlatformGuard {
494    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
495        match self {
496            PlatformGuard::Unix => write!(f, "unix"),
497            PlatformGuard::Windows => write!(f, "windows"),
498            PlatformGuard::Macos => write!(f, "macos"),
499            PlatformGuard::Linux => write!(f, "linux"),
500        }
501    }
502}
503
504impl fmt::Display for Guard {
505    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
506        match self {
507            Guard::Platform { target } => write!(f, "{}", target),
508            Guard::EnvExists { key } => write!(f, "env:{}", key),
509            Guard::EnvEquals { key, value } => write!(f, "eq(env:{}, {})", key, value),
510            Guard::StaticBool { value } => write!(f, "bool:{}", value),
511        }
512    }
513}
514
515impl fmt::Display for WorkspaceTarget {
516    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
517        match self {
518            WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
519            WorkspaceTarget::Local => write!(f, "LOCAL"),
520        }
521    }
522}
523
524impl fmt::Display for Value {
525    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
526        match self {
527            Value::String(s) => write!(f, "\"{}\"", s),
528            Value::Int(i) => write!(f, "{}", i),
529            Value::List(items) => {
530                write!(f, "[")?;
531                for (i, item) in items.iter().enumerate() {
532                    if i > 0 {
533                        write!(f, ", ")?;
534                    }
535                    write!(f, "{}", item)?;
536                }
537                write!(f, "]")
538            }
539            Value::Map(map) => {
540                write!(f, "{{")?;
541                for (i, (k, v)) in map.iter().enumerate() {
542                    if i > 0 {
543                        write!(f, ", ")?;
544                    }
545                    write!(f, "{}: {}", k, v)?;
546                }
547                write!(f, "}}")
548            }
549            Value::Bool(b) => write!(f, "{}", b),
550            Value::TaskHandle(id) => write!(f, "task#{}", id),
551        }
552    }
553}
554
555impl fmt::Display for Expr {
556    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
557        match self {
558            Expr::Literal(v) => write!(f, "{}", v),
559            Expr::Var(name) => write!(f, "${}", name),
560            Expr::KeyPath { base, keys } => {
561                write!(f, "${}", base)?;
562                for key in keys {
563                    write!(f, ".{}", key)?;
564                }
565                Ok(())
566            }
567            Expr::Call { name, args } => {
568                write!(f, "{}(", name)?;
569                for (i, arg) in args.iter().enumerate() {
570                    if i > 0 {
571                        write!(f, ", ")?;
572                    }
573                    write!(f, "{}", arg)?;
574                }
575                write!(f, ")")
576            }
577            Expr::List(items) => {
578                write!(f, "[")?;
579                for (i, item) in items.iter().enumerate() {
580                    if i > 0 {
581                        write!(f, ", ")?;
582                    }
583                    write!(f, "{}", item)?;
584                }
585                write!(f, "]")
586            }
587            Expr::Map(entries) => {
588                write!(f, "{{")?;
589                for (i, (key, val)) in entries.iter().enumerate() {
590                    if i > 0 {
591                        write!(f, ", ")?;
592                    }
593                    write!(f, "\"{}\": {}", key, val)?;
594                }
595                write!(f, "}}")
596            }
597            Expr::Compare { op, left, right } => {
598                write!(f, "{} {} {}", left, op, right)
599            }
600            Expr::Not(inner) => {
601                // Parenthesize compound operands so Display round-trips:
602                // `!(a == b)` must not render as `!a == b` (= `(!a) == b`).
603                match inner.as_ref() {
604                    Expr::Compare { .. } => write!(f, "!({})", inner),
605                    _ => write!(f, "!{}", inner),
606                }
607            }
608            Expr::Logical { op, left, right } => {
609                write!(f, "({} {} {})", left, op, right)
610            }
611        }
612    }
613}
614
615impl fmt::Display for CompareOp {
616    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
617        match self {
618            CompareOp::Eq => write!(f, "=="),
619            CompareOp::Ne => write!(f, "!="),
620        }
621    }
622}
623
624impl fmt::Display for LogicalOp {
625    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
626        match self {
627            LogicalOp::And => write!(f, "&&"),
628            LogicalOp::Or => write!(f, "||"),
629        }
630    }
631}
632
633enum GuardDisplayContext {
634    Root,
635    InAnyArg,
636    InNot,
637    InAll,
638}
639
640impl GuardExpr {
641    fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
642        match self {
643            GuardExpr::Predicate(guard) => write!(f, "{}", guard),
644            GuardExpr::All(children) => {
645                let wrap = matches!(
646                    ctx,
647                    GuardDisplayContext::InAnyArg | GuardDisplayContext::InNot
648                ) && children.len() > 1;
649                if wrap {
650                    write!(f, "(")?;
651                }
652                for (i, child) in children.iter().enumerate() {
653                    if i > 0 {
654                        write!(f, ", ")?;
655                    }
656                    child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
657                }
658                if wrap {
659                    write!(f, ")")?;
660                }
661                Ok(())
662            }
663            GuardExpr::Or(children) => {
664                write!(f, "any(")?;
665                for (i, child) in children.iter().enumerate() {
666                    if i > 0 {
667                        write!(f, ", ")?;
668                    }
669                    child.fmt_with_ctx(f, GuardDisplayContext::InAnyArg)?;
670                }
671                write!(f, ")")
672            }
673            GuardExpr::Not(child) => {
674                write!(f, "not(")?;
675                child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
676                write!(f, ")")
677            }
678        }
679    }
680}
681
682impl fmt::Display for GuardExpr {
683    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
684        self.fmt_with_ctx(f, GuardDisplayContext::Root)
685    }
686}
687
688impl fmt::Display for Step {
689    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
690        if let Some(expr) = &self.guard {
691            write!(f, "[{}] ", expr)?;
692        }
693        write!(f, "{}", self.kind)
694    }
695}