oxdock_parser/
ast.rs

1use std::collections::HashMap;
2
3#[derive(Copy, Clone, Debug, Eq, PartialEq)]
4pub enum Command {
5    Workdir,
6    Workspace,
7    Env,
8    Echo,
9    Run,
10    RunBg,
11    Copy,
12    Capture,
13    CopyGit,
14    Symlink,
15    Mkdir,
16    Ls,
17    Cwd,
18    Cat,
19    Write,
20    Exit,
21}
22
23pub const COMMANDS: &[Command] = &[
24    Command::Workdir,
25    Command::Workspace,
26    Command::Env,
27    Command::Echo,
28    Command::Run,
29    Command::RunBg,
30    Command::Copy,
31    Command::Capture,
32    Command::CopyGit,
33    Command::Symlink,
34    Command::Mkdir,
35    Command::Ls,
36    Command::Cwd,
37    Command::Cat,
38    Command::Write,
39    Command::Exit,
40];
41
42impl Command {
43    pub const fn as_str(self) -> &'static str {
44        match self {
45            Command::Workdir => "WORKDIR",
46            Command::Workspace => "WORKSPACE",
47            Command::Env => "ENV",
48            Command::Echo => "ECHO",
49            Command::Run => "RUN",
50            Command::RunBg => "RUN_BG",
51            Command::Copy => "COPY",
52            Command::Capture => "CAPTURE",
53            Command::CopyGit => "COPY_GIT",
54            Command::Symlink => "SYMLINK",
55            Command::Mkdir => "MKDIR",
56            Command::Ls => "LS",
57            Command::Cwd => "CWD",
58            Command::Cat => "CAT",
59            Command::Write => "WRITE",
60            Command::Exit => "EXIT",
61        }
62    }
63
64    pub const fn expects_inner_command(self) -> bool {
65        matches!(self, Command::Capture)
66    }
67
68    pub fn parse(s: &str) -> Option<Self> {
69        match s {
70            "WORKDIR" => Some(Command::Workdir),
71            "WORKSPACE" => Some(Command::Workspace),
72            "ENV" => Some(Command::Env),
73            "ECHO" => Some(Command::Echo),
74            "RUN" => Some(Command::Run),
75            "RUN_BG" => Some(Command::RunBg),
76            "COPY" => Some(Command::Copy),
77            "CAPTURE" => Some(Command::Capture),
78            "COPY_GIT" => Some(Command::CopyGit),
79            "SYMLINK" => Some(Command::Symlink),
80            "MKDIR" => Some(Command::Mkdir),
81            "LS" => Some(Command::Ls),
82            "CWD" => Some(Command::Cwd),
83            "CAT" => Some(Command::Cat),
84            "WRITE" => Some(Command::Write),
85            "EXIT" => Some(Command::Exit),
86            _ => None,
87        }
88    }
89}
90
91#[derive(Copy, Clone, Debug, Eq, PartialEq)]
92pub enum PlatformGuard {
93    Unix,
94    Windows,
95    Macos,
96    Linux,
97}
98
99#[derive(Debug, Clone, Eq, PartialEq)]
100pub enum Guard {
101    Platform {
102        target: PlatformGuard,
103        invert: bool,
104    },
105    EnvExists {
106        key: String,
107        invert: bool,
108    },
109    EnvEquals {
110        key: String,
111        value: String,
112        invert: bool,
113    },
114}
115
116#[derive(Debug, Clone, Eq, PartialEq)]
117pub enum StepKind {
118    Workdir(String),
119    Workspace(WorkspaceTarget),
120    Env {
121        key: String,
122        value: String,
123    },
124    Run(String),
125    Echo(String),
126    RunBg(String),
127    Copy {
128        from: String,
129        to: String,
130    },
131    Symlink {
132        from: String,
133        to: String,
134    },
135    Mkdir(String),
136    Ls(Option<String>),
137    Cwd,
138    Cat(String),
139    Write {
140        path: String,
141        contents: String,
142    },
143    Capture {
144        path: String,
145        cmd: String,
146    },
147    CopyGit {
148        rev: String,
149        from: String,
150        to: String,
151    },
152    Exit(i32),
153}
154
155#[derive(Debug, Clone, Eq, PartialEq)]
156pub struct Step {
157    pub guards: Vec<Vec<Guard>>,
158    pub kind: StepKind,
159    pub scope_enter: usize,
160    pub scope_exit: usize,
161}
162
163#[derive(Debug, Clone, Eq, PartialEq)]
164pub enum WorkspaceTarget {
165    Snapshot,
166    Local,
167}
168
169fn platform_matches(target: PlatformGuard) -> bool {
170    #[allow(clippy::disallowed_macros)]
171    match target {
172        PlatformGuard::Unix => cfg!(unix),
173        PlatformGuard::Windows => cfg!(windows),
174        PlatformGuard::Macos => cfg!(target_os = "macos"),
175        PlatformGuard::Linux => cfg!(target_os = "linux"),
176    }
177}
178
179pub fn guard_allows(guard: &Guard, script_envs: &HashMap<String, String>) -> bool {
180    match guard {
181        Guard::Platform { target, invert } => {
182            let res = platform_matches(*target);
183            if *invert { !res } else { res }
184        }
185        Guard::EnvExists { key, invert } => {
186            let res = script_envs
187                .get(key)
188                .cloned()
189                .or_else(|| std::env::var(key).ok())
190                .map(|v| !v.is_empty())
191                .unwrap_or(false);
192            if *invert { !res } else { res }
193        }
194        Guard::EnvEquals { key, value, invert } => {
195            let res = script_envs
196                .get(key)
197                .cloned()
198                .or_else(|| std::env::var(key).ok())
199                .map(|v| v == *value)
200                .unwrap_or(false);
201            if *invert { !res } else { res }
202        }
203    }
204}
205
206pub fn guard_group_allows(group: &[Guard], script_envs: &HashMap<String, String>) -> bool {
207    group.iter().all(|g| guard_allows(g, script_envs))
208}
209
210pub fn guards_allow_any(groups: &[Vec<Guard>], script_envs: &HashMap<String, String>) -> bool {
211    if groups.is_empty() {
212        return true;
213    }
214    groups.iter().any(|g| guard_group_allows(g, script_envs))
215}
216
217use std::fmt;
218
219impl fmt::Display for PlatformGuard {
220    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
221        match self {
222            PlatformGuard::Unix => write!(f, "unix"),
223            PlatformGuard::Windows => write!(f, "windows"),
224            PlatformGuard::Macos => write!(f, "macos"),
225            PlatformGuard::Linux => write!(f, "linux"),
226        }
227    }
228}
229
230impl fmt::Display for Guard {
231    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
232        match self {
233            Guard::Platform { target, invert } => {
234                if *invert {
235                    write!(f, "!")?
236                }
237                write!(f, "platform:{}", target)
238            }
239            Guard::EnvExists { key, invert } => {
240                if *invert {
241                    write!(f, "!")?
242                }
243                write!(f, "env:{}", key)
244            }
245            Guard::EnvEquals { key, value, invert } => {
246                if *invert {
247                    write!(f, "!")?
248                }
249                write!(f, "env:{}={}", key, value)
250            }
251        }
252    }
253}
254
255impl fmt::Display for WorkspaceTarget {
256    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
257        match self {
258            WorkspaceTarget::Snapshot => write!(f, "SNAPSHOT"),
259            WorkspaceTarget::Local => write!(f, "LOCAL"),
260        }
261    }
262}
263
264fn quote_arg(s: &str) -> String {
265    // Strict quoting to avoid parser ambiguity, especially with CAPTURE command
266    // where unquoted args followed by run_args can be consumed greedily.
267    // Also quote if it starts with a digit to avoid invalid Rust tokens (e.g. 0o8) in macros.
268    let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
269        && !s.starts_with(|c: char| c.is_ascii_digit());
270    if is_safe && !s.is_empty() {
271        s.to_string()
272    } else {
273        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
274    }
275}
276
277fn quote_msg(s: &str) -> String {
278    // Strict quoting to ensure round-trip stability through TokenStream (macro input).
279    // The macro input reconstructor removes spaces around "sticky" characters (/-.:=)
280    // and collapses multiple spaces, so we must quote strings containing them.
281    // We also quote strings with spaces to be safe, as TokenStream does not preserve whitespace.
282    // Also quote if it starts with a digit to avoid invalid Rust tokens.
283    let is_safe = s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
284        && !s.starts_with(|c: char| c.is_ascii_digit());
285
286    if is_safe && !s.is_empty() {
287        s.to_string()
288    } else {
289        format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
290    }
291}
292
293fn quote_run(s: &str) -> String {
294    // For RUN commands, we want to preserve the raw string as much as possible.
295    // However, to ensure round-trip stability through TokenStream (macro input),
296    // we must ensure that the generated string is a valid sequence of Rust tokens.
297    // Invalid tokens (like 0o8) must be quoted.
298    // Also, sticky characters (like -) can merge with previous tokens in macro input,
299    // so we quote words starting with them to ensure separation.
300
301    let force_full_quote = s.is_empty()
302        || s.chars().any(|c| c == ';' || c == '\n' || c == '\r')
303        || s.contains("//")
304        || s.contains("/*");
305
306    if force_full_quote {
307        return format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""));
308    }
309
310    s.split(' ')
311        .map(|word| {
312            let needs_quote = word.starts_with(|c: char| c.is_ascii_digit())
313                || word.starts_with(['/', '.', '-', ':', '=']);
314            if needs_quote {
315                format!("\"{}\"", word.replace('\\', "\\\\").replace('"', "\\\""))
316            } else {
317                word.to_string()
318            }
319        })
320        .collect::<Vec<_>>()
321        .join(" ")
322}
323
324impl fmt::Display for StepKind {
325    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
326        match self {
327            StepKind::Workdir(arg) => write!(f, "WORKDIR {}", quote_arg(arg)),
328            StepKind::Workspace(target) => write!(f, "WORKSPACE {}", target),
329            StepKind::Env { key, value } => write!(f, "ENV {}={}", key, quote_arg(value)),
330            StepKind::Run(cmd) => write!(f, "RUN {}", quote_run(cmd)),
331            StepKind::Echo(msg) => write!(f, "ECHO {}", quote_msg(msg)),
332            StepKind::RunBg(cmd) => write!(f, "RUN_BG {}", quote_run(cmd)),
333            StepKind::Copy { from, to } => write!(f, "COPY {} {}", quote_arg(from), quote_arg(to)),
334            StepKind::Symlink { from, to } => {
335                write!(f, "SYMLINK {} {}", quote_arg(from), quote_arg(to))
336            }
337            StepKind::Mkdir(arg) => write!(f, "MKDIR {}", quote_arg(arg)),
338            StepKind::Ls(arg) => {
339                write!(f, "LS")?;
340                if let Some(a) = arg {
341                    write!(f, " {}", quote_arg(a))?;
342                }
343                Ok(())
344            }
345            StepKind::Cwd => write!(f, "CWD"),
346            StepKind::Cat(arg) => write!(f, "CAT {}", quote_arg(arg)),
347            StepKind::Write { path, contents } => {
348                write!(f, "WRITE {} {}", quote_arg(path), quote_msg(contents))
349            }
350            StepKind::Capture { path, cmd } => {
351                write!(f, "CAPTURE {} {}", quote_arg(path), quote_run(cmd))
352            }
353            StepKind::CopyGit { rev, from, to } => write!(
354                f,
355                "COPY_GIT {} {} {}",
356                quote_arg(rev),
357                quote_arg(from),
358                quote_arg(to)
359            ),
360            StepKind::Exit(code) => write!(f, "EXIT {}", code),
361        }
362    }
363}
364
365impl fmt::Display for Step {
366    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
367        for group in &self.guards {
368            write!(f, "[")?;
369            for (i, guard) in group.iter().enumerate() {
370                if i > 0 {
371                    write!(f, ", ")?
372                }
373                write!(f, "{}", guard)?;
374            }
375            write!(f, "] ")?;
376        }
377        write!(f, "{}", self.kind)
378    }
379}