use crate::markdown::{CodeBlock, Doc};
pub const GENERATED_HEADER: &str = "// @generated by `cargo soothfast docs gen-tests`";
pub fn sanitized_stem(rel_path: &str) -> String {
let stem = rel_path.trim_end_matches(".md");
let san: String = stem
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() {
c.to_ascii_lowercase()
} else {
'_'
}
})
.collect();
san.trim_matches('_').to_string()
}
fn indent(code: &str, pad: &str) -> String {
code.lines()
.map(|l| {
if l.is_empty() {
String::new()
} else {
format!("{pad}{l}")
}
})
.collect::<Vec<_>>()
.join("\n")
}
#[derive(Debug, Clone, PartialEq)]
pub struct Generated {
pub path: String,
pub content: String,
}
const VERBATIM: &str =
"macro_rules! verbatim {\n ($($tokens:tt)*) => {\n $($tokens)*\n };\n}\n";
pub const DOC_TESTS_DIR: &str = "tests/soothfast_docs";
pub const OWNED: &[(&str, &str, Option<&str>)] = &[
("tests", "soothfast_doc_", Some(DOC_TESTS_DIR)),
(DOC_TESTS_DIR, "", None),
("examples", "soothfast_capture_", Some(CAPTURE_DIR)),
(CAPTURE_DIR, "", None),
];
pub fn recorded_source(content: &str) -> Option<(&str, Option<usize>)> {
let source = content.lines().nth(1)?.strip_prefix("// source: ")?;
match source.rsplit_once(" line ") {
Some((page, line)) => match line.parse::<usize>() {
Ok(line) => Some((page, Some(line))),
Err(_) => Some((source, None)),
},
None => Some((source, None)),
}
}
pub fn doc_test_set<'a>(
docs: impl IntoIterator<Item = (&'a str, &'a Doc)>,
) -> Result<Vec<Generated>, String> {
let docs: Vec<(&str, &Doc)> = docs.into_iter().collect();
one_global_allocator(&docs, CodeBlock::is_testable_rust)?;
let mut pages: Vec<(String, &str, String)> = Vec::new();
for (rel_path, doc) in docs {
if let Some(content) = test_file(rel_path, doc) {
pages.push((
format!("doc_{}", sanitized_stem(rel_path)),
rel_path,
content,
));
}
}
pages.sort_by(|a, b| a.1.cmp(b.1));
for (i, (module, path, _)) in pages.iter().enumerate() {
if let Some((_, other, _)) = pages[..i].iter().find(|(m, _, _)| m == module) {
return Err(format!(
"{other} and {path} both generate doc-test module `{module}`; rename one"
));
}
}
if pages.is_empty() {
return Ok(Vec::new());
}
let mods: String = pages
.iter()
.map(|(m, _, _)| format!("mod {m};\n"))
.collect();
let main =
format!("{GENERATED_HEADER}\n#![allow(unused)]\n\n{VERBATIM}\nverbatim! {{\n{mods}}}\n");
let mut set = vec![Generated {
path: format!("{DOC_TESTS_DIR}/main.rs"),
content: main,
}];
set.extend(pages.into_iter().map(|(module, _, content)| Generated {
path: format!("{DOC_TESTS_DIR}/{module}.rs"),
content,
}));
Ok(set)
}
fn one_global_allocator(
docs: &[(&str, &Doc)],
in_target: fn(&CodeBlock) -> bool,
) -> Result<(), String> {
let mut found = docs.iter().flat_map(|(page, doc)| {
doc.blocks
.iter()
.filter(move |b| in_target(b))
.filter(|b| b.code.contains("#[global_allocator]") || b.code.contains("bench_main!"))
.map(move |b| format!("{page} line {}", b.line))
});
match (found.next(), found.next()) {
(Some(first), Some(second)) => Err(format!(
"{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"
)),
_ => Ok(()),
}
}
pub fn owning_target(rel_path: &str) -> Option<(&'static str, bool)> {
let (dir, name) = rel_path.rsplit_once('/').unwrap_or(("", rel_path));
if !name.ends_with(".rs") {
return None;
}
OWNED
.iter()
.find(|(d, prefix, _)| *d == dir && name.starts_with(prefix))
.map(|(dir, _, replaced_by)| (replaced_by.unwrap_or(dir), replaced_by.is_some()))
}
pub fn test_file(rel_path: &str, doc: &Doc) -> Option<String> {
let testable: Vec<&CodeBlock> = doc.blocks.iter().filter(|b| b.is_testable_rust()).collect();
if testable.is_empty() {
return None;
}
let mut out = format!("{GENERATED_HEADER}\n// source: {rel_path}\n#![allow(unused)]\n");
for block in testable {
let body = indent(&block.code, " ");
let call_main = if block.code.lines().any(|l| top_level_main(l).is_some()) {
"\n main();"
} else {
""
};
let cfg = match block.feature_cfg() {
Some(pred) => format!("#[cfg({pred})]\n"),
None => String::new(),
};
if block.has_tag("no_run") {
out.push_str(&format!(
"\n// line {}: compile-only (no_run)\n{cfg}#[rustfmt::skip]\n#[allow(dead_code)]\nfn doc_block_line_{}() {{\n{body}\n}}\n",
block.line, block.line
));
} else {
out.push_str(&format!(
"\n// line {}\n{cfg}#[rustfmt::skip]\n#[test]\nfn doc_block_line_{}() {{\n{body}{call_main}\n}}\n",
block.line, block.line
));
}
}
Some(out)
}
pub const CAPTURE_DIR: &str = "examples/soothfast_capture";
pub const CAPTURE_EXAMPLE: &str = "soothfast_capture";
pub const CAPTURE_UNKNOWN: (i32, &str) = (2, "soothfast_capture: unknown block");
pub const CAPTURE_DISABLED: (i32, &str) = (3, "soothfast_capture: disabled block");
pub fn capture_ids(rel_path: &str, doc: &Doc) -> Vec<String> {
let stem = sanitized_stem(rel_path);
let count = doc.blocks.iter().filter(|b| b.is_capture()).count();
(1..=count).map(|n| format!("{stem}_{n}")).collect()
}
pub fn capture_set<'a>(
docs: impl IntoIterator<Item = (&'a str, &'a Doc)>,
) -> Result<Vec<Generated>, String> {
let docs: Vec<(&str, &Doc)> = docs.into_iter().collect();
one_global_allocator(&docs, CodeBlock::is_capture)?;
let mut blocks: Vec<(String, &str, Option<String>, String)> = Vec::new();
let mut stems: Vec<(String, &str)> = Vec::new();
for (rel_path, doc) in docs {
let ids = capture_ids(rel_path, doc);
if ids.is_empty() {
continue;
}
let stem = sanitized_stem(rel_path);
if let Some((_, other)) = stems.iter().find(|(s, _)| *s == stem) {
return Err(format!(
"{other} and {rel_path} both generate capture blocks `{stem}_N`; rename one"
));
}
stems.push((stem, rel_path));
let captures = doc.blocks.iter().filter(|b| b.is_capture());
for (id, block) in ids.into_iter().zip(captures) {
let body = capture_module(block)
.map_err(|e| format!("{rel_path} line {}: {e}", block.line))?;
let content = format!(
"{GENERATED_HEADER}\n// source: {rel_path} line {}\n{body}",
block.line
);
blocks.push((id, rel_path, block.feature_cfg(), content));
}
}
if blocks.is_empty() {
return Ok(Vec::new());
}
blocks.sort_by(|a, b| a.1.cmp(b.1));
let mut set = vec![Generated {
path: format!("{CAPTURE_DIR}/main.rs"),
content: capture_main(&blocks),
}];
set.extend(blocks.into_iter().map(|(id, _, _, content)| Generated {
path: format!("{CAPTURE_DIR}/block_{id}.rs"),
content,
}));
Ok(set)
}
fn capture_main(blocks: &[(String, &str, Option<String>, String)]) -> String {
let mut mods = String::new();
let mut arms = String::new();
for (id, _, cfg, _) in blocks {
let run = format!("\"{id}\" => run(block_{id}::main),\n");
match cfg {
Some(pred) => {
mods.push_str(&format!("#[cfg({pred})]\nmod block_{id};\n"));
arms.push_str(&format!(" #[cfg({pred})]\n {run}"));
arms.push_str(&format!(
" #[cfg(not({pred}))]\n \"{id}\" => exit(CAPTURE_DISABLED, &id),\n"
));
}
None => {
mods.push_str(&format!("mod block_{id};\n"));
arms.push_str(&format!(" {run}"));
}
}
}
let (unknown, unknown_msg) = CAPTURE_UNKNOWN;
let (disabled, disabled_msg) = CAPTURE_DISABLED;
format!(
"{GENERATED_HEADER}\n#![allow(dead_code, unused_imports)]\n\n\
use std::process::{{ExitCode, Termination}};\n\n\
{VERBATIM}\n\
verbatim! {{\n{mods}}}\n\n\
const CAPTURE_UNKNOWN: (u8, &str) = ({unknown}, \"{unknown_msg}\");\n\
const CAPTURE_DISABLED: (u8, &str) = ({disabled}, \"{disabled_msg}\");\n\n\
fn main() -> ExitCode {{\n \
let id = std::env::args().nth(1).unwrap_or_default();\n \
verbatim! {{\n \
match id.as_str() {{\n{arms} \
_ => exit(CAPTURE_UNKNOWN, &id),\n \
}}\n \
}}\n\
}}\n\n\
fn run<T: Termination>(main: fn() -> T) -> ExitCode {{\n \
main().report()\n\
}}\n\n\
fn exit((code, sentinel): (u8, &str), id: &str) -> ExitCode {{\n \
eprintln!(\"{{sentinel}} {{id}}\");\n \
ExitCode::from(code)\n\
}}\n"
)
}
fn capture_module(block: &CodeBlock) -> Result<String, String> {
let lines: Vec<&str> = block.code.lines().collect();
let Some(at) = lines.iter().position(|l| top_level_main(l).is_some()) else {
return Ok(format!(
"pub fn main() {{\n{}\n}}\n",
indent(&block.code, " ")
));
};
for (i, line) in lines.iter().enumerate() {
let next = lines[i + 1..].iter().find(|l| !l.trim().is_empty());
if line.starts_with("#![") && !is_lint_attribute(line) {
return Err(
"a capture block cannot use a crate-level attribute (`#![...]`) other than a lint level"
.into(),
);
}
if line.starts_with("#[macro_use]") && next.is_some_and(|l| l.starts_with("extern crate")) {
return Err("a capture block cannot use `#[macro_use] extern crate`".into());
}
}
let mut out: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
if top_level_main(lines[at]) == Some(false) {
out[at] = format!("pub {}", lines[at]);
}
Ok(format!("{}\n", out.join("\n")))
}
fn is_lint_attribute(line: &str) -> bool {
["allow", "warn", "deny", "forbid", "expect"]
.iter()
.any(|lint| {
line.strip_prefix("#![")
.is_some_and(|a| a.starts_with(&format!("{lint}(")))
})
}
fn top_level_main(line: &str) -> Option<bool> {
let restricted = line
.strip_prefix("pub(")
.and_then(|rest| rest.split_once(") ").map(|(_, rest)| rest));
let (public, rest) = match restricted.or_else(|| line.strip_prefix("pub ")) {
Some(rest) => (true, rest),
None => (false, line),
};
let rest = rest.strip_prefix("async ").unwrap_or(rest);
let after = rest.strip_prefix("fn main")?;
(!after.starts_with(|c: char| c.is_alphanumeric() || c == '_')).then_some(public)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::markdown;
#[test]
fn generates_tests_and_examples() {
let md = "```rust\nlet x = 1;\n```\n```rust no_run\nloop {}\n```\n```rust capture-output\nprintln!(\"h\");\n```\n";
let doc = markdown::scan(md).unwrap();
let tests = test_file("docs/a.md", &doc).unwrap();
assert!(tests.contains("#[test]"));
assert!(tests.contains("compile-only"));
assert!(!tests.contains("println!")); assert_eq!(capture_ids("docs/a.md", &doc), ["docs_a_1"]);
}
#[test]
fn doc_tests_compile_into_one_target_with_a_module_per_page() {
let a = markdown::scan("```rust\nlet x = 1;\n```\n").unwrap();
let b = markdown::scan("```rust\nlet y = 2;\n```\n").unwrap();
let none = markdown::scan("```rust ignore\nnope();\n```\n").unwrap();
let set = doc_test_set([
("docs/b.md", &b),
("README.md", &a),
("docs/skip.md", &none),
])
.unwrap();
let paths: Vec<&str> = set.iter().map(|g| g.path.as_str()).collect();
assert_eq!(
paths,
[
"tests/soothfast_docs/main.rs",
"tests/soothfast_docs/doc_readme.rs",
"tests/soothfast_docs/doc_docs_b.rs"
]
);
assert!(set[0].content.starts_with(GENERATED_HEADER));
assert!(
set[0]
.content
.ends_with("verbatim! {\nmod doc_readme;\nmod doc_docs_b;\n}\n")
);
assert_eq!(set[1].content, test_file("README.md", &a).unwrap());
}
#[test]
fn no_testable_page_means_no_doc_test_target() {
let none = markdown::scan("```rust ignore\nnope();\n```\n").unwrap();
assert!(doc_test_set([("README.md", &none)]).unwrap().is_empty());
}
#[test]
fn two_pages_on_one_module_name_are_an_error_naming_both() {
let a = markdown::scan("```rust\nlet x = 1;\n```\n").unwrap();
let e = doc_test_set([("docs/a-b.md", &a), ("docs/a_b.md", &a)]).unwrap_err();
assert!(
e.contains("docs/a-b.md") && e.contains("docs/a_b.md"),
"{e}"
);
}
#[test]
fn owned_places_cover_both_layouts_and_nothing_else() {
assert_eq!(
owning_target("tests/soothfast_doc_readme.rs"),
Some((DOC_TESTS_DIR, true))
);
assert_eq!(
owning_target("tests/soothfast_docs/main.rs"),
Some((DOC_TESTS_DIR, false))
);
assert_eq!(
owning_target("examples/soothfast_capture_readme_1.rs"),
Some((CAPTURE_DIR, true))
);
assert_eq!(
owning_target("examples/soothfast_capture/block_readme_1.rs"),
Some((CAPTURE_DIR, false))
);
assert_eq!(owning_target("tests/integration.rs"), None);
assert_eq!(owning_target("tests/soothfast_docs/data.json"), None);
assert_eq!(owning_target("src/soothfast_doc_x.rs"), None);
}
#[test]
fn all_ignored_means_no_test_file() {
let doc = markdown::scan("```rust ignore\nfictional();\n```\n").unwrap();
assert!(test_file("README.md", &doc).is_none());
}
#[test]
fn feature_tag_gates_generated_test() {
let md =
"```rust feature=risk\nlet x = 1;\n```\n```rust no_run feature=risk\nloop {}\n```\n";
let doc = markdown::scan(md).unwrap();
let tests = test_file("docs/r.md", &doc).unwrap();
assert_eq!(tests.matches("#[cfg(feature = \"risk\")]").count(), 2);
}
#[test]
fn comma_separated_feature_tag_gates_on_all_of_them() {
let md = "```rust no_run feature=risk,polygon\nloop {}\n```\n";
let doc = markdown::scan(md).unwrap();
let tests = test_file("docs/r.md", &doc).unwrap();
assert!(tests.contains("#[cfg(all(feature = \"risk\", feature = \"polygon\"))]"));
}
fn capture(md: &str) -> Vec<Generated> {
capture_set([("docs/c.md", &markdown::scan(md).unwrap())]).unwrap()
}
#[test]
fn capture_blocks_compile_into_one_example_with_a_module_per_block() {
let set = capture(
"```rust capture-output\nprintln!(\"a\");\n```\n```rust capture-output\nprintln!(\"b\");\n```\n",
);
let paths: Vec<&str> = set.iter().map(|g| g.path.as_str()).collect();
assert_eq!(
paths,
[
"examples/soothfast_capture/main.rs",
"examples/soothfast_capture/block_docs_c_1.rs",
"examples/soothfast_capture/block_docs_c_2.rs",
]
);
let main = &set[0].content;
assert!(main.contains("mod block_docs_c_1;\nmod block_docs_c_2;\n"));
assert!(main.contains("\"docs_c_2\" => run(block_docs_c_2::main),"));
assert!(main.contains("_ => exit(CAPTURE_UNKNOWN, &id),"));
assert!(
set[1]
.content
.contains("pub fn main() {\n println!(\"a\");\n}")
);
assert_eq!(
recorded_source(&set[1].content),
Some(("docs/c.md", Some(1)))
);
}
#[test]
fn no_capture_block_means_no_capture_example() {
assert!(capture("```rust\nlet x = 1;\n```\n").is_empty());
}
#[test]
fn feature_tag_gates_the_module_and_dispatches_disabled_without_it() {
let set = capture("```rust capture-output feature=risk,polygon\nprintln!(\"hi\");\n```\n");
let main = &set[0].content;
let pred = "all(feature = \"risk\", feature = \"polygon\")";
assert!(main.contains(&format!("#[cfg({pred})]\nmod block_docs_c_1;")));
assert!(main.contains(&format!(
"#[cfg({pred})]\n \"docs_c_1\" => run(block_docs_c_1::main),"
)));
assert!(main.contains(&format!(
"#[cfg(not({pred}))]\n \"docs_c_1\" => exit(CAPTURE_DISABLED, &id),"
)));
assert!(main.contains(
"const CAPTURE_DISABLED: (u8, &str) = (3, \"soothfast_capture: disabled block\");"
));
}
#[test]
fn a_blocks_own_top_level_main_becomes_pub_and_keeps_its_attributes() {
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";
let block = &capture(md)[1].content;
assert!(block.contains("// fn main is below\nuse std::fmt;"));
assert!(block.contains("#[tokio::main]\npub async fn main() -> Result<"));
assert!(block.contains(" fn main_helper() {}"));
assert_eq!(block.matches("fn main(").count(), 1);
}
#[test]
fn an_already_pub_main_is_left_alone() {
let block = &capture("```rust capture-output\npub fn main() {}\n```\n")[1].content;
assert!(block.ends_with("\npub fn main() {}\n"), "{block}");
}
#[test]
fn crate_level_attributes_in_a_capture_block_are_an_error() {
for code in [
"#![feature(test)]\nfn main() {}",
"#[macro_use]\nextern crate serde;\nfn main() {}",
] {
let doc = markdown::scan(&format!("```rust capture-output\n{code}\n```\n")).unwrap();
let e = capture_set([("docs/c.md", &doc)]).unwrap_err();
assert!(
e.starts_with("docs/c.md line 1: a capture block cannot"),
"{e}"
);
}
}
#[test]
fn two_pages_on_one_capture_stem_are_an_error_naming_both() {
let a = markdown::scan("```rust capture-output\nlet x = 1;\n```\n").unwrap();
let e = capture_set([("docs/a-b.md", &a), ("docs/a_b.md", &a)]).unwrap_err();
assert!(
e.contains("docs/a-b.md") && e.contains("docs/a_b.md"),
"{e}"
);
}
#[test]
fn recorded_source_reads_page_with_or_without_a_line() {
let h = GENERATED_HEADER;
assert_eq!(
recorded_source(&format!("{h}\n// source: README.md\n")),
Some(("README.md", None))
);
assert_eq!(
recorded_source(&format!("{h}\n// source: docs/a line b.md line 41\n")),
Some(("docs/a line b.md", Some(41)))
);
assert_eq!(
recorded_source(&format!("{h}\n// source: docs/a line b.md\n")),
Some(("docs/a line b.md", None))
);
assert_eq!(recorded_source(&format!("{h}\nfn main() {{}}\n")), None);
}
#[test]
fn a_lint_level_inner_attribute_is_allowed_in_a_capture_block() {
for attr in ["allow(dead_code)", "warn(missing_docs)", "expect(unused)"] {
let md = format!("```rust capture-output\n#![{attr}]\nfn main() {{}}\n```\n");
let block = &capture(&md)[1].content;
assert!(
block.contains(&format!("#![{attr}]\npub fn main() {{}}")),
"{block}"
);
}
}
#[test]
fn a_restricted_visibility_main_is_the_blocks_own_main() {
for vis in ["pub(crate)", "pub(super)"] {
let md = format!("```rust capture-output\n{vis} fn main() {{}}\n```\n");
let block = &capture(&md)[1].content;
assert!(
block.ends_with(&format!("\n{vis} fn main() {{}}\n")),
"{block}"
);
}
}
#[test]
fn only_a_top_level_main_is_called_by_a_doc_test() {
let md = "```rust\n// fn main is not here\nfn main_loop() {}\nlet s = \"fn main\";\n```\n```rust\nfn main() {}\n```\n";
let tests = test_file("docs/m.md", &markdown::scan(md).unwrap()).unwrap();
assert_eq!(tests.matches(" main();").count(), 1, "{tests}");
}
#[test]
fn two_blocks_installing_a_global_allocator_are_an_error_naming_both() {
let a = markdown::scan("```rust no_run\nsoothfast::bench_main!();\n```\n").unwrap();
let b =
markdown::scan("```rust no_run\n#[global_allocator]\nstatic A: X = X;\n```\n").unwrap();
let e = doc_test_set([("docs/a.md", &a), ("docs/b.md", &b)]).unwrap_err();
assert!(
e.starts_with("docs/a.md line 1 and docs/b.md line 1 both install"),
"{e}"
);
assert!(doc_test_set([("docs/a.md", &a)]).is_ok());
}
fn assert_rustfmt_leaves_alone(name: &str, set: &[Generated], root: &str) {
let dir = std::env::temp_dir().join(format!("soothfast-fmt-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
for g in set {
let path = dir.join(&g.path);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, &g.content).unwrap();
}
for edition in ["2015", "2021", "2024"] {
let out = std::process::Command::new("rustfmt")
.args(["--check", "--edition", edition])
.arg(dir.join(root).join("main.rs"))
.output()
.unwrap();
assert!(
out.status.success(),
"edition {edition}:\n{}",
String::from_utf8_lossy(&out.stdout)
);
}
let _ = std::fs::remove_dir_all(&dir);
}
fn rustfmt_present() -> bool {
std::process::Command::new("rustfmt")
.arg("--version")
.output()
.is_ok_and(|o| o.status.success())
}
#[test]
fn generated_targets_are_already_rustfmt_clean() {
if !rustfmt_present() {
return;
}
let long =
"docs/library/an_unusually_long_page_name_for_a_reference_section_of_the_library.md";
let mut capture_md = String::new();
for n in 0..12 {
let tag = if n % 3 == 0 {
" feature=risk,polygon,indicators,backtesting"
} else {
""
};
capture_md.push_str(&format!(
"```rust capture-output{tag}\nprintln!(\"{n}\");\n```\n"
));
}
let md = format!("```rust\nlet x = 1;\n```\n{capture_md}");
let pages: Vec<(&str, Doc)> = [
long,
"docs/b.md",
"docs/a_10.md",
"docs/a_2.md",
"README.md",
]
.into_iter()
.map(|p| (p, markdown::scan(&md).unwrap()))
.collect();
let docs = || pages.iter().map(|(p, d)| (*p, d));
assert_rustfmt_leaves_alone("tests", &doc_test_set(docs()).unwrap(), DOC_TESTS_DIR);
assert_rustfmt_leaves_alone("capture", &capture_set(docs()).unwrap(), CAPTURE_DIR);
}
}