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 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}