221 lines
7.5 KiB
Rust
221 lines
7.5 KiB
Rust
//! 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());
|
|
}
|