oxdock_parser/
lib.rs

1pub mod ast;
2mod lexer;
3#[cfg(feature = "proc-macro-api")]
4mod macro_input;
5pub mod parser;
6
7pub use ast::*;
8pub use lexer::LANGUAGE_SPEC;
9#[cfg(feature = "proc-macro-api")]
10pub use macro_input::{
11    DslMacroInput, ScriptSource, parse_braced_tokens, script_from_braced_tokens,
12};
13pub use parser::parse_script;
14
15#[cfg(test)]
16mod tests {
17    use super::*;
18    use indoc::indoc;
19    #[cfg(feature = "proc-macro-api")]
20    use quote::quote;
21    use std::collections::HashMap;
22
23    fn guard_text(step: &Step) -> Option<String> {
24        step.guard.as_ref().map(|g| g.to_string())
25    }
26
27    #[test]
28    fn commands_are_case_sensitive() {
29        for bad in ["run echo hi", "Run echo hi", "rUn echo hi", "write foo bar"] {
30            parse_script(bad).expect_err("mixed/lowercase commands must fail");
31        }
32    }
33
34    #[test]
35    fn string_dsl_supports_rust_style_comments() {
36        let script = indoc! {r#"
37            // leading comment line
38            WORKDIR /tmp // inline comment
39            RUN echo "keep // literal"
40            /* block comment
41               WORKDIR ignored
42               /* nested inner */
43               RUN ignored as well
44            */
45            RUN echo final
46            RUN echo 'literal /* stay */ value'
47        "#};
48        let steps = parse_script(script).expect("parse ok");
49        assert_eq!(steps.len(), 4, "expected 4 executable steps");
50        match &steps[0].kind {
51            StepKind::Workdir(path) => assert_eq!(path, "/tmp"),
52            other => panic!("expected WORKDIR, saw {:?}", other),
53        }
54        match &steps[1].kind {
55            StepKind::Run(cmd) => assert_eq!(cmd, "echo \"keep // literal\""),
56            other => panic!("expected RUN, saw {:?}", other),
57        }
58        match &steps[2].kind {
59            StepKind::Run(cmd) => assert_eq!(cmd, "echo final"),
60            other => panic!("expected RUN, saw {:?}", other),
61        }
62        match &steps[3].kind {
63            StepKind::Run(cmd) => assert_eq!(cmd, "echo 'literal /* stay */ value'"),
64            other => panic!("expected RUN, saw {:?}", other),
65        }
66    }
67
68    #[test]
69    fn string_dsl_errors_on_unclosed_block_comment() {
70        let script = indoc! {r#"
71            RUN echo hi
72            /* unclosed
73        "#};
74        parse_script(script).expect_err("should fail");
75    }
76
77    #[test]
78    fn semicolon_attached_to_command_splits_instructions() {
79        let script = "RUN echo hi; RUN echo bye";
80        let steps = parse_script(script).expect("parse ok");
81        assert_eq!(steps.len(), 2);
82        match &steps[0].kind {
83            StepKind::Run(cmd) => assert_eq!(cmd, "echo hi"),
84            other => panic!("expected RUN, saw {:?}", other),
85        }
86        match &steps[1].kind {
87            StepKind::Run(cmd) => assert_eq!(cmd, "echo bye"),
88            other => panic!("expected RUN, saw {:?}", other),
89        }
90    }
91
92    #[test]
93    fn guard_supports_colon_separator() {
94        let script = "[env:FOO] RUN echo hi";
95        let steps = parse_script(script).expect("parse ok");
96        assert_eq!(steps.len(), 1);
97        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:FOO"));
98    }
99
100    #[test]
101    fn guard_lines_chain_before_block() {
102        let script = indoc! {r#"
103            [env:A]
104            [env:B]
105            {
106                WRITE ok.txt hi
107            }
108        "#};
109        let steps = parse_script(script).expect("parse ok");
110        assert_eq!(steps.len(), 1);
111        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
112    }
113
114    #[test]
115    fn guard_block_must_contain_command() {
116        let script = indoc! {r#"
117            [env.A] {
118            }
119        "#};
120        parse_script(script).expect_err("empty block should fail");
121    }
122
123    #[test]
124    fn with_io_supports_named_pipes() {
125        let script = "WITH_IO [stdin, stdout=pipe:setup, stderr=pipe:errors] RUN echo hi";
126        let steps = parse_script(script).expect("parse ok");
127        assert_eq!(steps.len(), 1);
128        match &steps[0].kind {
129            StepKind::WithIo { bindings, cmd } => {
130                assert_eq!(bindings.len(), 3);
131                assert!(
132                    bindings
133                        .iter()
134                        .any(|b| matches!(b.stream, IoStream::Stdin) && b.pipe.is_none())
135                );
136                assert!(
137                    bindings.iter().any(|b| matches!(b.stream, IoStream::Stdout)
138                        && b.pipe.as_deref() == Some("setup"))
139                );
140                assert!(
141                    bindings.iter().any(|b| matches!(b.stream, IoStream::Stderr)
142                        && b.pipe.as_deref() == Some("errors"))
143                );
144                assert_eq!(cmd.as_ref(), &StepKind::Run("echo hi".into()));
145            }
146            other => panic!("expected WITH_IO, saw {:?}", other),
147        }
148    }
149
150    #[test]
151    fn brace_blocks_require_guard() {
152        let script = indoc! {r#"
153            {
154                WRITE nope.txt hi
155            }
156        "#};
157        parse_script(script).expect_err("unguarded block should fail");
158    }
159
160    #[test]
161    fn multi_line_guard_blocks_apply_to_next_command() {
162        let script = indoc! {r#"
163            [
164                env:A,
165                env:B
166            ]
167            RUN echo guarded
168        "#};
169        let steps = parse_script(script).expect("parse ok");
170        assert_eq!(steps.len(), 1);
171        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A, env:B"));
172    }
173
174    #[test]
175    fn guarded_brace_blocks_apply_to_all_inner_steps() {
176        let script = indoc! {r#"
177            [env:A] {
178                WRITE one.txt 1
179                WRITE two.txt 2
180            }
181        "#};
182        let steps = parse_script(script).expect("parse ok");
183        assert_eq!(steps.len(), 2);
184        assert!(steps.iter().all(|s| s.guard.is_some()));
185    }
186
187    #[test]
188    fn nested_guard_blocks_stack() {
189        let script = indoc! {r#"
190            [env:A] {
191                WRITE outer.txt no
192                [env:B] {
193                    WRITE nested.txt yes
194                }
195            }
196        "#};
197        let steps = parse_script(script).expect("parse ok");
198        assert_eq!(steps.len(), 2);
199        assert_eq!(guard_text(&steps[0]).as_deref(), Some("env:A"));
200        assert_eq!(guard_text(&steps[1]).as_deref(), Some("env:A, env:B"));
201    }
202
203    #[test]
204    fn nested_guard_block_scopes_stack_counts() {
205        let script = indoc! {r#"
206            [env:A] {
207                WRITE outer.txt ok
208                [env:B] {
209                    WRITE deep.txt ok
210                }
211                WRITE outer_again.txt ok
212            }
213        "#};
214        let steps = parse_script(script).expect("parse ok");
215        assert_eq!(steps.len(), 3);
216        assert_eq!(steps[0].scope_enter, 1);
217        assert_eq!(steps[0].scope_exit, 0);
218        assert_eq!(steps[1].scope_enter, 1);
219        assert_eq!(steps[1].scope_exit, 1);
220        assert_eq!(steps[2].scope_enter, 0);
221        assert_eq!(steps[2].scope_exit, 1);
222    }
223
224    #[test]
225    fn guard_or_and_and_compose_as_expected() {
226        let script = indoc! {r#"
227            [env:A]
228            [or(env:B, env:C)]
229            RUN echo complex
230        "#};
231        let steps = parse_script(script).expect("parse ok");
232        assert_eq!(steps.len(), 1);
233        let guard = steps[0].guard.as_ref().expect("missing guard");
234        assert_eq!(guard.to_string(), "env:A, or(env:B, env:C)");
235
236        let mut env = HashMap::new();
237        env.insert("A".into(), "1".into());
238        env.insert("B".into(), "1".into());
239        assert!(guard_expr_allows(guard, &env), "A && B should pass");
240
241        env.remove("B");
242        env.insert("C".into(), "1".into());
243        assert!(guard_expr_allows(guard, &env), "A && C should pass");
244
245        env.remove("C");
246        assert!(!guard_expr_allows(guard, &env), "A without B/C should fail");
247    }
248
249    #[test]
250    fn guard_or_requires_at_least_one_branch() {
251        let expr = GuardExpr::or(vec![
252            Guard::EnvExists {
253                key: "MISSING".into(),
254                invert: false,
255            }
256            .into(),
257            Guard::EnvExists {
258                key: "ALSO_MISSING".into(),
259                invert: false,
260            }
261            .into(),
262        ]);
263        assert!(!guard_expr_allows(&expr, &HashMap::new()));
264        let mut env = HashMap::new();
265        env.insert("MISSING".into(), "1".into());
266        assert!(guard_expr_allows(&expr, &env));
267    }
268
269    #[test]
270    fn guard_or_can_chain_with_additional_predicates() {
271        let script = "[or(env:A, linux), mac] RUN echo hi";
272        let steps = parse_script(script).expect("parse ok");
273        assert_eq!(steps.len(), 1);
274        let guard = steps[0].guard.as_ref().expect("missing guard");
275        assert_eq!(guard.to_string(), "or(env:A, linux), macos");
276        let GuardExpr::All(children) = guard else {
277            panic!("expected ALL guard");
278        };
279        assert!(matches!(children[0], GuardExpr::Or(_)));
280        match &children[1] {
281            GuardExpr::Predicate(Guard::Platform {
282                target: PlatformGuard::Macos,
283                invert: false,
284            }) => {}
285            other => panic!("unexpected trailing guard: {other:?}"),
286        }
287    }
288
289    #[test]
290    fn guard_or_guard_line_parses() {
291        use crate::lexer::{LanguageParser, Rule};
292        use pest::Parser;
293        LanguageParser::parse(Rule::guard_line, "[or(linux, env:FOO)]")
294            .expect("guard guard line should parse");
295    }
296
297    #[test]
298    fn env_equals_guard_respects_inversion() {
299        let g = Guard::EnvEquals {
300            key: "A".into(),
301            value: "1".into(),
302            invert: true,
303        };
304        let mut env = HashMap::new();
305        env.insert("A".into(), "1".into());
306        assert!(!guard_allows(&g, &env));
307        env.insert("A".into(), "2".into());
308        assert!(guard_allows(&g, &env));
309    }
310
311    #[test]
312    fn guard_block_emits_scope_markers() {
313        let script = indoc! {r#"
314            ENV RUN=1
315            [env:RUN] {
316                WRITE one.txt 1
317                WRITE two.txt 2
318            }
319            WRITE three.txt 3
320        "#};
321        let steps = parse_script(script).expect("parse ok");
322        assert_eq!(steps.len(), 4);
323        assert_eq!(steps[1].scope_enter, 1);
324        assert_eq!(steps[1].scope_exit, 0);
325        assert_eq!(steps[2].scope_enter, 0);
326        assert_eq!(steps[2].scope_exit, 1);
327        assert_eq!(steps[3].scope_enter, 0);
328        assert_eq!(steps[3].scope_exit, 0);
329    }
330
331    #[test]
332    fn run_args_single_quoted_unwraps() {
333        let script = "RUN \"echo hi\"";
334        let steps = parse_script(script).expect("parse ok");
335        assert_eq!(steps.len(), 1, "expected single step");
336        match &steps[0].kind {
337            StepKind::Run(cmd) => assert_eq!(cmd, "echo hi"),
338            other => panic!("expected RUN command, saw {:?}", other),
339        }
340    }
341
342    #[test]
343    fn run_args_preserve_quotes_for_problematic_tokens() {
344        let script = "RUN echo \"a; b\"";
345        let steps = parse_script(script).expect("parse ok");
346        match &steps[0].kind {
347            StepKind::Run(cmd) => {
348                assert_eq!(cmd, "echo \"a; b\"");
349            }
350            other => panic!("expected RUN command, saw {:?}", other),
351        }
352    }
353
354    #[test]
355    fn workdir_allows_templated_argument_with_spaces() {
356        let script = "WORKDIR {{ env:OXBOOK_RUNNER_DIR }}";
357        let steps = parse_script(script).expect("parse ok");
358        assert_eq!(steps.len(), 1);
359        match &steps[0].kind {
360            StepKind::Workdir(path) => {
361                assert_eq!(path, "{{ env:OXBOOK_RUNNER_DIR }}");
362            }
363            other => panic!("expected WORKDIR, saw {:?}", other),
364        }
365    }
366
367    #[test]
368    #[cfg(feature = "proc-macro-api")]
369    fn string_and_braced_scripts_produce_identical_ast() {
370        let mut cases = Vec::new();
371
372        cases.push((
373            indoc! {r#"
374                WORKDIR /tmp
375                RUN echo hello
376            "#}
377            .trim()
378            .to_string(),
379            quote! {
380                WORKDIR /tmp
381                RUN echo hello
382            },
383        ));
384
385        cases.push((
386            indoc! {r#"
387                [!env:SKIP]
388                [windows] RUN echo win
389                [env:MODE==beta, linux] RUN echo combo
390            "#}
391            .trim()
392            .to_string(),
393            quote! {
394                [!env:SKIP]
395                [windows] RUN echo win
396                [env:MODE==beta, linux] RUN echo combo
397            },
398        ));
399
400        cases.push((
401            indoc! {r#"
402                [env:OUTER] {
403                    WORKDIR nested
404                    [env:INNER] RUN echo deep
405                }
406            "#}
407            .trim()
408            .to_string(),
409            quote! {
410                [env:OUTER] {
411                    WORKDIR nested
412                    [env:INNER] RUN echo deep
413                }
414            },
415        ));
416
417        cases.push((
418            indoc! {r#"
419                [env:TEST==1]
420                WITH_IO [stdout=pipe:capture_case] RUN echo hi
421                WITH_IO [stdin=pipe:capture_case] WRITE out.txt
422            "#}
423            .trim()
424            .to_string(),
425            quote! {
426                [env:TEST==1]
427                WITH_IO [stdout=pipe:capture_case] RUN echo hi
428                WITH_IO [stdin=pipe:capture_case] WRITE out.txt
429            },
430        ));
431
432        for (idx, (literal, tokens)) in cases.iter().enumerate() {
433            let text = literal.trim();
434            let string_steps = parse_script(text)
435                .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
436            let braced_steps = parse_braced_tokens(tokens)
437                .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
438            assert_eq!(
439                string_steps, braced_steps,
440                "AST mismatch for case {idx} literal:\n{text}"
441            );
442        }
443    }
444}