//! 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) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; let mut paths: Vec = 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>, Option) { let mut found = Vec::new(); let mut open: Option = 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 = 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::>() .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 = 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::>() .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()); }