Skip to main content

oxdock_parser/
ast.rs

1use std::collections::HashMap;
2
3#[derive(Copy, Clone, Debug, Eq, PartialEq)]
4pub enum Command {
5    InheritEnv,
6    Workdir,
7    Workspace,
8    Env,
9    Echo,
10    Run,
11    RunBg,
12    Copy,
13    WithIo,
14    CopyGit,
15    HashSha256,
16    Symlink,
17    Mkdir,
18    Ls,
19    Cwd,
20    Read,
21    Write,
22    Append,
23    AssertFile,
24    AssertDir,
25    AssertAbsent,
26    AssertStdout,
27    Exit,
28}
29
30pub const COMMANDS: &[Command] = &[
31    Command::InheritEnv,
32    Command::Workdir,
33    Command::Workspace,
34    Command::Env,
35    Command::Echo,
36    Command::Run,
37    Command::RunBg,
38    Command::Copy,
39    Command::WithIo,
40    Command::CopyGit,
41    Command::HashSha256,
42    Command::Symlink,
43    Command::Mkdir,
44    Command::Ls,
45    Command::Cwd,
46    Command::Read,
47    Command::Write,
48    Command::Append,
49    Command::AssertFile,
50    Command::AssertDir,
51    Command::AssertAbsent,
52    Command::AssertStdout,
53    Command::Exit,
54];
55
56impl Command {
57    pub const fn as_str(self) -> &'static str {
58        match self {
59            Command::InheritEnv => "INHERIT_ENV",
60            Command::Workdir => "WORKDIR",
61            Command::Workspace => "WORKSPACE",
62            Command::Env => "ENV",
63            Command::Echo => "ECHO",
64            Command::Run => "RUN",
65            Command::RunBg => "RUN_BG",
66            Command::Copy => "COPY",
67            Command::WithIo => "WITH_IO",
68            Command::CopyGit => "COPY_GIT",
69            Command::HashSha256 => "HASH_SHA256",
70            Command::Symlink => "SYMLINK",
71            Command::Mkdir => "MKDIR",
72            Command::Ls => "LS",
73            Command::Cwd => "CWD",
74            Command::Read => "READ",
75            Command::Write => "WRITE",
76            Command::Append => "APPEND",
77            Command::AssertFile => "ASSERT_FILE",
78            Command::AssertDir => "ASSERT_DIR",
79            Command::AssertAbsent => "ASSERT_ABSENT",
80            Command::AssertStdout => "ASSERT_STDOUT",
81            Command::Exit => "EXIT",
82        }
83    }
84
85    pub const fn expects_inner_command(self) -> bool {
86        matches!(self, Command::WithIo)
87    }
88
89    pub fn parse(s: &str) -> Option<Self> {
90        match s {
91            "INHERIT_ENV" => Some(Command::InheritEnv),
92            "WORKDIR" => Some(Command::Workdir),
93            "WORKSPACE" => Some(Command::Workspace),
94            "ENV" => Some(Command::Env),
95            "ECHO" => Some(Command::Echo),
96            "RUN" => Some(Command::Run),
97            "RUN_BG" => Some(Command::RunBg),
98            "COPY" => Some(Command::Copy),
99            "WITH_IO" => Some(Command::WithIo),
100            "COPY_GIT" => Some(Command::CopyGit),
101            "HASH_SHA256" => Some(Command::HashSha256),
102            "SYMLINK" => Some(Command::Symlink),
103            "MKDIR" => Some(Command::Mkdir),
104            "LS" => Some(Command::Ls),
105            "CWD" => Some(Command::Cwd),
106            "READ" => Some(Command::Read),
107            "WRITE" => Some(Command::Write),
108            "APPEND" => Some(Command::Append),
109            "ASSERT_FILE" => Some(Command::AssertFile),
110            "ASSERT_DIR" => Some(Command::AssertDir),
111            "ASSERT_ABSENT" => Some(Command::AssertAbsent),
112            "ASSERT_STDOUT" => Some(Command::AssertStdout),
113            "EXIT" => Some(Command::Exit),
114            _ => None,
115        }
116    }
117}
118
119#[derive(Copy, Clone, Debug, Eq, PartialEq)]
120pub enum PlatformGuard {
121    Unix,
122    Windows,
123    Macos,
124    Linux,
125}
126
127#[derive(Debug, Clone, Eq, PartialEq)]
128pub enum Guard {
129    Platform {
130        target: PlatformGuard,
131        invert: bool,
132    },
133    EnvExists {
134        key: String,
135        invert: bool,
136    },
137    EnvEquals {
138        key: String,
139        value: String,
140        invert: bool,
141    },
142}
143
144#[derive(Debug, Clone, Eq, PartialEq)]
145pub enum GuardExpr {
146    Predicate(Guard),
147    All(Vec<GuardExpr>),
148    Or(Vec<GuardExpr>),
149    Not(Box<GuardExpr>),
150}
151
152impl GuardExpr {
153    pub fn all(exprs: Vec<GuardExpr>) -> GuardExpr {
154        let mut flat = Vec::new();
155        for expr in exprs {
156            match expr {
157                GuardExpr::All(children) => flat.extend(children),
158                other => flat.push(other),
159            }
160        }
161        match flat.len() {
162            0 => panic!("GuardExpr::all requires at least one expression"),
163            1 => flat.into_iter().next().unwrap(),
164            _ => GuardExpr::All(flat),
165        }
166    }
167
168    pub fn or(exprs: Vec<GuardExpr>) -> GuardExpr {
169        let mut flat = Vec::new();
170        for expr in exprs {
171            match expr {
172                GuardExpr::Or(children) => flat.extend(children),
173                other => flat.push(other),
174            }
175        }
176        match flat.len() {
177            0 => panic!("GuardExpr::or requires at least one expression"),
178            1 => flat.into_iter().next().unwrap(),
179            _ => GuardExpr::Or(flat),
180        }
181    }
182
183    pub fn invert(expr: GuardExpr) -> GuardExpr {
184        match expr {
185            GuardExpr::Not(inner) => *inner,
186            other => GuardExpr::Not(Box::new(other)),
187        }
188    }
189}
190
191impl std::ops::Not for GuardExpr {
192    type Output = GuardExpr;
193
194    fn not(self) -> GuardExpr {
195        match self {
196            GuardExpr::Not(inner) => *inner,
197            other => GuardExpr::Not(Box::new(other)),
198        }
199    }
200}
201
202impl From<Guard> for GuardExpr {
203    fn from(guard: Guard) -> Self {
204        GuardExpr::Predicate(guard)
205    }
206}
207
208#[derive(Debug, Clone, Eq, PartialEq)]
209pub struct TemplateString(pub String);
210
211impl From<String> for TemplateString {
212    fn from(s: String) -> Self {
213        TemplateString(s)
214    }
215}
216
217impl From<&str> for TemplateString {
218    fn from(s: &str) -> Self {
219        TemplateString(s.to_string())
220    }
221}
222
223impl std::fmt::Display for TemplateString {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        write!(f, "{}", self.0)
226    }
227}
228
229impl AsRef<str> for TemplateString {
230    fn as_ref(&self) -> &str {
231        &self.0
232    }
233}
234
235impl PartialEq<str> for TemplateString {
236    fn eq(&self, other: &str) -> bool {
237        self.0 == other
238    }
239}
240
241impl PartialEq<&str> for TemplateString {
242    fn eq(&self, other: &&str) -> bool {
243        self.0 == *other
244    }
245}
246
247impl std::ops::Deref for TemplateString {
248    type Target = str;
249
250    fn deref(&self) -> &Self::Target {
251        &self.0
252    }
253}
254
255#[derive(Debug, Clone, Eq, PartialEq)]
256pub enum IoStream {
257    Stdin,
258    Stdout,
259    Stderr,
260}
261
262#[derive(Debug, Clone, Eq, PartialEq)]
263pub struct IoBinding {
264    pub stream: IoStream,
265    pub pipe: Option<String>,
266}
267
268#[derive(Debug, Clone, Eq, PartialEq)]
269pub enum StepKind {
270    Workdir(TemplateString),
271    Workspace(WorkspaceTarget),
272    Env {
273        key: String,
274        value: TemplateString,
275    },
276    /// Directive to inherit a selective list of environment variables from the host.
277    /// This is intended to be declared in the prelude/top-level only.
278    InheritEnv {
279        keys: Vec<String>,
280    },
281    Run(TemplateString),
282    Echo(TemplateString),
283    RunBg(TemplateString),
284    Copy {
285        from_current_workspace: bool,
286        from: TemplateString,
287        to: TemplateString,
288    },
289    Symlink {
290        from: TemplateString,
291        to: TemplateString,
292    },
293    Mkdir(TemplateString),
294    Ls(Option<TemplateString>),
295    Cwd,
296    Read(Option<TemplateString>),
297    Write {
298        path: TemplateString,
299        contents: Option<TemplateString>,
300    },
301    Append {
302        path: TemplateString,
303        contents: Option<TemplateString>,
304    },
305    /// Verify a workspace file exists and, when `hash` or `contents` is
306    /// present, matches it. The grammar guarantees the invariant: `hash` set
307    /// implies a 64-hex digest and no `contents`.
308    AssertFile {
309        hash: Option<String>,
310        path: TemplateString,
311        contents: Option<TemplateString>,
312    },
313    AssertDir(TemplateString),
314    AssertAbsent(TemplateString),
315    AssertStdout(TemplateString),
316    WithIo {
317        bindings: Vec<IoBinding>,
318        cmd: Box<StepKind>,
319    },
320    WithIoBlock {
321        bindings: Vec<IoBinding>,
322    },
323    CopyGit {
324        rev: TemplateString,
325        from: TemplateString,
326        to: TemplateString,
327        include_dirty: bool,
328    },
329    HashSha256 {
330        path: TemplateString,
331    },
332    Exit(i32),
333}
334
335#[derive(Debug, Clone, Eq, PartialEq)]
336pub struct Step {
337    pub guard: Option<GuardExpr>,
338    pub kind: StepKind,
339    pub scope_enter: usize,
340    pub scope_exit: usize,
341}
342
343#[derive(Debug, Clone, Eq, PartialEq)]
344pub enum WorkspaceTarget {
345    Snapshot,
346    Local,
347}
348
349fn platform_matches(target: PlatformGuard) -> bool {
350    #[allow(clippy::disallowed_macros)]
351    match target {
352        PlatformGuard::Unix => cfg!(unix),
353        PlatformGuard::Windows => cfg!(windows),
354        PlatformGuard::Macos => cfg!(target_os = "macos"),
355        PlatformGuard::Linux => cfg!(target_os = "linux"),
356    }
357}
358
359pub fn guard_allows(guard: &Guard, script_envs: &HashMap<String, String>) -> bool {
360    match guard {
361        Guard::Platform { target, invert } => {
362            let res = platform_matches(*target);
363            if *invert { !res } else { res }
364        }
365        Guard::EnvExists { key, invert } => {
366            let res = script_envs
367                .get(key)
368                .cloned()
369                .or_else(|| std::env::var(key).ok())
370                .map(|v| !v.is_empty())
371                .unwrap_or(false);
372            if *invert { !res } else { res }
373        }
374        Guard::EnvEquals { key, value, invert } => {
375            let res = script_envs
376                .get(key)
377                .cloned()
378                .or_else(|| std::env::var(key).ok())
379                .map(|v| v == *value)
380                .unwrap_or(false);
381            if *invert { !res } else { res }
382        }
383    }
384}
385
386pub fn guard_expr_allows(expr: &GuardExpr, script_envs: &HashMap<String, String>) -> bool {
387    match expr {
388        GuardExpr::Predicate(guard) => guard_allows(guard, script_envs),
389        GuardExpr::All(children) => children.iter().all(|g| guard_expr_allows(g, script_envs)),
390        GuardExpr::Or(children) => children.iter().any(|g| guard_expr_allows(g, script_envs)),
391        GuardExpr::Not(child) => !guard_expr_allows(child, script_envs),
392    }
393}
394
395pub fn guard_option_allows(
396    expr: Option<&GuardExpr>,
397    script_envs: &HashMap<String, String>,
398) -> bool {
399    match expr {
400        Some(e) => guard_expr_allows(e, script_envs),
401        None => true,
402    }
403}
404
405use std::fmt;
406
407impl fmt::Display for PlatformGuard {
408    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
409        match self {
410            PlatformGuard::Unix => write!(f, "unix"),
411            PlatformGuard::Windows => write!(f, "windows"),
412            PlatformGuard::Macos => write!(f, "macos"),
413            PlatformGuard::Linux => write!(f, "linux"),
414        }
415    }
416}
417
418impl fmt::Display for Guard {
419    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
420        match self {
421            Guard::Platform { target, invert } => {
422                if *invert {
423                    write!(f, "!{}", target)
424                } else {
425                    write!(f, "{}", target)
426                }
427            }
428            Guard::EnvExists { key, invert } => {
429                if *invert {
430                    write!(f, "!")?
431                }
432                write!(f, "env:{}", key)
433            }
434            Guard::EnvEquals { key, value, invert } => {
435                if *invert {
436                    write!(f, "env:{}!={}", key, value)
437                } else {
438                    write!(f, "env:{}=={}", key, value)
439                }
440            }
441        }
442    }
443}
444
445impl fmt::Display for WorkspaceTarget {
446    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
447        match self {
448            WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
449            WorkspaceTarget::Local => write!(f, "LOCAL"),
450        }
451    }
452}
453
454fn quote_arg(s: &str) -> String {
455    // Strict quoting avoids parser ambiguity when commands accept additional payloads
456    // (e.g. WRITE path <payload>) so arguments are never mistaken for subsequent tokens.
457    // Also quote if it starts with a digit to avoid invalid Rust tokens (e.g. 0o8) in macros.
458    let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
459        && !s.starts_with(|c: char| c.is_ascii_digit())
460        // Avoid unquoted args that equal command keywords (they would be parsed as commands
461        // when reconstructed from TokenStream). Quote them to preserve intent.
462        && super::Command::parse(s).is_none();
463    if is_safe && !s.is_empty() {
464        s.to_string()
465    } else {
466        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
467    }
468}
469
470fn quote_msg(s: &str) -> String {
471    // Strict quoting to ensure round-trip stability through TokenStream (macro input).
472    // The macro input reconstructor removes spaces around "sticky" characters (/-.:=)
473    // and collapses multiple spaces, so we must quote strings containing them.
474    // We also quote strings with spaces to be safe, as TokenStream does not preserve whitespace.
475    // Also quote if it starts with a digit to avoid invalid Rust tokens.
476    let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
477        && !s.starts_with(|c: char| c.is_ascii_digit())
478        // As with args, avoid leaving bare tokens that match command names.
479        && super::Command::parse(s).is_none();
480
481    if is_safe && !s.is_empty() {
482        s.to_string()
483    } else {
484        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
485    }
486}
487
488fn quote_run(s: &str) -> String {
489    // For RUN commands, we want to preserve the raw string as much as possible.
490    // However, to ensure round-trip stability through TokenStream (macro input),
491    // we must ensure that the generated string is a valid sequence of Rust tokens.
492    // Invalid tokens (like 0o8) must be quoted.
493    // Also, sticky characters (like -) can merge with previous tokens in macro input,
494    // so we quote words starting with them to ensure separation.
495
496    let force_full_quote = s.is_empty()
497        || s.chars().any(|c| c == ';' || c == '\n' || c == '\r')
498        || s.contains("//")
499        || s.contains("/*");
500
501    if force_full_quote {
502        return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
503    }
504
505    s.split(' ')
506        .map(|word| {
507            let needs_quote = word.starts_with(|c: char| c.is_ascii_digit())
508                || word.starts_with(['/', '.', '-', ':', '=']);
509            if needs_quote {
510                format!("\"{}\"", word.replace('\\', "\\\\").replace('"', "\\\""))
511            } else {
512                word.to_string()
513            }
514        })
515        .collect::<Vec<_>>()
516        .join(" ")
517}
518
519fn format_io_binding(binding: &IoBinding) -> String {
520    let stream = match binding.stream {
521        IoStream::Stdin => "stdin",
522        IoStream::Stdout => "stdout",
523        IoStream::Stderr => "stderr",
524    };
525    if let Some(pipe) = &binding.pipe {
526        format!("{}=pipe:{}", stream, pipe)
527    } else {
528        stream.to_string()
529    }
530}
531
532impl fmt::Display for StepKind {
533    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
534        match self {
535            StepKind::InheritEnv { keys } => {
536                write!(f, "INHERIT_ENV [{}]", keys.join(", "))
537            }
538            StepKind::Workdir(arg) => write!(f, "WORKDIR {}", quote_arg(arg)),
539            StepKind::Workspace(target) => write!(f, "WORKSPACE {}", target),
540            StepKind::Env { key, value } => write!(f, "ENV {}={}", key, quote_arg(value)),
541            StepKind::Run(cmd) => write!(f, "RUN {}", quote_run(cmd)),
542            StepKind::Echo(msg) => write!(f, "ECHO {}", quote_msg(msg)),
543            StepKind::RunBg(cmd) => write!(f, "RUN_BG {}", quote_run(cmd)),
544            StepKind::Copy {
545                from_current_workspace,
546                from,
547                to,
548            } => {
549                if *from_current_workspace {
550                    write!(
551                        f,
552                        "COPY --from-current-workspace {} {}",
553                        quote_arg(from),
554                        quote_arg(to)
555                    )
556                } else {
557                    write!(f, "COPY {} {}", quote_arg(from), quote_arg(to))
558                }
559            }
560            StepKind::Symlink { from, to } => {
561                write!(f, "SYMLINK {} {}", quote_arg(from), quote_arg(to))
562            }
563            StepKind::Mkdir(arg) => write!(f, "MKDIR {}", quote_arg(arg)),
564            StepKind::Ls(arg) => {
565                write!(f, "LS")?;
566                if let Some(a) = arg {
567                    write!(f, " {}", quote_arg(a))?;
568                }
569                Ok(())
570            }
571            StepKind::Cwd => write!(f, "CWD"),
572            StepKind::Read(arg) => {
573                write!(f, "READ")?;
574                if let Some(a) = arg {
575                    write!(f, " {}", quote_arg(a))?;
576                }
577                Ok(())
578            }
579            StepKind::Write { path, contents } => {
580                write!(f, "WRITE {}", quote_arg(path))?;
581                if let Some(body) = contents {
582                    write!(f, " {}", quote_msg(body))?;
583                }
584                Ok(())
585            }
586            StepKind::Append { path, contents } => {
587                write!(f, "APPEND {}", quote_arg(path))?;
588                if let Some(body) = contents {
589                    write!(f, " {}", quote_msg(body))?;
590                }
591                Ok(())
592            }
593            StepKind::AssertFile {
594                hash,
595                path,
596                contents,
597            } => {
598                if let Some(digest) = hash {
599                    write!(f, "ASSERT_FILE --hash {} {}", digest, quote_arg(path))
600                } else {
601                    write!(f, "ASSERT_FILE {}", quote_arg(path))?;
602                    if let Some(body) = contents {
603                        write!(f, " {}", quote_msg(body))?;
604                    }
605                    Ok(())
606                }
607            }
608            StepKind::AssertDir(arg) => write!(f, "ASSERT_DIR {}", quote_arg(arg)),
609            StepKind::AssertAbsent(arg) => write!(f, "ASSERT_ABSENT {}", quote_arg(arg)),
610            StepKind::AssertStdout(msg) => write!(f, "ASSERT_STDOUT {}", quote_msg(msg)),
611            StepKind::WithIo { bindings, cmd } => {
612                let parts: Vec<String> = bindings.iter().map(format_io_binding).collect();
613                write!(f, "WITH_IO [{}] {}", parts.join(", "), cmd)
614            }
615            StepKind::WithIoBlock { bindings } => {
616                let parts: Vec<String> = bindings.iter().map(format_io_binding).collect();
617                write!(f, "WITH_IO [{}] {{...}}", parts.join(", "))
618            }
619            StepKind::CopyGit {
620                rev,
621                from,
622                to,
623                include_dirty,
624            } => {
625                if *include_dirty {
626                    write!(
627                        f,
628                        "COPY_GIT --include-dirty {} {} {}",
629                        quote_arg(rev),
630                        quote_arg(from),
631                        quote_arg(to)
632                    )
633                } else {
634                    write!(
635                        f,
636                        "COPY_GIT {} {} {}",
637                        quote_arg(rev),
638                        quote_arg(from),
639                        quote_arg(to)
640                    )
641                }
642            }
643            StepKind::HashSha256 { path } => write!(f, "HASH_SHA256 {}", quote_arg(path)),
644            StepKind::Exit(code) => write!(f, "EXIT {}", code),
645        }
646    }
647}
648
649enum GuardDisplayContext {
650    Root,
651    InOrArg,
652    InNot,
653    InAll,
654}
655
656impl GuardExpr {
657    fn fmt_with_ctx(&self, f: &mut fmt::Formatter<'_>, ctx: GuardDisplayContext) -> fmt::Result {
658        match self {
659            GuardExpr::Predicate(guard) => write!(f, "{}", guard),
660            GuardExpr::All(children) => {
661                let wrap = matches!(
662                    ctx,
663                    GuardDisplayContext::InOrArg | GuardDisplayContext::InNot
664                ) && children.len() > 1;
665                if wrap {
666                    write!(f, "(")?;
667                }
668                for (i, child) in children.iter().enumerate() {
669                    if i > 0 {
670                        write!(f, ", ")?;
671                    }
672                    child.fmt_with_ctx(f, GuardDisplayContext::InAll)?;
673                }
674                if wrap {
675                    write!(f, ")")?;
676                }
677                Ok(())
678            }
679            GuardExpr::Or(children) => {
680                write!(f, "or(")?;
681                for (i, child) in children.iter().enumerate() {
682                    if i > 0 {
683                        write!(f, ", ")?;
684                    }
685                    child.fmt_with_ctx(f, GuardDisplayContext::InOrArg)?;
686                }
687                write!(f, ")")
688            }
689            GuardExpr::Not(child) => {
690                write!(f, "!")?;
691                let needs_paren =
692                    !matches!(child.as_ref(), GuardExpr::Predicate(_) | GuardExpr::Not(_));
693                if needs_paren {
694                    write!(f, "(")?;
695                }
696                child.fmt_with_ctx(f, GuardDisplayContext::InNot)?;
697                if needs_paren {
698                    write!(f, ")")?;
699                }
700                Ok(())
701            }
702        }
703    }
704}
705
706impl fmt::Display for GuardExpr {
707    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
708        self.fmt_with_ctx(f, GuardDisplayContext::Root)
709    }
710}
711
712impl fmt::Display for Step {
713    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
714        if let Some(expr) = &self.guard {
715            write!(f, "[{}] ", expr)?;
716        }
717        write!(f, "{}", self.kind)
718    }
719}