Files
alexm abc0bdf621
Sync README to GitHub / sync (push) Successful in 12s
CI / check (push) Successful in 8m31s
Deploy docs / deploy (push) Successful in 6m44s
Nightly / nightly (push) Successful in 10m33s
Dev (#1)
Reviewed-on: #1
2026-08-07 15:48:11 -04:00

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());
}