+220
@@ -0,0 +1,220 @@
|
||||
//! Checks the markdown that gets included into the documentation.
|
||||
//!
|
||||
//! Rustdoc reads a fenced block with an empty info string as Rust and compiles it.
|
||||
//! So a `course.yaml` snippet in a bare fence fails the doc build with a parse
|
||||
//! error pointing at a generated file, which is a confusing way to learn that a
|
||||
//! four-letter language tag is missing. This test names the file and line instead.
|
||||
//!
|
||||
//! Two related problems are already covered elsewhere and are not repeated here: a
|
||||
//! missing `include_str!` target fails compilation, and a broken intra-doc link
|
||||
//! fails under `#![deny(rustdoc::broken_intra_doc_links)]` in `lib.rs`.
|
||||
//!
|
||||
//! No regex engine, matching the rest of the crate: every rule is a scan.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Info-string words rustdoc reads as attributes on a *Rust* block. Anything else
|
||||
/// is a language name, and rustdoc skips the block.
|
||||
const RUST_ATTRS: &[&str] = &[
|
||||
"rust",
|
||||
"ignore",
|
||||
"should_panic",
|
||||
"no_run",
|
||||
"compile_fail",
|
||||
"test_harness",
|
||||
"standalone_crate",
|
||||
];
|
||||
|
||||
/// Language tags this repository uses for content that is not Rust.
|
||||
const KNOWN_LANGS: &[&str] = &[
|
||||
"console", "text", "yaml", "toml", "json", "bash", "sh", "shell", "typst", "diff", "csv",
|
||||
"markdown", "md", "xml", "html", "python",
|
||||
];
|
||||
|
||||
/// The repository root, derived from the manifest directory.
|
||||
fn root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
}
|
||||
|
||||
/// Every `.md` file under `docs/`, sorted so failures are reported in a stable
|
||||
/// order.
|
||||
fn markdown_files(dir: &Path, out: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
let mut paths: Vec<PathBuf> = entries.filter_map(|e| e.ok()).map(|e| e.path()).collect();
|
||||
paths.sort();
|
||||
for path in paths {
|
||||
if path.is_dir() {
|
||||
markdown_files(&path, out);
|
||||
} else if path.extension().is_some_and(|e| e == "md") {
|
||||
out.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fence opener: its line number, its run of backticks, and its info string.
|
||||
struct Fence<'a> {
|
||||
line: usize,
|
||||
ticks: usize,
|
||||
info: &'a str,
|
||||
}
|
||||
|
||||
/// Finds every opening fence, tracking nesting by backtick count so that a `` ``` ``
|
||||
/// inside a ```` ```` ```` block is treated as content rather than as a new fence.
|
||||
fn opening_fences(text: &str) -> (Vec<Fence<'_>>, Option<usize>) {
|
||||
let mut found = Vec::new();
|
||||
let mut open: Option<usize> = None;
|
||||
|
||||
for (index, raw) in text.lines().enumerate() {
|
||||
let line = raw.trim_start();
|
||||
if !line.starts_with("```") {
|
||||
continue;
|
||||
}
|
||||
let ticks = line.chars().take_while(|c| *c == '`').count();
|
||||
let info = line[ticks..].trim();
|
||||
|
||||
match open {
|
||||
None => {
|
||||
open = Some(ticks);
|
||||
found.push(Fence {
|
||||
line: index + 1,
|
||||
ticks,
|
||||
info,
|
||||
});
|
||||
}
|
||||
// A closing fence is at least as long as its opener and carries no info
|
||||
// string. Anything else is content inside the block.
|
||||
Some(width) if ticks >= width && info.is_empty() => open = None,
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
(found, open)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_code_fence_declares_its_language() {
|
||||
let root = root();
|
||||
let mut files = Vec::new();
|
||||
markdown_files(&root.join("docs"), &mut files);
|
||||
assert!(!files.is_empty(), "no markdown found under docs/");
|
||||
|
||||
let mut issues: Vec<String> = Vec::new();
|
||||
|
||||
for path in &files {
|
||||
let text = std::fs::read_to_string(path).expect("markdown is readable");
|
||||
let name = path.strip_prefix(&root).unwrap_or(path).display();
|
||||
let (fences, unclosed) = opening_fences(&text);
|
||||
|
||||
if unclosed.is_some() {
|
||||
issues.push(format!(
|
||||
"{name}: a code fence is never closed. A nested ``` inside a block \
|
||||
closes its parent early; widen the outer fence to ````."
|
||||
));
|
||||
}
|
||||
|
||||
for fence in fences {
|
||||
let _ = fence.ticks;
|
||||
if fence.info.is_empty() {
|
||||
issues.push(format!(
|
||||
"{name}:{}: fence has no language, so rustdoc compiles it as Rust. \
|
||||
Tag it (```console, ```yaml, ```text) or mark it ```rust.",
|
||||
fence.line
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let words: Vec<&str> = fence
|
||||
.info
|
||||
.split(|c: char| c == ',' || c.is_whitespace())
|
||||
.filter(|w| !w.is_empty())
|
||||
.collect();
|
||||
let head = words[0];
|
||||
|
||||
if RUST_ATTRS.contains(&head) || head.starts_with("edition") {
|
||||
// A Rust block. Every further word must be a real attribute, since a
|
||||
// typo like `no_ru` silently stops the example from being tested.
|
||||
for word in &words {
|
||||
if !RUST_ATTRS.contains(word) && !word.starts_with("edition") {
|
||||
issues.push(format!(
|
||||
"{name}:{}: `{word}` is not a rustdoc code attribute, so this \
|
||||
block is silently not tested",
|
||||
fence.line
|
||||
));
|
||||
}
|
||||
}
|
||||
} else if !KNOWN_LANGS.contains(&head) {
|
||||
issues.push(format!(
|
||||
"{name}:{}: unrecognized language `{head}`; add it to KNOWN_LANGS \
|
||||
in tests/docs.rs if that is intended",
|
||||
fence.line
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
issues.is_empty(),
|
||||
"{} problem(s) in included markdown:\n{}",
|
||||
issues.len(),
|
||||
issues
|
||||
.iter()
|
||||
.map(|i| format!(" - {i}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_guide_includes_every_markdown_file_under_docs() {
|
||||
// A page nobody includes is a page nobody reads. This catches a new file in
|
||||
// docs/ that was never wired into the module tree.
|
||||
let root = root();
|
||||
let guide = std::fs::read_to_string(root.join("src/guide.rs")).expect("src/guide.rs exists");
|
||||
let lib = std::fs::read_to_string(root.join("src/lib.rs")).expect("src/lib.rs exists");
|
||||
let included = format!("{guide}{lib}");
|
||||
|
||||
let mut files = Vec::new();
|
||||
markdown_files(&root.join("docs"), &mut files);
|
||||
|
||||
let orphans: Vec<String> = files
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let name = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
!included.contains(&name)
|
||||
})
|
||||
.map(|path| {
|
||||
path.strip_prefix(&root)
|
||||
.unwrap_or(path)
|
||||
.display()
|
||||
.to_string()
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
orphans.is_empty(),
|
||||
"these files are not included by any doc attribute:\n{}\nAdd a doc-only module \
|
||||
in src/guide.rs, or move the file out of docs/.",
|
||||
orphans
|
||||
.iter()
|
||||
.map(|o| format!(" - {o}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fence_scanning_handles_nesting_and_tags() {
|
||||
let (fences, unclosed) = opening_fences("```yaml\na: 1\n```\n");
|
||||
assert_eq!(fences.len(), 1);
|
||||
assert_eq!(fences[0].info, "yaml");
|
||||
assert!(unclosed.is_none());
|
||||
|
||||
// A ``` inside a ```` block is content, not a fence.
|
||||
let (fences, unclosed) = opening_fences("````yaml\nbody: |\n ```\n table\n ```\n````\n");
|
||||
assert_eq!(fences.len(), 1, "the inner fence should not open a block");
|
||||
assert!(unclosed.is_none());
|
||||
|
||||
let (_, unclosed) = opening_fences("```text\nno end\n");
|
||||
assert!(unclosed.is_some());
|
||||
}
|
||||
Reference in New Issue
Block a user