oxdock_core/
ast.rs

1use anyhow::{Result, bail};
2use std::collections::HashMap;
3
4#[derive(Copy, Clone, Debug, Eq, PartialEq)]
5pub enum Command {
6    Workdir,
7    Workspace,
8    Env,
9    Echo,
10    Run,
11    RunBg,
12    Copy,
13    Symlink,
14    Mkdir,
15    Ls,
16    Cat,
17    Write,
18    Exit,
19}
20
21pub const COMMANDS: &[Command] = &[
22    Command::Workdir,
23    Command::Workspace,
24    Command::Env,
25    Command::Echo,
26    Command::Run,
27    Command::RunBg,
28    Command::Copy,
29    Command::Symlink,
30    Command::Mkdir,
31    Command::Ls,
32    Command::Cat,
33    Command::Write,
34    Command::Exit,
35];
36
37fn platform_matches(target: PlatformGuard) -> bool {
38    match target {
39        PlatformGuard::Unix => cfg!(unix),
40        PlatformGuard::Windows => cfg!(windows),
41        PlatformGuard::Macos => cfg!(target_os = "macos"),
42        PlatformGuard::Linux => cfg!(target_os = "linux"),
43    }
44}
45
46fn guard_allows(guard: &Guard, script_envs: &HashMap<String, String>) -> bool {
47    match guard {
48        Guard::Platform { target, invert } => {
49            let res = platform_matches(*target);
50            if *invert { !res } else { res }
51        }
52        Guard::EnvExists { key, invert } => {
53            let res = script_envs
54                .get(key)
55                .cloned()
56                .or_else(|| std::env::var(key).ok())
57                .map(|v| !v.is_empty())
58                .unwrap_or(false);
59            if *invert { !res } else { res }
60        }
61        Guard::EnvEquals { key, value, invert } => {
62            let res = script_envs
63                .get(key)
64                .cloned()
65                .or_else(|| std::env::var(key).ok())
66                .map(|v| v == *value)
67                .unwrap_or(false);
68            if *invert { !res } else { res }
69        }
70    }
71}
72
73fn guard_group_allows(group: &[Guard], script_envs: &HashMap<String, String>) -> bool {
74    group.iter().all(|g| guard_allows(g, script_envs))
75}
76
77pub fn guards_allow_any(groups: &[Vec<Guard>], script_envs: &HashMap<String, String>) -> bool {
78    // No guards means allow.
79    if groups.is_empty() {
80        return true;
81    }
82    groups.iter().any(|g| guard_group_allows(g, script_envs))
83}
84
85fn parse_guard(raw: &str, line_no: usize) -> Result<Guard> {
86    let mut text = raw.trim();
87    let mut invert_prefix = false;
88    if let Some(rest) = text.strip_prefix('!') {
89        invert_prefix = true;
90        text = rest.trim();
91    }
92
93    if let Some(after) = text.strip_prefix("platform") {
94        let after = after.trim_start();
95        if let Some(rest) = after.strip_prefix(':').or_else(|| after.strip_prefix('=')) {
96            let tag = rest.trim().to_ascii_lowercase();
97            let target = match tag.as_str() {
98                "unix" => PlatformGuard::Unix,
99                "windows" => PlatformGuard::Windows,
100                "mac" | "macos" => PlatformGuard::Macos,
101                "linux" => PlatformGuard::Linux,
102                _ => bail!("line {}: unknown platform '{}'", line_no, rest),
103            };
104            return Ok(Guard::Platform {
105                target,
106                invert: invert_prefix,
107            });
108        }
109    }
110
111    if let Some(rest) = text.strip_prefix("env:") {
112        let rest = rest.trim();
113        if let Some(pos) = rest.find("!=") {
114            let key = rest[..pos].trim();
115            let value = rest[pos + 2..].trim();
116            if key.is_empty() || value.is_empty() {
117                bail!("line {}: guard env: requires key and value", line_no);
118            }
119            return Ok(Guard::EnvEquals {
120                key: key.to_string(),
121                value: value.to_string(),
122                invert: true,
123            });
124        }
125        if let Some(pos) = rest.find('=') {
126            let key = rest[..pos].trim();
127            let value = rest[pos + 1..].trim();
128            if key.is_empty() || value.is_empty() {
129                bail!("line {}: guard env: requires key and value", line_no);
130            }
131            return Ok(Guard::EnvEquals {
132                key: key.to_string(),
133                value: value.to_string(),
134                invert: invert_prefix,
135            });
136        }
137        if rest.is_empty() {
138            bail!("line {}: guard env: requires a variable name", line_no);
139        }
140        return Ok(Guard::EnvExists {
141            key: rest.to_string(),
142            invert: invert_prefix,
143        });
144    }
145
146    let tag = text.to_ascii_lowercase();
147    let target = match tag.as_str() {
148        "unix" => PlatformGuard::Unix,
149        "windows" => PlatformGuard::Windows,
150        "mac" | "macos" => PlatformGuard::Macos,
151        "linux" => PlatformGuard::Linux,
152        _ => bail!("line {}: unknown guard '{}'", line_no, raw),
153    };
154    Ok(Guard::Platform {
155        target,
156        invert: invert_prefix,
157    })
158}
159
160impl Command {
161    pub const fn as_str(self) -> &'static str {
162        match self {
163            Command::Workdir => "WORKDIR",
164            Command::Workspace => "WORKSPACE",
165            Command::Env => "ENV",
166            Command::Echo => "ECHO",
167            Command::Run => "RUN",
168            Command::RunBg => "RUN_BG",
169            Command::Copy => "COPY",
170            Command::Symlink => "SYMLINK",
171            Command::Mkdir => "MKDIR",
172            Command::Ls => "LS",
173            Command::Cat => "CAT",
174            Command::Write => "WRITE",
175            Command::Exit => "EXIT",
176        }
177    }
178
179    pub fn parse(op: &str) -> Option<Self> {
180        COMMANDS.iter().copied().find(|c| c.as_str() == op)
181    }
182}
183
184#[derive(Copy, Clone, Debug, Eq, PartialEq)]
185pub enum PlatformGuard {
186    Unix,
187    Windows,
188    Macos,
189    Linux,
190}
191
192#[derive(Debug, Clone, Eq, PartialEq)]
193pub enum Guard {
194    Platform {
195        target: PlatformGuard,
196        invert: bool,
197    },
198    EnvExists {
199        key: String,
200        invert: bool,
201    },
202    EnvEquals {
203        key: String,
204        value: String,
205        invert: bool,
206    },
207}
208
209#[derive(Debug, Clone, Eq, PartialEq)]
210pub enum StepKind {
211    Workdir(String),
212    Workspace(WorkspaceTarget),
213    Env { key: String, value: String },
214    Run(String),
215    Echo(String),
216    RunBg(String),
217    Copy { from: String, to: String },
218    Symlink { from: String, to: String },
219    Mkdir(String),
220    Ls(Option<String>),
221    Cat(String),
222    Write { path: String, contents: String },
223    Exit(i32),
224}
225
226#[derive(Debug, Clone, Eq, PartialEq)]
227pub struct Step {
228    pub guards: Vec<Vec<Guard>>,
229    pub kind: StepKind,
230}
231
232#[derive(Debug, Clone, Eq, PartialEq)]
233pub enum WorkspaceTarget {
234    Snapshot,
235    Local,
236}
237
238pub fn parse_script(input: &str) -> Result<Vec<Step>> {
239    use std::collections::VecDeque;
240    let mut steps = Vec::new();
241    let mut pending_guards: Vec<Vec<Guard>> = Vec::new();
242
243    // Use a queue so we can push back semicolon tail segments to be parsed
244    // as separate commands while preserving their original logical order.
245    let mut queue: VecDeque<(usize, String)> = VecDeque::new();
246    for (i, raw) in input.lines().enumerate() {
247        let raw = raw.trim();
248        if raw.is_empty() || raw.starts_with('#') {
249            continue;
250        }
251        queue.push_back((i + 1, raw.to_string()));
252    }
253
254    while let Some((line_no, rawline)) = queue.pop_front() {
255        let line = rawline.as_str();
256
257        // If a line begins with a guard block, parse it. If the guard block
258        // is followed by a command on the same line, apply guards (plus any
259        // previously-pending guards) to that command. If the guard block is
260        // on its own line (no command after `]`), stash the guards to apply
261        // to the next non-empty command line.
262        let (groups, remainder_opt) = if let Some(rest) = line.strip_prefix('[') {
263            let end = rest
264                .find(']')
265                .ok_or_else(|| anyhow::anyhow!("line {}: guard must close with ]", line_no))?;
266            let guards_raw = &rest[..end];
267            let after = rest[end + 1..].trim();
268            // Parse alternatives separated by `|`, each alternative is a comma-separated AND group.
269            let mut parsed_groups: Vec<Vec<Guard>> = Vec::new();
270            for alt in guards_raw.split('|') {
271                let mut group: Vec<Guard> = Vec::new();
272                for g in alt.split(',') {
273                    let parsed = parse_guard(g, line_no)?;
274                    group.push(parsed);
275                }
276                parsed_groups.push(group);
277            }
278            if after.is_empty() {
279                // Guard-only line: combine with pending_guards by ANDing groups (cartesian product).
280                if pending_guards.is_empty() {
281                    pending_guards = parsed_groups;
282                } else {
283                    let mut new_pending: Vec<Vec<Guard>> = Vec::new();
284                    for p in pending_guards.iter() {
285                        for q in parsed_groups.iter() {
286                            let mut merged = p.clone();
287                            merged.extend(q.clone());
288                            new_pending.push(merged);
289                        }
290                    }
291                    pending_guards = new_pending;
292                }
293                continue;
294            }
295            (parsed_groups, Some(after.to_string()))
296        } else {
297            (Vec::new(), Some(line.to_string()))
298        };
299
300        // Combine any pending guard groups (from previous guard-only lines)
301        // with any groups parsed on this line. Combination is an AND between
302        // groups (cartesian product), producing OR-of-AND groups for the
303        // resulting step. If there are no inline groups, use pending groups.
304        let mut all_groups: Vec<Vec<Guard>> = Vec::new();
305        if groups.is_empty() {
306            // No inline groups: attach pending groups (if any) to this step.
307            all_groups = pending_guards.clone();
308        } else if pending_guards.is_empty() {
309            all_groups = groups.clone();
310        } else {
311            for p in pending_guards.iter() {
312                for q in groups.iter() {
313                    let mut merged = p.clone();
314                    merged.extend(q.clone());
315                    all_groups.push(merged);
316                }
317            }
318        }
319        pending_guards.clear();
320        let mut remainder = remainder_opt.unwrap();
321
322        // Parse op and rest from the current remainder.  Create owned
323        // strings to avoid borrowing `remainder` while we may need to
324        // reassign it below.
325        let mut parts = remainder.splitn(2, ' ');
326        let op_owned = parts.next().unwrap().trim().to_string();
327        let rest_owned = parts
328            .next()
329            .map(|s| s.trim().to_string())
330            .unwrap_or_default();
331        let cmd = Command::parse(op_owned.as_str()).ok_or_else(|| {
332            anyhow::anyhow!("line {}: unknown instruction '{}'", line_no, op_owned)
333        })?;
334
335        // If command is not RUN or RUN_BG and the `rest` contains a ';',
336        // split it so we only consume up to the first semicolon for this
337        // command. Push the tail back onto the queue to be parsed next.
338        if cmd != Command::Run && cmd != Command::RunBg {
339            if let Some(idx_sc) = rest_owned.find(';') {
340                let first = rest_owned[..idx_sc].trim().to_string();
341                let tail = rest_owned[idx_sc + 1..].trim();
342                // replace remainder with the first segment for current handling
343                remainder = first;
344                // push the tail as a new logical line onto the front of the queue
345                if !tail.is_empty() {
346                    queue.push_front((line_no, tail.to_string()));
347                }
348            } else {
349                remainder = rest_owned.to_string();
350            }
351        } else {
352            // RUN/RUN_BG keep the entire rest verbatim
353            remainder = rest_owned.to_string();
354        }
355
356        let kind = match cmd {
357            Command::Workdir => {
358                if remainder.is_empty() {
359                    bail!("line {}: WORKDIR requires a path", line_no);
360                }
361                StepKind::Workdir(remainder.to_string())
362            }
363            Command::Workspace => {
364                let target = match remainder.as_str() {
365                    "SNAPSHOT" | "snapshot" => WorkspaceTarget::Snapshot,
366                    "LOCAL" | "local" => WorkspaceTarget::Local,
367                    _ => bail!("line {}: WORKSPACE requires LOCAL or SNAPSHOT", line_no),
368                };
369                StepKind::Workspace(target)
370            }
371            Command::Env => {
372                let mut parts = remainder.splitn(2, '=');
373                let key = parts
374                    .next()
375                    .map(str::trim)
376                    .filter(|s| !s.is_empty())
377                    .ok_or_else(|| anyhow::anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
378                let value = parts
379                    .next()
380                    .map(str::to_string)
381                    .ok_or_else(|| anyhow::anyhow!("line {}: ENV requires KEY=VALUE", line_no))?;
382                StepKind::Env {
383                    key: key.to_string(),
384                    value,
385                }
386            }
387            Command::Echo => {
388                if remainder.is_empty() {
389                    bail!("line {}: ECHO requires a message", line_no);
390                }
391                StepKind::Echo(remainder.to_string())
392            }
393            Command::Run => {
394                if remainder.is_empty() {
395                    bail!("line {}: RUN requires a command", line_no);
396                }
397                StepKind::Run(remainder.to_string())
398            }
399            Command::RunBg => {
400                if remainder.is_empty() {
401                    bail!("line {}: RUN_BG requires a command", line_no);
402                }
403                StepKind::RunBg(remainder.to_string())
404            }
405            Command::Copy => {
406                let mut p = remainder.split_whitespace();
407                let from = p.next().ok_or_else(|| {
408                    anyhow::anyhow!("line {}: COPY requires <from> <to>", line_no)
409                })?;
410                let to = p.next().ok_or_else(|| {
411                    anyhow::anyhow!("line {}: COPY requires <from> <to>", line_no)
412                })?;
413                StepKind::Copy {
414                    from: from.to_string(),
415                    to: to.to_string(),
416                }
417            }
418            Command::Symlink => {
419                let mut p = remainder.split_whitespace();
420                let from = p.next().ok_or_else(|| {
421                    anyhow::anyhow!("line {}: SYMLINK requires <link> <target>", line_no)
422                })?;
423                let to = p.next().ok_or_else(|| {
424                    anyhow::anyhow!("line {}: SYMLINK requires <link> <target>", line_no)
425                })?;
426                StepKind::Symlink {
427                    from: from.to_string(),
428                    to: to.to_string(),
429                }
430            }
431            Command::Mkdir => {
432                if remainder.is_empty() {
433                    bail!("line {}: MKDIR requires a path", line_no);
434                }
435                StepKind::Mkdir(remainder.to_string())
436            }
437            Command::Ls => {
438                let path = remainder
439                    .split_whitespace()
440                    .next()
441                    .filter(|s| !s.is_empty())
442                    .map(|s| s.to_string());
443                StepKind::Ls(path)
444            }
445            Command::Write => {
446                let mut p = remainder.splitn(2, ' ');
447                let path = p.next().filter(|s| !s.is_empty()).ok_or_else(|| {
448                    anyhow::anyhow!("line {}: WRITE requires <path> <contents>", line_no)
449                })?;
450                let contents = p.next().filter(|s| !s.is_empty()).ok_or_else(|| {
451                    anyhow::anyhow!("line {}: WRITE requires <path> <contents>", line_no)
452                })?;
453                StepKind::Write {
454                    path: path.to_string(),
455                    contents: contents.to_string(),
456                }
457            }
458            Command::Cat => {
459                let path = remainder
460                    .split_whitespace()
461                    .next()
462                    .filter(|s| !s.is_empty())
463                    .ok_or_else(|| anyhow::anyhow!("line {}: CAT requires <path>", line_no))?;
464                StepKind::Cat(path.to_string())
465            }
466            Command::Exit => {
467                if remainder.is_empty() {
468                    bail!("line {}: EXIT requires a code", line_no);
469                }
470                let code: i32 = remainder.parse().map_err(|_| {
471                    anyhow::anyhow!("line {}: EXIT code must be an integer", line_no)
472                })?;
473                StepKind::Exit(code)
474            }
475        };
476
477        steps.push(Step {
478            guards: all_groups,
479            kind,
480        });
481    }
482    Ok(steps)
483}