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}