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