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    #[test]
24    fn commands_are_case_sensitive() {
25        for bad in ["run echo hi", "Run echo hi", "rUn echo hi", "write foo bar"] {
26            parse_script(bad).expect_err("mixed/lowercase commands must fail");
27        }
28    }
29
30    #[test]
31    fn string_dsl_supports_rust_style_comments() {
32        let script = indoc! {r#"
33            // leading comment line
34            WORKDIR /tmp // inline comment
35            RUN echo "keep // literal"
36            /* block comment
37               WORKDIR ignored
38               /* nested inner */
39               RUN ignored as well
40            */
41            RUN echo final
42            RUN echo 'literal /* stay */ value'
43        "#};
44        let steps = parse_script(script).expect("parse ok");
45        assert_eq!(steps.len(), 4, "expected 4 executable steps");
46        match &steps[0].kind {
47            StepKind::Workdir(path) => assert_eq!(path, "/tmp"),
48            other => panic!("expected WORKDIR, saw {:?}", other),
49        }
50        match &steps[1].kind {
51            StepKind::Run(cmd) => assert_eq!(cmd, "echo \"keep // literal\""),
52            other => panic!("expected RUN, saw {:?}", other),
53        }
54        match &steps[2].kind {
55            StepKind::Run(cmd) => assert_eq!(cmd, "echo final"),
56            other => panic!("expected RUN, saw {:?}", other),
57        }
58        match &steps[3].kind {
59            StepKind::Run(cmd) => assert_eq!(cmd, "echo 'literal /* stay */ value'"),
60            other => panic!("expected RUN, saw {:?}", other),
61        }
62    }
63
64    #[test]
65    fn string_dsl_errors_on_unclosed_block_comment() {
66        let script = indoc! {r#"
67            RUN echo hi
68            /* unclosed
69        "#};
70        parse_script(script).expect_err("should fail");
71    }
72
73    #[test]
74    fn semicolon_attached_to_command_splits_instructions() {
75        let script = "RUN echo hi; RUN echo bye";
76        let steps = parse_script(script).expect("parse ok");
77        assert_eq!(steps.len(), 2);
78        match &steps[0].kind {
79            StepKind::Run(cmd) => assert_eq!(cmd, "echo hi"),
80            other => panic!("expected RUN, saw {:?}", other),
81        }
82        match &steps[1].kind {
83            StepKind::Run(cmd) => assert_eq!(cmd, "echo bye"),
84            other => panic!("expected RUN, saw {:?}", other),
85        }
86    }
87
88    #[test]
89    fn guard_lines_chain_before_block() {
90        let script = indoc! {r#"
91            [env:A]
92            [env:B]
93            {
94                WRITE ok.txt hi
95            }
96        "#};
97        let steps = parse_script(script).expect("parse ok");
98        assert_eq!(steps.len(), 1);
99        assert_eq!(steps[0].guards.len(), 1);
100        assert_eq!(steps[0].guards[0].len(), 2);
101    }
102
103    #[test]
104    fn guard_block_must_contain_command() {
105        let script = indoc! {r#"
106            [env:A] {
107            }
108        "#};
109        parse_script(script).expect_err("empty block should fail");
110    }
111
112    #[test]
113    fn brace_blocks_require_guard() {
114        let script = indoc! {r#"
115            {
116                WRITE nope.txt hi
117            }
118        "#};
119        parse_script(script).expect_err("unguarded block should fail");
120    }
121
122    #[test]
123    fn multi_line_guard_blocks_apply_to_next_command() {
124        let script = indoc! {r#"
125            [
126                env:A,
127                env:B
128            ]
129            RUN echo guarded
130        "#};
131        let steps = parse_script(script).expect("parse ok");
132        assert_eq!(steps.len(), 1);
133        assert_eq!(steps[0].guards.len(), 1);
134        assert_eq!(steps[0].guards[0].len(), 2);
135    }
136
137    #[test]
138    fn guarded_brace_blocks_apply_to_all_inner_steps() {
139        let script = indoc! {r#"
140            [env:A] {
141                WRITE one.txt 1
142                WRITE two.txt 2
143            }
144        "#};
145        let steps = parse_script(script).expect("parse ok");
146        assert_eq!(steps.len(), 2);
147        assert!(steps.iter().all(|s| !s.guards.is_empty()));
148    }
149
150    #[test]
151    fn nested_guard_blocks_stack() {
152        let script = indoc! {r#"
153            [env:A] {
154                WRITE outer.txt no
155                [env:B] {
156                    WRITE nested.txt yes
157                }
158            }
159        "#};
160        let steps = parse_script(script).expect("parse ok");
161        assert_eq!(steps.len(), 2);
162        assert_eq!(steps[0].guards[0].len(), 1);
163        assert_eq!(steps[1].guards[0].len(), 2);
164    }
165
166    #[test]
167    fn nested_guard_block_scopes_stack_counts() {
168        let script = indoc! {r#"
169            [env:A] {
170                WRITE outer.txt ok
171                [env:B] {
172                    WRITE deep.txt ok
173                }
174                WRITE outer_again.txt ok
175            }
176        "#};
177        let steps = parse_script(script).expect("parse ok");
178        assert_eq!(steps.len(), 3);
179        assert_eq!(steps[0].scope_enter, 1);
180        assert_eq!(steps[0].scope_exit, 0);
181        assert_eq!(steps[1].scope_enter, 1);
182        assert_eq!(steps[1].scope_exit, 1);
183        assert_eq!(steps[2].scope_enter, 0);
184        assert_eq!(steps[2].scope_exit, 1);
185    }
186
187    #[test]
188    fn guards_allow_any_act_as_or_of_ands() {
189        let script = indoc! {r#"
190            [env:A]
191            [env:B | env:C]
192            RUN echo complex
193        "#};
194        let steps = parse_script(script).expect("parse ok");
195        assert_eq!(steps.len(), 1);
196        // Should be (A AND B) OR (A AND C)
197        // The parser produces [[A, B], [A, C]]
198        assert_eq!(steps[0].guards.len(), 2);
199        assert_eq!(steps[0].guards[0].len(), 2);
200        assert_eq!(steps[0].guards[1].len(), 2);
201    }
202
203    #[test]
204    fn guards_allow_any_falls_back_to_false_when_all_fail() {
205        let groups = vec![
206            vec![Guard::EnvExists {
207                key: "MISSING".into(),
208                invert: false,
209            }],
210            vec![Guard::EnvExists {
211                key: "ALSO_MISSING".into(),
212                invert: false,
213            }],
214        ];
215        assert!(!guards_allow_any(&groups, &HashMap::new()));
216    }
217
218    #[test]
219    fn env_equals_guard_respects_inversion() {
220        let g = Guard::EnvEquals {
221            key: "A".into(),
222            value: "1".into(),
223            invert: true,
224        };
225        let mut env = HashMap::new();
226        env.insert("A".into(), "1".into());
227        assert!(!guard_allows(&g, &env));
228        env.insert("A".into(), "2".into());
229        assert!(guard_allows(&g, &env));
230    }
231
232    #[test]
233    fn guard_block_emits_scope_markers() {
234        let script = indoc! {r#"
235            ENV RUN=1
236            [env:RUN] {
237                WRITE one.txt 1
238                WRITE two.txt 2
239            }
240            WRITE three.txt 3
241        "#};
242        let steps = parse_script(script).expect("parse ok");
243        assert_eq!(steps.len(), 4);
244        assert_eq!(steps[1].scope_enter, 1);
245        assert_eq!(steps[1].scope_exit, 0);
246        assert_eq!(steps[2].scope_enter, 0);
247        assert_eq!(steps[2].scope_exit, 1);
248        assert_eq!(steps[3].scope_enter, 0);
249        assert_eq!(steps[3].scope_exit, 0);
250    }
251
252    #[test]
253    #[cfg(feature = "proc-macro-api")]
254    fn string_and_braced_scripts_produce_identical_ast() {
255        let mut cases = Vec::new();
256
257        cases.push((
258            indoc! {r#"
259                WORKDIR /tmp
260                RUN echo hello
261            "#}
262            .trim()
263            .to_string(),
264            quote! {
265                WORKDIR /tmp
266                RUN echo hello
267            },
268        ));
269
270        cases.push((
271            indoc! {r#"
272                [!env:SKIP]
273                [platform:windows] RUN echo win
274                [env:MODE=beta, linux] RUN echo combo
275            "#}
276            .trim()
277            .to_string(),
278            quote! {
279                [!env:SKIP]
280                [platform:windows] RUN echo win
281                [env:MODE=beta, linux] RUN echo combo
282            },
283        ));
284
285        cases.push((
286            indoc! {r#"
287                [env:OUTER] {
288                    WORKDIR nested
289                    [env:INNER] RUN echo deep
290                }
291            "#}
292            .trim()
293            .to_string(),
294            quote! {
295                [env:OUTER] {
296                    WORKDIR nested
297                    [env:INNER] RUN echo deep
298                }
299            },
300        ));
301
302        cases.push((
303            indoc! {r#"
304                [env:TEST=1] CAPTURE out.txt RUN echo hi
305                [env:FOO] WRITE foo.txt "bar"
306                SYMLINK link target
307            "#}
308            .trim()
309            .to_string(),
310            quote! {
311                [env:TEST=1] CAPTURE out.txt RUN echo hi
312                [env:FOO] WRITE foo.txt "bar"
313                SYMLINK link target
314            },
315        ));
316
317        for (idx, (literal, tokens)) in cases.iter().enumerate() {
318            let text = literal.trim();
319            let string_steps = parse_script(text)
320                .unwrap_or_else(|e| panic!("string parse failed for case {idx}: {e}"));
321            let braced_steps = parse_braced_tokens(tokens)
322                .unwrap_or_else(|e| panic!("token parse failed for case {idx}: {e}"));
323            assert_eq!(
324                string_steps, braced_steps,
325                "AST mismatch for case {idx} literal:\n{text}"
326            );
327        }
328    }
329}