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}