#[path = "rustdoc_link_scan/mod.rs"]
mod scan;
use scan::strict::{
broken_links, code_identifiers, fence_marker, html_tags, prose_lines, stray_html_tags,
};
use scan::{
analyze, doc_blocks, index_declarations, library_sources, links_in_block, parse_item, resolve,
Carrier, Decl, DocBlock, Verdict, UNRESOLVED,
};
use std::collections::{BTreeMap, BTreeSet};
#[test]
fn no_public_doc_comment_links_to_a_private_item() {
let sources = library_sources();
let (findings, _) = analyze(&sources);
if findings.is_empty() {
return;
}
let mut report = String::new();
for finding in &findings {
report.push_str(&format!(
"\n {}:{}\n link [`{}`]\n target {}:{} visibility {}\n carrier {}:{} {}\n",
finding.file,
finding.line,
finding.link,
finding.target_file,
finding.target_line,
if finding.target_vis.is_empty() {
"(none)"
} else {
&finding.target_vis
},
finding.file,
finding.carrier_line,
finding.carrier,
));
}
panic!(
"{} public doc comment(s) link to a private item. `cargo doc --no-deps` \
exits 101 on these, and `cargo test` / `cargo clippy -- -D warnings` do \
not see them — that blindness is why this gate exists.\n{report}\n\
Fix by ONE of:\n \
a) drop the brackets and write the path as a plain code span, when the \
target is an internal hook with no public page to point at;\n \
b) make the target `pub`, if it really belongs to the public surface.\n\
`pub(crate)` does NOT fix this: rustdoc treats it as private.",
findings.len()
);
}
#[test]
fn every_qualified_link_resolves_or_is_declared_unreachable() {
let sources = library_sources();
let (_, unresolved) = analyze(&sources);
let allowed: BTreeSet<&str> = UNRESOLVED.iter().map(|(link, _)| *link).collect();
let orphans: Vec<String> = unresolved
.iter()
.filter(|(_, link)| !allowed.contains(link.as_str()))
.map(|(file, link)| format!("{file}: {link}"))
.collect();
assert!(
orphans.is_empty(),
"the declaration index cannot reach {} `crate::`-qualified link target(s). \
This is the blindness guard: when the item parser stops recognising a \
declaration form, the index empties and links stop resolving in bulk \
rather than the gate quietly passing by not looking. Either fix the \
parser or name the form in `UNRESOLVED` with a reason.\n{}",
orphans.len(),
orphans.join("\n")
);
}
#[test]
fn the_walker_accounts_for_every_doc_line() {
for (file, source) in library_sources() {
let total = source
.lines()
.filter(|line| {
let t = line.trim_start();
t.starts_with("///") || t.starts_with("//!")
})
.count();
let assigned: usize = doc_blocks(&source, true)
.iter()
.map(|block| block.lines.len())
.sum();
assert_eq!(
assigned, total,
"{file}: the block walker accounted for {assigned} of {total} doc \
lines. A dropped or merged block makes every assertion above pass \
by not looking at the text."
);
}
}
#[test]
fn the_unresolved_allowlist_stays_honest() {
let sources = library_sources();
let corpus: String = sources
.iter()
.map(|(_, text)| text.as_str())
.collect::<Vec<_>>()
.join("\n");
for (link, reason) in UNRESOLVED {
assert!(
corpus.contains(link),
"`{link}` is allowlisted as unreachable but no longer appears in \
`src/`. Remove the entry."
);
assert!(
reason.len() > 20,
"`{link}` is allowlisted without naming WHICH declaration form the \
index cannot see. Reason given: {reason:?}"
);
}
}
#[test]
fn no_doc_link_names_an_item_this_crate_does_not_contain() {
let sources = library_sources();
let broken = broken_links(&sources);
assert!(
broken.is_empty(),
"{} doc link(s) end in a segment that appears nowhere in `src/` outside \
comments. No scoping rule can resolve a name the crate does not \
contain, so each of these is a typo that `cargo doc --no-deps` reports \
as `broken_intra_doc_links` and `cargo test` never mentions.\n{}",
broken.len(),
broken
.iter()
.map(|b| format!(
" {}:{} [`{}`] — `{}` is not declared or used anywhere",
b.file, b.line, b.link, b.leaf
))
.collect::<Vec<_>>()
.join("\n")
);
}
#[test]
fn no_doc_comment_leaves_a_raw_html_tag_in_prose() {
let sources = library_sources();
let stray = stray_html_tags(&sources);
assert!(
stray.is_empty(),
"{} raw HTML tag(s) sit in doc prose. rustdoc parses doc comments as \
Markdown, so `Vec<String>` written outside a code span becomes an \
unclosed HTML tag and `invalid_html_tags` denies it. Wrap the type in \
backticks.\n{}",
stray.len(),
stray
.iter()
.map(|t| format!(" {}:{} {} — {}", t.file, t.line, t.tag, t.problem))
.collect::<Vec<_>>()
.join("\n")
);
}
const STRICT_FIXTURE: &str = "\
//! Module prose mentioning a Vec<String> outside backticks.
//! A legitimate `Vec<String>` and a `<T>` inside code spans stay quiet,
//! as does the autolink <https://example.com> and <a@example.com>.
/// Sibling of [`present`] and of [`Self::absent_everywhere`].
///
/// The line break inside a code span is load-bearing: `--flag
/// <SECONDS>` closes on the next line and is not a tag.
///
/// ```
/// let _: Vec<String> = Vec::new();
/// ```
pub fn present() {}
";
#[test]
fn the_strict_detectors_find_what_they_claim_and_nothing_else() {
let fixture = sources(&[("src/lib.rs", STRICT_FIXTURE)]);
let broken = broken_links(&fixture);
assert_eq!(broken.len(), 1, "expected one broken link; got {broken:?}");
assert_eq!(broken[0].leaf, "absent_everywhere");
assert_eq!(broken[0].link, "Self::absent_everywhere");
assert!(
!broken.iter().any(|b| b.leaf == "present"),
"a link to a name the crate declares must stay silent"
);
let stray = stray_html_tags(&fixture);
assert_eq!(stray.len(), 1, "expected one stray tag; got {stray:?}");
assert_eq!(stray[0].tag, "<String>");
assert_eq!(stray[0].line, 1);
let unopened = sources(&[("src/lib.rs", "//! Text with a </div> and no opener.\n")]);
let stray = stray_html_tags(&unopened);
assert_eq!(stray.len(), 1);
assert_eq!(stray[0].tag, "</div>");
let valid = sources(&[(
"src/lib.rs",
"//! Text with <em>emphasis</em>, a <br> and a <span/>.\n",
)]);
assert!(stray_html_tags(&valid).is_empty());
let corpus = code_identifiers(&fixture);
assert!(corpus.contains("present"));
assert!(!corpus.contains("absent_everywhere"));
let spanning = vec![
(1, " `--wait-job-singleton".to_string()),
(2, " <SECONDS>` to poll until the lock drops.".to_string()),
];
let prose = prose_lines(&spanning);
assert!(
html_tags(&prose[1].1).is_empty(),
"a code span that opened on the previous line must still be a code span"
);
assert_eq!(fence_marker("```rust"), Some('`'));
assert_eq!(fence_marker("~~~"), Some('~'));
assert_eq!(fence_marker("`inline`"), None);
}
const ROOT_MODULES: &str = "pub mod cli;\npub mod paths;\n";
const CLI_MODULES: &str = "pub mod commands;\npub mod globals;\n";
const DEFECT_TARGET: &str = "\
pub struct Cli {}
impl Cli {
fn install_write_policy(&self) {
let _ = self;
}
}
";
const DEFECT_HOMONYM: &str = "\
pub fn install_write_policy(policy: WritePolicy) {
let _ = policy;
}
";
const DEFECT_CARRIER: &str = "\
pub enum Commands {}
impl Commands {
/// The conjunction is not a new list: [`crate::cli::Cli::install_write_policy`]
/// already asks precisely this question.
pub fn persists(&self) -> bool {
true
}
}
";
fn sources(parts: &[(&str, &str)]) -> Vec<(String, String)> {
parts
.iter()
.map(|(name, body)| ((*name).to_string(), (*body).to_string()))
.collect()
}
#[test]
fn the_gate_detects_what_it_claims_to_detect() {
let shipped = sources(&[
("src/lib.rs", ROOT_MODULES),
("src/cli/mod.rs", CLI_MODULES),
("src/cli/globals.rs", DEFECT_TARGET),
("src/paths.rs", DEFECT_HOMONYM),
("src/cli/commands.rs", DEFECT_CARRIER),
]);
let (findings, _) = analyze(&shipped);
assert_eq!(
findings.len(),
1,
"the gate must flag the v1.2.8 defect; got {findings:?}"
);
assert_eq!(findings[0].target_file, "src/cli/globals.rs");
assert_eq!(findings[0].target_vis, "");
let mut decls = Vec::new();
let mut modules = BTreeSet::new();
index_declarations(DEFECT_HOMONYM, "src/paths.rs", &mut decls, &mut modules);
let mut homonym_only: BTreeMap<(Option<String>, String), Vec<Decl>> = BTreeMap::new();
for decl in decls {
homonym_only
.entry((decl.owner.clone(), decl.name.clone()))
.or_default()
.push(decl);
}
assert_eq!(
resolve(&homonym_only, "crate::paths::install_write_policy"),
Verdict::Public,
"the free public homonym must resolve on its own path"
);
assert_eq!(
resolve(&homonym_only, "crate::cli::Cli::install_write_policy"),
Verdict::Unresolved,
"the owner segment must stop the public homonym from answering for the \
private method; without it the gate is blind to the shipped defect"
);
let fixed = sources(&[
("src/lib.rs", ROOT_MODULES),
("src/cli/mod.rs", CLI_MODULES),
(
"src/cli/globals.rs",
"pub struct Cli {}\nimpl Cli {\n pub fn install_write_policy(&self) {}\n}\n",
),
("src/cli/commands.rs", DEFECT_CARRIER),
]);
assert!(
analyze(&fixed).0.is_empty(),
"a bare `pub` target is the real fix and must clear the finding"
);
let crate_visible = sources(&[
("src/lib.rs", ROOT_MODULES),
("src/cli/mod.rs", CLI_MODULES),
(
"src/cli/globals.rs",
"pub struct Cli {}\nimpl Cli {\n pub(crate) fn install_write_policy(&self) {}\n}\n",
),
("src/cli/commands.rs", DEFECT_CARRIER),
]);
assert_eq!(
analyze(&crate_visible).0.len(),
1,
"`pub(crate)` must still be reported, or the gate cures one symptom \
instead of the class"
);
let private_carrier = sources(&[
("src/lib.rs", ROOT_MODULES),
("src/cli/mod.rs", CLI_MODULES),
("src/cli/globals.rs", DEFECT_TARGET),
(
"src/cli/commands.rs",
"pub enum Commands {}\nimpl Commands {\n /// [`crate::cli::Cli::install_write_policy`]\n fn persists(&self) -> bool { true }\n}\n",
),
]);
assert!(analyze(&private_carrier).0.is_empty());
let (_, keyword, name) = parse_item(" pub const fn as_str(&self) -> &str {").unwrap();
assert_eq!((keyword, name.as_str()), ("fn", "as_str"));
let (_, keyword, name) = parse_item("pub const MAX: usize = 8;").unwrap();
assert_eq!((keyword, name.as_str()), ("const", "MAX"));
let block = DocBlock {
lines: vec![
(1, " [`resolve_projection`] does the work.".to_string()),
(2, " [`resolve_projection`]: super::gate".to_string()),
],
carrier: Carrier::Private,
};
assert!(
links_in_block(&block).is_empty(),
"a shortcut whose destination is defined in the same block is not a \
path of its own"
);
assert_eq!(resolve(&BTreeMap::new(), "format"), Verdict::Skipped);
assert_eq!(resolve(&BTreeMap::new(), "Self::mutates"), Verdict::Skipped);
assert_eq!(
resolve(&BTreeMap::new(), "std::fmt::Debug"),
Verdict::Skipped
);
}
#[test]
fn enum_variants_inherit_the_enum_visibility() {
let mut decls = Vec::new();
let mut modules = BTreeSet::new();
index_declarations(
"pub enum AppError {\n Usage { message: String },\n Timeout,\n}\n",
"src/errors.rs",
&mut decls,
&mut modules,
);
let usage = decls
.iter()
.find(|d| d.name == "Usage")
.expect("a variant must be indexed, or every link through it goes unresolved");
assert_eq!(usage.owner.as_deref(), Some("AppError"));
assert!(usage.is_public);
}
#[cfg(feature = "slow-tests")]
#[test]
fn cargo_doc_agrees_with_the_static_scan() {
use std::path::PathBuf;
use std::process::Command;
let repo = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let isolated = repo.join("target").join("rustdoc-gate");
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string());
let out = Command::new(cargo)
.args(["doc", "--no-deps", "--all-features"])
.current_dir(&repo)
.env("CARGO_TARGET_DIR", &isolated)
.output()
.expect(
"could not run `cargo doc`. The toolchain that runs this test is the \
one that must document the crate; a host without it is not running \
the toolchain `rust-toolchain.toml` pins.",
);
if out.status.success() {
return;
}
panic!(
"`cargo doc --no-deps` failed. It enforces all three lints \
`[lints.rustdoc]` denies, and it is the ONLY thing that enforces \
`broken_intra_doc_links` and `invalid_html_tags` — the static scan in \
this file covers `private_intra_doc_links` alone.\n\
--- stdout ---\n{}\n--- stderr ---\n{}",
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
);
}