Skip to main content

soothfast_docs/
gentests.rs

1//! Generated artifacts from markdown code blocks: a test file per markdown
2//! source (plain rust blocks) and a runnable example per capture block.
3//! CI compares regenerated content against disk, so stale artifacts fail.
4
5use crate::markdown::{CodeBlock, Doc};
6
7/// First line of every generated file; `docs check` uses it to tell
8/// generated artifacts from hand-written ones.
9pub const GENERATED_HEADER: &str = "// @generated by `cargo soothfast docs gen-tests`";
10
11/// `docs/measuring.md` → `soothfast_doc_docs_measuring`.
12pub 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
40/// Test-file content for one markdown doc, or None when it has no testable
41/// blocks (all ignored / non-rust / capture).
42pub 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        // Blocks that define `fn main` get it as a nested fn, then called.
51        let call_main = if block.code.contains("fn main") {
52            "\n    main();"
53        } else {
54            ""
55        };
56        // A `feature=NAME` tag gates the generated test behind that feature (`feature=A,B` behind all of them).
57        let cfg = match block.feature_cfg() {
58            Some(pred) => format!("#[cfg({pred})]\n"),
59            None => String::new(),
60        };
61        // rustfmt::skip (per-item — the inner-attribute form is unstable):
62        // blocks are verbatim from the markdown; formatting the generated fn
63        // would make it diverge from its source and fail `docs check`.
64        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
79/// Example-file contents for the capture blocks of one markdown doc, in
80/// capture order: (example_name, content).
81///
82/// A `feature=NAME` tag gates the real `fn main` behind that feature
83/// (`feature=A,B` behind all of them)
84pub 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                // A feature-gated block that already defines `fn main` may
95                // also have preceding items (`use` ...): a bare `#[cfg(...)]`
96                // above the verbatim block would only gate the first item,
97                // leaving `fn main` unconditional and colliding with the
98                // fallback below. A `mod` scopes the whole block as one
99                // item; `pub use` re-exports `main` (whatever its signature/
100                // attributes, e.g. `#[tokio::main]`) as the real entry point
101                // — it must be `pub` first, or the re-export can't see it.
102                (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!")); // capture block excluded
140        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        // A block whose body needs two features to compile — gating on only
164        // the first leaves it enabled, and failing to build, when the second
165        // is off.
166        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        // The real body only compiles in — and only prints — under the
193        // feature; an example file always needs *some* fn main, so the
194        // fallback keeps `cargo build --examples` green without it.
195        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        // A block with its own `use` + `fn main` (e.g. `#[tokio::main]`)
203        // must not leave `fn main` unconditional alongside the fallback —
204        // that would be two `main`s when the feature is off.
205        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); // real (renamed) + no-op fallback
211        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()")); // re-exported item must be pub
214        assert!(content.contains("#[cfg(not(feature = \"test-utils\"))]\nfn main() {}"));
215        assert!(content.contains("use std::fmt;"));
216    }
217}