Skip to main content

oxdock_parser/
lib.rs

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