Skip to main content

soothfast_docs/
gentests.rs

1//! Generated artifacts from markdown code blocks: one integration-test target
2//! per package with a module per page (plain rust blocks), and one example
3//! per package with a module per capture block. CI compares regenerated
4//! content against disk, so stale artifacts fail.
5
6use crate::markdown::{CodeBlock, Doc};
7
8/// First line of every generated file; `docs check` uses it to tell
9/// generated artifacts from hand-written ones.
10pub const GENERATED_HEADER: &str = "// @generated by `cargo soothfast docs gen-tests`";
11
12/// `docs/measuring.md` → `soothfast_doc_docs_measuring`.
13pub fn sanitized_stem(rel_path: &str) -> String {
14    let stem = rel_path.trim_end_matches(".md");
15    let san: String = stem
16        .chars()
17        .map(|c| {
18            if c.is_ascii_alphanumeric() {
19                c.to_ascii_lowercase()
20            } else {
21                '_'
22            }
23        })
24        .collect();
25    san.trim_matches('_').to_string()
26}
27
28fn indent(code: &str, pad: &str) -> String {
29    code.lines()
30        .map(|l| {
31            if l.is_empty() {
32                String::new()
33            } else {
34                format!("{pad}{l}")
35            }
36        })
37        .collect::<Vec<_>>()
38        .join("\n")
39}
40
41/// One generated file, its path relative to the package directory.
42#[derive(Debug, Clone, PartialEq)]
43pub struct Generated {
44    /// Where the file goes, relative to the package directory.
45    pub path: String,
46    /// The file's full content, header included.
47    pub content: String,
48}
49
50/// Wraps the generated `mod` lines and dispatch of a target's `main.rs`.
51/// rustfmt neither formats a brace-delimited macro body nor follows a `mod`
52/// inside one, so `cargo fmt` cannot make a generated file differ from what
53/// `docs check` regenerates, whatever its version, edition or config.
54const VERBATIM: &str =
55    "macro_rules! verbatim {\n    ($($tokens:tt)*) => {\n        $($tokens)*\n    };\n}\n";
56
57/// The one integration-test target the doc tests of a package compile into.
58pub const DOC_TESTS_DIR: &str = "tests/soothfast_docs";
59
60/// Where generated files live, as (directory, file-name prefix, replaced by),
61/// relative to the package directory. A file there that starts with
62/// [`GENERATED_HEADER`] belongs to gen-tests: it is rewritten when expected
63/// and removed when not. An entry with an empty prefix is a target directory
64/// gen-tests owns outright; a prefixed entry is the per-page or per-block
65/// layout of earlier releases and names the target directory that replaced it.
66pub const OWNED: &[(&str, &str, Option<&str>)] = &[
67    ("tests", "soothfast_doc_", Some(DOC_TESTS_DIR)),
68    (DOC_TESTS_DIR, "", None),
69    ("examples", "soothfast_capture_", Some(CAPTURE_DIR)),
70    (CAPTURE_DIR, "", None),
71];
72
73/// The page a generated file was generated from, and the line of the block
74/// when it is one block's file, read from its `// source: PAGE[ line N]` line.
75pub fn recorded_source(content: &str) -> Option<(&str, Option<usize>)> {
76    let source = content.lines().nth(1)?.strip_prefix("// source: ")?;
77    match source.rsplit_once(" line ") {
78        Some((page, line)) => match line.parse::<usize>() {
79            Ok(line) => Some((page, Some(line))),
80            Err(_) => Some((source, None)),
81        },
82        None => Some((source, None)),
83    }
84}
85
86/// Every doc-test file a package needs: one module per page with testable
87/// blocks under [`DOC_TESTS_DIR`], and the `main.rs` that declares them.
88/// Empty when no page has a testable block. An error names two pages whose
89/// paths sanitize to the same module.
90pub fn doc_test_set<'a>(
91    docs: impl IntoIterator<Item = (&'a str, &'a Doc)>,
92) -> Result<Vec<Generated>, String> {
93    let docs: Vec<(&str, &Doc)> = docs.into_iter().collect();
94    one_global_allocator(&docs, CodeBlock::is_testable_rust)?;
95    let mut pages: Vec<(String, &str, String)> = Vec::new();
96    for (rel_path, doc) in docs {
97        if let Some(content) = test_file(rel_path, doc) {
98            pages.push((
99                format!("doc_{}", sanitized_stem(rel_path)),
100                rel_path,
101                content,
102            ));
103        }
104    }
105    pages.sort_by(|a, b| a.1.cmp(b.1));
106    for (i, (module, path, _)) in pages.iter().enumerate() {
107        if let Some((_, other, _)) = pages[..i].iter().find(|(m, _, _)| m == module) {
108            return Err(format!(
109                "{other} and {path} both generate doc-test module `{module}`; rename one"
110            ));
111        }
112    }
113    if pages.is_empty() {
114        return Ok(Vec::new());
115    }
116    let mods: String = pages
117        .iter()
118        .map(|(m, _, _)| format!("mod {m};\n"))
119        .collect();
120    let main =
121        format!("{GENERATED_HEADER}\n#![allow(unused)]\n\n{VERBATIM}\nverbatim! {{\n{mods}}}\n");
122    let mut set = vec![Generated {
123        path: format!("{DOC_TESTS_DIR}/main.rs"),
124        content: main,
125    }];
126    set.extend(pages.into_iter().map(|(module, _, content)| Generated {
127        path: format!("{DOC_TESTS_DIR}/{module}.rs"),
128        content,
129    }));
130    Ok(set)
131}
132
133/// The blocks of one target share a crate, so at most one of them may install
134/// a global allocator, directly or through `soothfast::bench_main!()`.
135fn one_global_allocator(
136    docs: &[(&str, &Doc)],
137    in_target: fn(&CodeBlock) -> bool,
138) -> Result<(), String> {
139    let mut found = docs.iter().flat_map(|(page, doc)| {
140        doc.blocks
141            .iter()
142            .filter(move |b| in_target(b))
143            .filter(|b| b.code.contains("#[global_allocator]") || b.code.contains("bench_main!"))
144            .map(move |b| format!("{page} line {}", b.line))
145    });
146    match (found.next(), found.next()) {
147        (Some(first), Some(second)) => Err(format!(
148            "{first} and {second} both install a global allocator, and the blocks of a package compile into one crate; mark one `ignore` or `no_run` in another package"
149        )),
150        _ => Ok(()),
151    }
152}
153
154/// For `rel_path` (relative to the package directory) in a place gen-tests
155/// writes, per [`OWNED`]: the target directory it belongs to, and whether it is
156/// a leftover of the layout that target replaced. The file's header decides
157/// ownership.
158pub fn owning_target(rel_path: &str) -> Option<(&'static str, bool)> {
159    let (dir, name) = rel_path.rsplit_once('/').unwrap_or(("", rel_path));
160    if !name.ends_with(".rs") {
161        return None;
162    }
163    OWNED
164        .iter()
165        .find(|(d, prefix, _)| *d == dir && name.starts_with(prefix))
166        .map(|(dir, _, replaced_by)| (replaced_by.unwrap_or(dir), replaced_by.is_some()))
167}
168
169/// Module content for one markdown page's doc tests, or None when it has no
170/// testable blocks (all ignored / non-rust / capture).
171pub fn test_file(rel_path: &str, doc: &Doc) -> Option<String> {
172    let testable: Vec<&CodeBlock> = doc.blocks.iter().filter(|b| b.is_testable_rust()).collect();
173    if testable.is_empty() {
174        return None;
175    }
176    let mut out = format!("{GENERATED_HEADER}\n// source: {rel_path}\n#![allow(unused)]\n");
177    for block in testable {
178        let body = indent(&block.code, "    ");
179        // Blocks that define `fn main` get it as a nested fn, then called.
180        let call_main = if block.code.lines().any(|l| top_level_main(l).is_some()) {
181            "\n    main();"
182        } else {
183            ""
184        };
185        // A `feature=NAME` tag gates the generated test behind that feature (`feature=A,B` behind all of them).
186        let cfg = match block.feature_cfg() {
187            Some(pred) => format!("#[cfg({pred})]\n"),
188            None => String::new(),
189        };
190        // rustfmt::skip (per-item — the inner-attribute form is unstable):
191        // blocks are verbatim from the markdown; formatting the generated fn
192        // would make it diverge from its source and fail `docs check`.
193        if block.has_tag("no_run") {
194            out.push_str(&format!(
195                "\n// line {}: compile-only (no_run)\n{cfg}#[rustfmt::skip]\n#[allow(dead_code)]\nfn doc_block_line_{}() {{\n{body}\n}}\n",
196                block.line, block.line
197            ));
198        } else {
199            out.push_str(&format!(
200                "\n// line {}\n{cfg}#[rustfmt::skip]\n#[test]\nfn doc_block_line_{}() {{\n{body}{call_main}\n}}\n",
201                block.line, block.line
202            ));
203        }
204    }
205    Some(out)
206}
207
208/// The one example target every capture block of a package compiles into.
209pub const CAPTURE_DIR: &str = "examples/soothfast_capture";
210
211/// Cargo's name for the [`CAPTURE_DIR`] example.
212pub const CAPTURE_EXAMPLE: &str = "soothfast_capture";
213
214/// Exit status of the capture example for a block id it does not contain.
215pub const CAPTURE_UNKNOWN: (i32, &str) = (2, "soothfast_capture: unknown block");
216
217/// Exit status of the capture example for a block whose `feature=` tag is
218/// not enabled in this build. The stderr line starts with the sentinel.
219pub const CAPTURE_DISABLED: (i32, &str) = (3, "soothfast_capture: disabled block");
220
221/// Block ids for one page's capture blocks, in capture order: the argument
222/// the capture example takes to run that block.
223pub fn capture_ids(rel_path: &str, doc: &Doc) -> Vec<String> {
224    let stem = sanitized_stem(rel_path);
225    let count = doc.blocks.iter().filter(|b| b.is_capture()).count();
226    (1..=count).map(|n| format!("{stem}_{n}")).collect()
227}
228
229/// Every capture file a package needs: one `block_<id>.rs` module per capture
230/// block under [`CAPTURE_DIR`], and the `main.rs` that runs the block its first
231/// argument names. Empty when no page has a capture block.
232///
233/// A block with a `feature=` tag (`feature=A,B` for all of them) compiles in
234/// only under its features; without them the example exits with
235/// [`CAPTURE_DISABLED`], so a capture run can keep the recorded output rather
236/// than splice in nothing. A block keeps its own top-level `fn main`, whatever
237/// it returns or however it is attributed (`#[tokio::main]`); a block without
238/// one becomes the body of one. An error names a page whose block uses a
239/// crate-level attribute, which a module cannot carry, or two pages whose
240/// paths sanitize to the same block ids.
241pub fn capture_set<'a>(
242    docs: impl IntoIterator<Item = (&'a str, &'a Doc)>,
243) -> Result<Vec<Generated>, String> {
244    let docs: Vec<(&str, &Doc)> = docs.into_iter().collect();
245    one_global_allocator(&docs, CodeBlock::is_capture)?;
246    let mut blocks: Vec<(String, &str, Option<String>, String)> = Vec::new();
247    let mut stems: Vec<(String, &str)> = Vec::new();
248    for (rel_path, doc) in docs {
249        let ids = capture_ids(rel_path, doc);
250        if ids.is_empty() {
251            continue;
252        }
253        let stem = sanitized_stem(rel_path);
254        if let Some((_, other)) = stems.iter().find(|(s, _)| *s == stem) {
255            return Err(format!(
256                "{other} and {rel_path} both generate capture blocks `{stem}_N`; rename one"
257            ));
258        }
259        stems.push((stem, rel_path));
260        let captures = doc.blocks.iter().filter(|b| b.is_capture());
261        for (id, block) in ids.into_iter().zip(captures) {
262            let body = capture_module(block)
263                .map_err(|e| format!("{rel_path} line {}: {e}", block.line))?;
264            let content = format!(
265                "{GENERATED_HEADER}\n// source: {rel_path} line {}\n{body}",
266                block.line
267            );
268            blocks.push((id, rel_path, block.feature_cfg(), content));
269        }
270    }
271    if blocks.is_empty() {
272        return Ok(Vec::new());
273    }
274    blocks.sort_by(|a, b| a.1.cmp(b.1));
275    let mut set = vec![Generated {
276        path: format!("{CAPTURE_DIR}/main.rs"),
277        content: capture_main(&blocks),
278    }];
279    set.extend(blocks.into_iter().map(|(id, _, _, content)| Generated {
280        path: format!("{CAPTURE_DIR}/block_{id}.rs"),
281        content,
282    }));
283    Ok(set)
284}
285
286fn capture_main(blocks: &[(String, &str, Option<String>, String)]) -> String {
287    let mut mods = String::new();
288    let mut arms = String::new();
289    for (id, _, cfg, _) in blocks {
290        let run = format!("\"{id}\" => run(block_{id}::main),\n");
291        match cfg {
292            Some(pred) => {
293                mods.push_str(&format!("#[cfg({pred})]\nmod block_{id};\n"));
294                arms.push_str(&format!("            #[cfg({pred})]\n            {run}"));
295                arms.push_str(&format!(
296                    "            #[cfg(not({pred}))]\n            \"{id}\" => exit(CAPTURE_DISABLED, &id),\n"
297                ));
298            }
299            None => {
300                mods.push_str(&format!("mod block_{id};\n"));
301                arms.push_str(&format!("            {run}"));
302            }
303        }
304    }
305    let (unknown, unknown_msg) = CAPTURE_UNKNOWN;
306    let (disabled, disabled_msg) = CAPTURE_DISABLED;
307    format!(
308        "{GENERATED_HEADER}\n#![allow(dead_code, unused_imports)]\n\n\
309         use std::process::{{ExitCode, Termination}};\n\n\
310         {VERBATIM}\n\
311         verbatim! {{\n{mods}}}\n\n\
312         const CAPTURE_UNKNOWN: (u8, &str) = ({unknown}, \"{unknown_msg}\");\n\
313         const CAPTURE_DISABLED: (u8, &str) = ({disabled}, \"{disabled_msg}\");\n\n\
314         fn main() -> ExitCode {{\n    \
315             let id = std::env::args().nth(1).unwrap_or_default();\n    \
316             verbatim! {{\n        \
317                 match id.as_str() {{\n{arms}            \
318                     _ => exit(CAPTURE_UNKNOWN, &id),\n        \
319                 }}\n    \
320             }}\n\
321         }}\n\n\
322         fn run<T: Termination>(main: fn() -> T) -> ExitCode {{\n    \
323             main().report()\n\
324         }}\n\n\
325         fn exit((code, sentinel): (u8, &str), id: &str) -> ExitCode {{\n    \
326             eprintln!(\"{{sentinel}} {{id}}\");\n    \
327             ExitCode::from(code)\n\
328         }}\n"
329    )
330}
331
332/// One block as a module whose `pub fn main` the capture example calls.
333fn capture_module(block: &CodeBlock) -> Result<String, String> {
334    let lines: Vec<&str> = block.code.lines().collect();
335    let Some(at) = lines.iter().position(|l| top_level_main(l).is_some()) else {
336        return Ok(format!(
337            "pub fn main() {{\n{}\n}}\n",
338            indent(&block.code, "    ")
339        ));
340    };
341    for (i, line) in lines.iter().enumerate() {
342        let next = lines[i + 1..].iter().find(|l| !l.trim().is_empty());
343        if line.starts_with("#![") && !is_lint_attribute(line) {
344            return Err(
345                "a capture block cannot use a crate-level attribute (`#![...]`) other than a lint level"
346                    .into(),
347            );
348        }
349        if line.starts_with("#[macro_use]") && next.is_some_and(|l| l.starts_with("extern crate")) {
350            return Err("a capture block cannot use `#[macro_use] extern crate`".into());
351        }
352    }
353    let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
354    if top_level_main(lines[at]) == Some(false) {
355        out[at] = format!("pub {}", lines[at]);
356    }
357    Ok(format!("{}\n", out.join("\n")))
358}
359
360/// Whether an inner attribute only sets lint levels, which a module file may
361/// carry as well as a crate root.
362fn is_lint_attribute(line: &str) -> bool {
363    ["allow", "warn", "deny", "forbid", "expect"]
364        .iter()
365        .any(|lint| {
366            line.strip_prefix("#![")
367                .is_some_and(|a| a.starts_with(&format!("{lint}(")))
368        })
369}
370
371/// For a line declaring a top-level `fn main` (or `async fn main`), whether
372/// it is already `pub`. Indented lines and other items are None.
373fn top_level_main(line: &str) -> Option<bool> {
374    let restricted = line
375        .strip_prefix("pub(")
376        .and_then(|rest| rest.split_once(") ").map(|(_, rest)| rest));
377    let (public, rest) = match restricted.or_else(|| line.strip_prefix("pub ")) {
378        Some(rest) => (true, rest),
379        None => (false, line),
380    };
381    let rest = rest.strip_prefix("async ").unwrap_or(rest);
382    let after = rest.strip_prefix("fn main")?;
383    (!after.starts_with(|c: char| c.is_alphanumeric() || c == '_')).then_some(public)
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::markdown;
390
391    #[test]
392    fn generates_tests_and_examples() {
393        let md = "```rust\nlet x = 1;\n```\n```rust no_run\nloop {}\n```\n```rust capture-output\nprintln!(\"h\");\n```\n";
394        let doc = markdown::scan(md).unwrap();
395        let tests = test_file("docs/a.md", &doc).unwrap();
396        assert!(tests.contains("#[test]"));
397        assert!(tests.contains("compile-only"));
398        assert!(!tests.contains("println!")); // capture block excluded
399        assert_eq!(capture_ids("docs/a.md", &doc), ["docs_a_1"]);
400    }
401
402    #[test]
403    fn doc_tests_compile_into_one_target_with_a_module_per_page() {
404        let a = markdown::scan("```rust\nlet x = 1;\n```\n").unwrap();
405        let b = markdown::scan("```rust\nlet y = 2;\n```\n").unwrap();
406        let none = markdown::scan("```rust ignore\nnope();\n```\n").unwrap();
407        let set = doc_test_set([
408            ("docs/b.md", &b),
409            ("README.md", &a),
410            ("docs/skip.md", &none),
411        ])
412        .unwrap();
413        let paths: Vec<&str> = set.iter().map(|g| g.path.as_str()).collect();
414        assert_eq!(
415            paths,
416            [
417                "tests/soothfast_docs/main.rs",
418                "tests/soothfast_docs/doc_readme.rs",
419                "tests/soothfast_docs/doc_docs_b.rs"
420            ]
421        );
422        assert!(set[0].content.starts_with(GENERATED_HEADER));
423        assert!(
424            set[0]
425                .content
426                .ends_with("verbatim! {\nmod doc_readme;\nmod doc_docs_b;\n}\n")
427        );
428        assert_eq!(set[1].content, test_file("README.md", &a).unwrap());
429    }
430
431    #[test]
432    fn no_testable_page_means_no_doc_test_target() {
433        let none = markdown::scan("```rust ignore\nnope();\n```\n").unwrap();
434        assert!(doc_test_set([("README.md", &none)]).unwrap().is_empty());
435    }
436
437    #[test]
438    fn two_pages_on_one_module_name_are_an_error_naming_both() {
439        let a = markdown::scan("```rust\nlet x = 1;\n```\n").unwrap();
440        let e = doc_test_set([("docs/a-b.md", &a), ("docs/a_b.md", &a)]).unwrap_err();
441        assert!(
442            e.contains("docs/a-b.md") && e.contains("docs/a_b.md"),
443            "{e}"
444        );
445    }
446
447    #[test]
448    fn owned_places_cover_both_layouts_and_nothing_else() {
449        assert_eq!(
450            owning_target("tests/soothfast_doc_readme.rs"),
451            Some((DOC_TESTS_DIR, true))
452        );
453        assert_eq!(
454            owning_target("tests/soothfast_docs/main.rs"),
455            Some((DOC_TESTS_DIR, false))
456        );
457        assert_eq!(
458            owning_target("examples/soothfast_capture_readme_1.rs"),
459            Some((CAPTURE_DIR, true))
460        );
461        assert_eq!(
462            owning_target("examples/soothfast_capture/block_readme_1.rs"),
463            Some((CAPTURE_DIR, false))
464        );
465        assert_eq!(owning_target("tests/integration.rs"), None);
466        assert_eq!(owning_target("tests/soothfast_docs/data.json"), None);
467        assert_eq!(owning_target("src/soothfast_doc_x.rs"), None);
468    }
469
470    #[test]
471    fn all_ignored_means_no_test_file() {
472        let doc = markdown::scan("```rust ignore\nfictional();\n```\n").unwrap();
473        assert!(test_file("README.md", &doc).is_none());
474    }
475
476    #[test]
477    fn feature_tag_gates_generated_test() {
478        let md =
479            "```rust feature=risk\nlet x = 1;\n```\n```rust no_run feature=risk\nloop {}\n```\n";
480        let doc = markdown::scan(md).unwrap();
481        let tests = test_file("docs/r.md", &doc).unwrap();
482        assert_eq!(tests.matches("#[cfg(feature = \"risk\")]").count(), 2);
483    }
484
485    #[test]
486    fn comma_separated_feature_tag_gates_on_all_of_them() {
487        // A block whose body needs two features to compile — gating on only
488        // the first leaves it enabled, and failing to build, when the second
489        // is off.
490        let md = "```rust no_run feature=risk,polygon\nloop {}\n```\n";
491        let doc = markdown::scan(md).unwrap();
492        let tests = test_file("docs/r.md", &doc).unwrap();
493        assert!(tests.contains("#[cfg(all(feature = \"risk\", feature = \"polygon\"))]"));
494    }
495
496    fn capture(md: &str) -> Vec<Generated> {
497        capture_set([("docs/c.md", &markdown::scan(md).unwrap())]).unwrap()
498    }
499
500    #[test]
501    fn capture_blocks_compile_into_one_example_with_a_module_per_block() {
502        let set = capture(
503            "```rust capture-output\nprintln!(\"a\");\n```\n```rust capture-output\nprintln!(\"b\");\n```\n",
504        );
505        let paths: Vec<&str> = set.iter().map(|g| g.path.as_str()).collect();
506        assert_eq!(
507            paths,
508            [
509                "examples/soothfast_capture/main.rs",
510                "examples/soothfast_capture/block_docs_c_1.rs",
511                "examples/soothfast_capture/block_docs_c_2.rs",
512            ]
513        );
514        let main = &set[0].content;
515        assert!(main.contains("mod block_docs_c_1;\nmod block_docs_c_2;\n"));
516        assert!(main.contains("\"docs_c_2\" => run(block_docs_c_2::main),"));
517        assert!(main.contains("_ => exit(CAPTURE_UNKNOWN, &id),"));
518        assert!(
519            set[1]
520                .content
521                .contains("pub fn main() {\n    println!(\"a\");\n}")
522        );
523        assert_eq!(
524            recorded_source(&set[1].content),
525            Some(("docs/c.md", Some(1)))
526        );
527    }
528
529    #[test]
530    fn no_capture_block_means_no_capture_example() {
531        assert!(capture("```rust\nlet x = 1;\n```\n").is_empty());
532    }
533
534    #[test]
535    fn feature_tag_gates_the_module_and_dispatches_disabled_without_it() {
536        let set = capture("```rust capture-output feature=risk,polygon\nprintln!(\"hi\");\n```\n");
537        let main = &set[0].content;
538        let pred = "all(feature = \"risk\", feature = \"polygon\")";
539        assert!(main.contains(&format!("#[cfg({pred})]\nmod block_docs_c_1;")));
540        assert!(main.contains(&format!(
541            "#[cfg({pred})]\n            \"docs_c_1\" => run(block_docs_c_1::main),"
542        )));
543        assert!(main.contains(&format!(
544            "#[cfg(not({pred}))]\n            \"docs_c_1\" => exit(CAPTURE_DISABLED, &id),"
545        )));
546        assert!(main.contains(
547            "const CAPTURE_DISABLED: (u8, &str) = (3, \"soothfast_capture: disabled block\");"
548        ));
549    }
550
551    #[test]
552    fn a_blocks_own_top_level_main_becomes_pub_and_keeps_its_attributes() {
553        let md = "```rust capture-output\n// fn main is below\nuse std::fmt;\n\n#[tokio::main]\nasync fn main() -> Result<(), Box<dyn std::error::Error>> {\n    fn main_helper() {}\n    Ok(())\n}\n```\n";
554        let block = &capture(md)[1].content;
555        assert!(block.contains("// fn main is below\nuse std::fmt;"));
556        assert!(block.contains("#[tokio::main]\npub async fn main() -> Result<"));
557        assert!(block.contains("    fn main_helper() {}"));
558        assert_eq!(block.matches("fn main(").count(), 1);
559    }
560
561    #[test]
562    fn an_already_pub_main_is_left_alone() {
563        let block = &capture("```rust capture-output\npub fn main() {}\n```\n")[1].content;
564        assert!(block.ends_with("\npub fn main() {}\n"), "{block}");
565    }
566
567    #[test]
568    fn crate_level_attributes_in_a_capture_block_are_an_error() {
569        for code in [
570            "#![feature(test)]\nfn main() {}",
571            "#[macro_use]\nextern crate serde;\nfn main() {}",
572        ] {
573            let doc = markdown::scan(&format!("```rust capture-output\n{code}\n```\n")).unwrap();
574            let e = capture_set([("docs/c.md", &doc)]).unwrap_err();
575            assert!(
576                e.starts_with("docs/c.md line 1: a capture block cannot"),
577                "{e}"
578            );
579        }
580    }
581
582    #[test]
583    fn two_pages_on_one_capture_stem_are_an_error_naming_both() {
584        let a = markdown::scan("```rust capture-output\nlet x = 1;\n```\n").unwrap();
585        let e = capture_set([("docs/a-b.md", &a), ("docs/a_b.md", &a)]).unwrap_err();
586        assert!(
587            e.contains("docs/a-b.md") && e.contains("docs/a_b.md"),
588            "{e}"
589        );
590    }
591
592    #[test]
593    fn recorded_source_reads_page_with_or_without_a_line() {
594        let h = GENERATED_HEADER;
595        assert_eq!(
596            recorded_source(&format!("{h}\n// source: README.md\n")),
597            Some(("README.md", None))
598        );
599        assert_eq!(
600            recorded_source(&format!("{h}\n// source: docs/a line b.md line 41\n")),
601            Some(("docs/a line b.md", Some(41)))
602        );
603        assert_eq!(
604            recorded_source(&format!("{h}\n// source: docs/a line b.md\n")),
605            Some(("docs/a line b.md", None))
606        );
607        assert_eq!(recorded_source(&format!("{h}\nfn main() {{}}\n")), None);
608    }
609
610    #[test]
611    fn a_lint_level_inner_attribute_is_allowed_in_a_capture_block() {
612        for attr in ["allow(dead_code)", "warn(missing_docs)", "expect(unused)"] {
613            let md = format!("```rust capture-output\n#![{attr}]\nfn main() {{}}\n```\n");
614            let block = &capture(&md)[1].content;
615            assert!(
616                block.contains(&format!("#![{attr}]\npub fn main() {{}}")),
617                "{block}"
618            );
619        }
620    }
621
622    #[test]
623    fn a_restricted_visibility_main_is_the_blocks_own_main() {
624        for vis in ["pub(crate)", "pub(super)"] {
625            let md = format!("```rust capture-output\n{vis} fn main() {{}}\n```\n");
626            let block = &capture(&md)[1].content;
627            assert!(
628                block.ends_with(&format!("\n{vis} fn main() {{}}\n")),
629                "{block}"
630            );
631        }
632    }
633
634    #[test]
635    fn only_a_top_level_main_is_called_by_a_doc_test() {
636        let md = "```rust\n// fn main is not here\nfn main_loop() {}\nlet s = \"fn main\";\n```\n```rust\nfn main() {}\n```\n";
637        let tests = test_file("docs/m.md", &markdown::scan(md).unwrap()).unwrap();
638        assert_eq!(tests.matches("    main();").count(), 1, "{tests}");
639    }
640
641    #[test]
642    fn two_blocks_installing_a_global_allocator_are_an_error_naming_both() {
643        let a = markdown::scan("```rust no_run\nsoothfast::bench_main!();\n```\n").unwrap();
644        let b =
645            markdown::scan("```rust no_run\n#[global_allocator]\nstatic A: X = X;\n```\n").unwrap();
646        let e = doc_test_set([("docs/a.md", &a), ("docs/b.md", &b)]).unwrap_err();
647        assert!(
648            e.starts_with("docs/a.md line 1 and docs/b.md line 1 both install"),
649            "{e}"
650        );
651        assert!(doc_test_set([("docs/a.md", &a)]).is_ok());
652    }
653
654    /// Writes `set` under a temp dir and asks rustfmt, in every edition,
655    /// whether it would change the target's `main.rs`.
656    fn assert_rustfmt_leaves_alone(name: &str, set: &[Generated], root: &str) {
657        let dir = std::env::temp_dir().join(format!("soothfast-fmt-{name}-{}", std::process::id()));
658        let _ = std::fs::remove_dir_all(&dir);
659        for g in set {
660            let path = dir.join(&g.path);
661            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
662            std::fs::write(path, &g.content).unwrap();
663        }
664        for edition in ["2015", "2021", "2024"] {
665            let out = std::process::Command::new("rustfmt")
666                .args(["--check", "--edition", edition])
667                .arg(dir.join(root).join("main.rs"))
668                .output()
669                .unwrap();
670            assert!(
671                out.status.success(),
672                "edition {edition}:\n{}",
673                String::from_utf8_lossy(&out.stdout)
674            );
675        }
676        let _ = std::fs::remove_dir_all(&dir);
677    }
678
679    fn rustfmt_present() -> bool {
680        std::process::Command::new("rustfmt")
681            .arg("--version")
682            .output()
683            .is_ok_and(|o| o.status.success())
684    }
685
686    #[test]
687    fn generated_targets_are_already_rustfmt_clean() {
688        if !rustfmt_present() {
689            return;
690        }
691        let long =
692            "docs/library/an_unusually_long_page_name_for_a_reference_section_of_the_library.md";
693        let mut capture_md = String::new();
694        for n in 0..12 {
695            let tag = if n % 3 == 0 {
696                " feature=risk,polygon,indicators,backtesting"
697            } else {
698                ""
699            };
700            capture_md.push_str(&format!(
701                "```rust capture-output{tag}\nprintln!(\"{n}\");\n```\n"
702            ));
703        }
704        let md = format!("```rust\nlet x = 1;\n```\n{capture_md}");
705        let pages: Vec<(&str, Doc)> = [
706            long,
707            "docs/b.md",
708            "docs/a_10.md",
709            "docs/a_2.md",
710            "README.md",
711        ]
712        .into_iter()
713        .map(|p| (p, markdown::scan(&md).unwrap()))
714        .collect();
715        let docs = || pages.iter().map(|(p, d)| (*p, d));
716        assert_rustfmt_leaves_alone("tests", &doc_test_set(docs()).unwrap(), DOC_TESTS_DIR);
717        assert_rustfmt_leaves_alone("capture", &capture_set(docs()).unwrap(), CAPTURE_DIR);
718    }
719}