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.
57        let cfg = match block.feature_tag() {
58            Some(f) => format!("#[cfg(feature = \"{f}\")]\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, with a
83/// no-op fallback when it's off — an example file (unlike a `#[test]`) can't
84/// simply be omitted, so this is what keeps `cargo build --examples` (and
85/// clippy/test over `--all-targets`) green under any feature combination.
86/// `docs capture` must be run with that same feature enabled to record real
87/// output; otherwise it captures the empty fallback.
88pub fn capture_examples(rel_path: &str, doc: &Doc) -> Vec<(String, String)> {
89    let stem = sanitized_stem(rel_path);
90    doc.blocks
91        .iter()
92        .filter(|b| b.is_capture())
93        .enumerate()
94        .map(|(n, block)| {
95            let name = format!("soothfast_capture_{stem}_{}", n + 1);
96            let has_main = block.code.contains("fn main");
97            let body = match (block.feature_tag(), has_main) {
98                // A feature-gated block that already defines `fn main` may
99                // also have preceding items (`use` ...): a bare `#[cfg(...)]`
100                // above the verbatim block would only gate the first item,
101                // leaving `fn main` unconditional and colliding with the
102                // fallback below. A `mod` scopes the whole block as one
103                // item; `pub use` re-exports `main` (whatever its signature/
104                // attributes, e.g. `#[tokio::main]`) as the real entry point
105                // — it must be `pub` first, or the re-export can't see it.
106                (Some(f), true) => {
107                    let pub_main = if block.code.contains("async fn main") {
108                        block.code.replacen("async fn main", "pub async fn main", 1)
109                    } else {
110                        block.code.replacen("fn main", "pub fn main", 1)
111                    };
112                    format!(
113                        "#[cfg(feature = \"{f}\")]\nmod soothfast_capture_body {{\n{}\n}}\n#[cfg(feature = \"{f}\")]\npub use soothfast_capture_body::main;\n\n#[cfg(not(feature = \"{f}\"))]\nfn main() {{}}\n",
114                        indent(&pub_main, "    ")
115                    )
116                }
117                (Some(f), false) => format!(
118                    "#[cfg(feature = \"{f}\")]\nfn main() {{\n{}\n}}\n\n#[cfg(not(feature = \"{f}\"))]\nfn main() {{}}\n",
119                    indent(&block.code, "    ")
120                ),
121                (None, true) => block.code.clone(),
122                (None, false) => format!("fn main() {{\n{}\n}}\n", indent(&block.code, "    ")),
123            };
124            let content =
125                format!("{GENERATED_HEADER}\n// source: {rel_path} line {}\n{body}", block.line);
126            (name, content)
127        })
128        .collect()
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134    use crate::markdown;
135
136    #[test]
137    fn generates_tests_and_examples() {
138        let md = "```rust\nlet x = 1;\n```\n```rust no_run\nloop {}\n```\n```rust capture-output\nprintln!(\"h\");\n```\n";
139        let doc = markdown::scan(md).unwrap();
140        let tests = test_file("docs/a.md", &doc).unwrap();
141        assert!(tests.contains("#[test]"));
142        assert!(tests.contains("compile-only"));
143        assert!(!tests.contains("println!")); // capture block excluded
144        let examples = capture_examples("docs/a.md", &doc);
145        assert_eq!(examples.len(), 1);
146        assert_eq!(examples[0].0, "soothfast_capture_docs_a_1");
147        assert!(examples[0].1.contains("fn main()"));
148    }
149
150    #[test]
151    fn all_ignored_means_no_test_file() {
152        let doc = markdown::scan("```rust ignore\nfictional();\n```\n").unwrap();
153        assert!(test_file("README.md", &doc).is_none());
154    }
155
156    #[test]
157    fn feature_tag_gates_generated_test() {
158        let md =
159            "```rust feature=risk\nlet x = 1;\n```\n```rust no_run feature=risk\nloop {}\n```\n";
160        let doc = markdown::scan(md).unwrap();
161        let tests = test_file("docs/r.md", &doc).unwrap();
162        assert_eq!(tests.matches("#[cfg(feature = \"risk\")]").count(), 2);
163    }
164
165    #[test]
166    fn feature_tag_gates_capture_example_with_a_noop_fallback() {
167        let md = "```rust capture-output feature=crypto\nprintln!(\"hi\");\n```\n";
168        let doc = markdown::scan(md).unwrap();
169        let examples = capture_examples("docs/c.md", &doc);
170        assert_eq!(examples.len(), 1);
171        let content = &examples[0].1;
172        // The real body only compiles in — and only prints — under the
173        // feature; an example file always needs *some* fn main, so the
174        // fallback keeps `cargo build --examples` green without it.
175        assert!(content.contains("#[cfg(feature = \"crypto\")]"));
176        assert!(content.contains("#[cfg(not(feature = \"crypto\"))]\nfn main() {}"));
177        assert!(content.contains("println!(\"hi\");"));
178    }
179
180    #[test]
181    fn feature_tag_with_own_fn_main_does_not_duplicate_main() {
182        // A block with its own `use` + `fn main` (e.g. `#[tokio::main]`)
183        // must not leave `fn main` unconditional alongside the fallback —
184        // that would be two `main`s when the feature is off.
185        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";
186        let doc = markdown::scan(md).unwrap();
187        let examples = capture_examples("docs/m.md", &doc);
188        assert_eq!(examples.len(), 1);
189        let content = &examples[0].1;
190        assert_eq!(content.matches("fn main").count(), 2); // real (renamed) + no-op fallback
191        assert!(content.contains("mod soothfast_capture_body"));
192        assert!(content.contains("pub use soothfast_capture_body::main;"));
193        assert!(content.contains("pub async fn main()")); // re-exported item must be pub
194        assert!(content.contains("#[cfg(not(feature = \"test-utils\"))]\nfn main() {}"));
195        assert!(content.contains("use std::fmt;"));
196    }
197}