soothfast_docs/
gentests.rs1use crate::markdown::{CodeBlock, Doc};
6
7pub const GENERATED_HEADER: &str = "// @generated by `cargo soothfast docs gen-tests`";
10
11pub fn sanitized_stem(rel_path: &str) -> String {
13 let stem = rel_path.trim_end_matches(".md");
14 let san: String = stem
15 .chars()
16 .map(|c| {
17 if c.is_ascii_alphanumeric() {
18 c.to_ascii_lowercase()
19 } else {
20 '_'
21 }
22 })
23 .collect();
24 san.trim_matches('_').to_string()
25}
26
27fn indent(code: &str, pad: &str) -> String {
28 code.lines()
29 .map(|l| {
30 if l.is_empty() {
31 String::new()
32 } else {
33 format!("{pad}{l}")
34 }
35 })
36 .collect::<Vec<_>>()
37 .join("\n")
38}
39
40pub fn test_file(rel_path: &str, doc: &Doc) -> Option<String> {
43 let testable: Vec<&CodeBlock> = doc.blocks.iter().filter(|b| b.is_testable_rust()).collect();
44 if testable.is_empty() {
45 return None;
46 }
47 let mut out = format!("{GENERATED_HEADER}\n// source: {rel_path}\n#![allow(unused)]\n");
48 for block in testable {
49 let body = indent(&block.code, " ");
50 let call_main = if block.code.contains("fn main") {
52 "\n main();"
53 } else {
54 ""
55 };
56 let cfg = match block.feature_cfg() {
58 Some(pred) => format!("#[cfg({pred})]\n"),
59 None => String::new(),
60 };
61 if block.has_tag("no_run") {
65 out.push_str(&format!(
66 "\n// line {}: compile-only (no_run)\n{cfg}#[rustfmt::skip]\n#[allow(dead_code)]\nfn doc_block_line_{}() {{\n{body}\n}}\n",
67 block.line, block.line
68 ));
69 } else {
70 out.push_str(&format!(
71 "\n// line {}\n{cfg}#[rustfmt::skip]\n#[test]\nfn doc_block_line_{}() {{\n{body}{call_main}\n}}\n",
72 block.line, block.line
73 ));
74 }
75 }
76 Some(out)
77}
78
79pub fn capture_examples(rel_path: &str, doc: &Doc) -> Vec<(String, String)> {
85 let stem = sanitized_stem(rel_path);
86 doc.blocks
87 .iter()
88 .filter(|b| b.is_capture())
89 .enumerate()
90 .map(|(n, block)| {
91 let name = format!("soothfast_capture_{stem}_{}", n + 1);
92 let has_main = block.code.contains("fn main");
93 let body = match (block.feature_cfg(), has_main) {
94 (Some(pred), true) => {
103 let pub_main = if block.code.contains("async fn main") {
104 block.code.replacen("async fn main", "pub async fn main", 1)
105 } else {
106 block.code.replacen("fn main", "pub fn main", 1)
107 };
108 format!(
109 "#[cfg({pred})]\nmod soothfast_capture_body {{\n{}\n}}\n#[cfg({pred})]\npub use soothfast_capture_body::main;\n\n#[cfg(not({pred}))]\nfn main() {{}}\n",
110 indent(&pub_main, " ")
111 )
112 }
113 (Some(pred), false) => format!(
114 "#[cfg({pred})]\nfn main() {{\n{}\n}}\n\n#[cfg(not({pred}))]\nfn main() {{}}\n",
115 indent(&block.code, " ")
116 ),
117 (None, true) => block.code.clone(),
118 (None, false) => format!("fn main() {{\n{}\n}}\n", indent(&block.code, " ")),
119 };
120 let content =
121 format!("{GENERATED_HEADER}\n// source: {rel_path} line {}\n{body}", block.line);
122 (name, content)
123 })
124 .collect()
125}
126
127#[cfg(test)]
128mod tests {
129 use super::*;
130 use crate::markdown;
131
132 #[test]
133 fn generates_tests_and_examples() {
134 let md = "```rust\nlet x = 1;\n```\n```rust no_run\nloop {}\n```\n```rust capture-output\nprintln!(\"h\");\n```\n";
135 let doc = markdown::scan(md).unwrap();
136 let tests = test_file("docs/a.md", &doc).unwrap();
137 assert!(tests.contains("#[test]"));
138 assert!(tests.contains("compile-only"));
139 assert!(!tests.contains("println!")); let examples = capture_examples("docs/a.md", &doc);
141 assert_eq!(examples.len(), 1);
142 assert_eq!(examples[0].0, "soothfast_capture_docs_a_1");
143 assert!(examples[0].1.contains("fn main()"));
144 }
145
146 #[test]
147 fn all_ignored_means_no_test_file() {
148 let doc = markdown::scan("```rust ignore\nfictional();\n```\n").unwrap();
149 assert!(test_file("README.md", &doc).is_none());
150 }
151
152 #[test]
153 fn feature_tag_gates_generated_test() {
154 let md =
155 "```rust feature=risk\nlet x = 1;\n```\n```rust no_run feature=risk\nloop {}\n```\n";
156 let doc = markdown::scan(md).unwrap();
157 let tests = test_file("docs/r.md", &doc).unwrap();
158 assert_eq!(tests.matches("#[cfg(feature = \"risk\")]").count(), 2);
159 }
160
161 #[test]
162 fn comma_separated_feature_tag_gates_on_all_of_them() {
163 let md = "```rust no_run feature=risk,polygon\nloop {}\n```\n";
167 let doc = markdown::scan(md).unwrap();
168 let tests = test_file("docs/r.md", &doc).unwrap();
169 assert!(tests.contains("#[cfg(all(feature = \"risk\", feature = \"polygon\"))]"));
170 }
171
172 #[test]
173 fn comma_separated_feature_tag_gates_capture_example_and_its_fallback() {
174 let md = "```rust capture-output feature=risk,polygon\nprintln!(\"hi\");\n```\n";
175 let doc = markdown::scan(md).unwrap();
176 let content = &capture_examples("docs/c.md", &doc)[0].1;
177 assert!(content.contains("#[cfg(all(feature = \"risk\", feature = \"polygon\"))]"));
178 assert!(
179 content.contains(
180 "#[cfg(not(all(feature = \"risk\", feature = \"polygon\")))]\nfn main() {}"
181 )
182 );
183 }
184
185 #[test]
186 fn feature_tag_gates_capture_example_with_a_noop_fallback() {
187 let md = "```rust capture-output feature=crypto\nprintln!(\"hi\");\n```\n";
188 let doc = markdown::scan(md).unwrap();
189 let examples = capture_examples("docs/c.md", &doc);
190 assert_eq!(examples.len(), 1);
191 let content = &examples[0].1;
192 assert!(content.contains("#[cfg(feature = \"crypto\")]"));
196 assert!(content.contains("#[cfg(not(feature = \"crypto\"))]\nfn main() {}"));
197 assert!(content.contains("println!(\"hi\");"));
198 }
199
200 #[test]
201 fn feature_tag_with_own_fn_main_does_not_duplicate_main() {
202 let md = "```rust capture-output feature=test-utils\nuse std::fmt;\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n Ok(())\n}\n```\n";
206 let doc = markdown::scan(md).unwrap();
207 let examples = capture_examples("docs/m.md", &doc);
208 assert_eq!(examples.len(), 1);
209 let content = &examples[0].1;
210 assert_eq!(content.matches("fn main").count(), 2); assert!(content.contains("mod soothfast_capture_body"));
212 assert!(content.contains("pub use soothfast_capture_body::main;"));
213 assert!(content.contains("pub async fn main()")); assert!(content.contains("#[cfg(not(feature = \"test-utils\"))]\nfn main() {}"));
215 assert!(content.contains("use std::fmt;"));
216 }
217}