diff --git a/Cargo.lock b/Cargo.lock index 2eb3cb9..863fb82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -389,6 +389,7 @@ dependencies = [ "serde_json", "sha2", "similar", + "syn 2.0.119", "tar", "tempfile", "wait-timeout", diff --git a/crates/mirth-lab/Cargo.toml b/crates/mirth-lab/Cargo.toml index 1c00609..fd14e1b 100644 --- a/crates/mirth-lab/Cargo.toml +++ b/crates/mirth-lab/Cargo.toml @@ -19,6 +19,7 @@ regex = "1" serde = { version = "1", features = ["derive"] } serde_json = "1" similar = "2" +syn = { version = "2.0.119", features = ["full"] } sha2 = "0.10" tempfile = "3" walkdir = "2" diff --git a/crates/mirth-lab/src/main.rs b/crates/mirth-lab/src/main.rs index aabd4c0..4041d28 100644 --- a/crates/mirth-lab/src/main.rs +++ b/crates/mirth-lab/src/main.rs @@ -34,6 +34,7 @@ mod tools { pub mod replay; pub mod repro_diff; pub mod rewrite_diff; + pub mod rustdoc_diff; pub mod scale_check; pub mod solver_diff; pub mod suggest_diff; @@ -129,6 +130,8 @@ enum Check { SurveyUnroll(tools::survey::UnrollArgs), /// Each closed-bug Ur query still finds the code its bug's fix changed. VerifyClosed(tools::verify_closed::Args), + /// rustdoc agrees with rustc: accepts what it accepts, consistent JSON, re-exports, auto-trait bounds. + RustdocDiff(tools::rustdoc_diff::Args), } fn main() -> ExitCode { @@ -172,6 +175,7 @@ fn main() -> ExitCode { Check::SurveyFetch(a) => tools::survey::fetch(a), Check::SurveyUnroll(a) => tools::survey::unroll(a), Check::VerifyClosed(a) => tools::verify_closed::run(a), + Check::RustdocDiff(a) => tools::rustdoc_diff::run(a), }; match result { Ok(code) => code, diff --git a/crates/mirth-lab/src/tools/rustdoc_diff.rs b/crates/mirth-lab/src/tools/rustdoc_diff.rs new file mode 100644 index 0000000..dfb1c8e --- /dev/null +++ b/crates/mirth-lab/src/tools/rustdoc_diff.rs @@ -0,0 +1,813 @@ +//! rustdoc consistency: rustdoc is a second front end over the same compiler, so it must agree +//! with rustc about the programs both read (check 6 of docs/checks.md). +//! +//! For each standalone UI test: +//! +//! - ice: rustdoc panics, on any test (also on programs rustc rejects). +//! - rejects: rustc accepts the test (`--emit=metadata`) and rustdoc reports an error, in HTML, +//! in HTML with `--document-private-items`, or in JSON (`--output-format json`). rustdoc +//! accepting what rustc rejects is expected: it does not type-check function bodies. +//! - json: the JSON does not hold together: the root, an id a module, struct, enum, trait or impl +//! lists, or an intra-doc link names, is missing from the index; an `id` in a type or a `use` +//! is in neither the index nor `paths`. +//! - reexport: a `pub use` at the crate root (not glob, not `_`, not `#[doc(hidden)]`) is in +//! neither form in the root module of the public JSON: as a `use` item of that name, or the +//! item itself inlined under that name. +//! - auto-trait: for each auto-trait impl rustdoc synthesizes (Send, Sync, Unpin, UnwindSafe, +//! RefUnwindSafe), a probe appended to the test requires the trait for the type under exactly +//! the bounds rustdoc shows; it must compile (#162274). For a negative impl the probe must +//! fail with E0277. A probe that fails for another reason (a type that cannot be named from +//! the crate root, a `#![forbid]`) is a note. + +use std::collections::{BTreeSet, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; +use std::sync::LazyLock; +use std::time::Duration; + +use mirth_lab::driver::{self, Record, Sweep}; +use mirth_lab::rustc::{self, Compile, Exit, Status, is_ice, run_command}; +use mirth_lab::uitest::{self, Kind, Test}; +use regex::Regex; +use serde::Serialize; +use serde_json::Value; + +#[derive(clap::Args, Debug)] +pub struct Args { + /// The toolchain whose rustc and rustdoc to compare (they must be the same commit). + #[arg(long, default_value = "nightly-2026-10-06")] + toolchain: String, + #[command(flatten)] + sweep: Sweep, +} + +/// Tests whose flags make no sense to rustdoc, or ask rustc for something rustdoc does not do. +/// Matched against the flags joined by spaces (`-Z x` is two tokens). `-Zparse-crate-root-only` +/// stops rustc after parsing, so its "accepts" means "parses". +static SKIP_FLAG: LazyLock = LazyLock::new(|| { + Regex::new(r"(^| )(--test|--print|-Z ?unpretty|--emit|-o( |$)|--out-dir|-Z ?no-codegen|-Z ?parse-crate-root-only|-Z ?parse-only)").unwrap() +}); +/// Flags rustdoc does not take; left out (the outcome cannot depend on them). +static DROP_FLAG: LazyLock = LazyLock::new(|| Regex::new(r"^(-O|-g)$").unwrap()); +static CRATE_TYPE: LazyLock = LazyLock::new(|| Regex::new(r"crate_type|crate-type").unwrap()); +static CRATE_TYPE_ATTR: LazyLock = LazyLock::new(|| Regex::new(r#"(?m)^\s*#!\[crate_type\s*=\s*"([a-z-]+)"\]"#).unwrap()); +static UNKNOWN_OPTION: LazyLock = LazyLock::new(|| Regex::new(r"(?i)unrecognized option|unknown codegen option|unknown unstable option|requires an argument").unwrap()); + +/// Expected differences between rustdoc and rustc, by the error's code or lint, with why. +const NOISE: &[(&str, &str)] = &[ + // rustdoc's own lints (broken intra-doc links, invalid HTML) under the test's + // `#![deny(warnings)]`: rustc does not run them. + ("rustdoc::", "a rustdoc lint denied by the test"), +]; + +/// rustdoc ICEs already reported upstream: a line of the panic, and the issue. +const KNOWN_ICE: &[(&str, &str)] = &[ + ("cx.impl_trait_bounds.is_empty()", "rust-lang/rust#155728 (fn_delegation)"), + // `UnsafeBinder(_) => unimplemented!()` under `FIXME(unsafe_binder): Implement rustdoc-json`. + ("json/conversions.rs:706:32", "FIXME(unsafe_binder) in rustdoc JSON"), +]; + +const AUTO_TRAITS: &[&str] = &["Send", "Sync", "Unpin", "UnwindSafe", "RefUnwindSafe"]; + +#[derive(Serialize)] +struct Rec { + test: String, + #[serde(skip_serializing_if = "Option::is_none")] + skip: Option, + rustc: Option, + found: Vec, + notes: Vec, + probes: usize, +} + +impl Record for Rec { + fn findings(&self) -> Vec { + self.found.clone() + } + fn test(&self) -> &str { + &self.test + } +} + +struct Tools { + rustc: PathBuf, + rustdoc: PathBuf, +} + +struct Doc { + status: Status, + stderr: String, + json: Option, +} + +fn rustdoc(tools: &Tools, test: &Test, flags: &[String], out: &Path, extra: &[&str]) -> Doc { + let mut cmd = Command::new(&tools.rustdoc); + cmd.arg(&test.path) + .args(["--edition", test.edition(), "-Zunstable-options", "--error-format=json", "-Ainternal_features", "-Aincomplete_features"]) + .arg("-o") + .arg(out) + .args(flags) + .args(extra) + .current_dir(out.parent().unwrap_or(out)) + .env("RUSTC_BOOTSTRAP", "1") + .env("RUST_BACKTRACE", "0"); + let done = match run_command(cmd, Duration::from_secs(120)) { + Ok(d) => d, + Err(e) => return Doc { status: Status::Error, stderr: e.to_string(), json: None }, + }; + let stderr = done.stderr_text(); + let status = match done.exit { + Exit::Timeout => Status::Timeout, + _ if is_ice(&stderr) => Status::Ice, + Exit::Code(0) => Status::Ok, + _ => Status::Error, + }; + let json = (status == Status::Ok && extra.contains(&"json")) + .then(|| { + let name = std::fs::read_dir(out).ok()?.flatten().map(|e| e.path()).find(|p| p.extension().is_some_and(|x| x == "json"))?; + serde_json::from_slice(&std::fs::read(name).ok()?).ok() + }) + .flatten(); + Doc { status, stderr, json } +} + +/// The errors' codes or lint names, for the noise list. +fn error_names(stderr: &str) -> Vec { + rustc::diagnostics(stderr) + .iter() + .filter(|d| d.level == "error" && !d.message.starts_with("aborting due to")) + .map(|d| { + if !d.code().is_empty() { + return d.code().to_owned(); + } + // A denied lint names itself in a child note: "`#[deny(rustdoc::x)]` implied by ...". + d.children.iter().find_map(|c| c.message.split('`').nth(1).map(|s| s.trim_start_matches("#[deny(").trim_end_matches(")]").to_owned())).unwrap_or_else(|| d.message.chars().take(80).collect()) + }) + .collect() +} + +fn noise(names: &[String]) -> Option<&'static str> { + if names.is_empty() { + return None; + } + NOISE.iter().find(|(pat, _)| names.iter().all(|n| n.contains(pat))).map(|(_, why)| *why) +} + +// ---- JSON self-consistency ---- + +fn id_of(v: &Value) -> Option { + match v { + Value::Number(n) => Some(n.to_string()), + Value::String(s) => Some(s.clone()), + _ => None, + } +} + +/// Ids a container lists, which must be items of the index. +const LISTS: &[&str] = &["items", "fields", "variants", "impls", "implementations", "tuple"]; + +fn walk_ids(v: &Value, key: &str, index: &serde_json::Map, paths: &serde_json::Map, out: &mut BTreeSet) { + match v { + Value::Object(m) => { + for (k, x) in m { + if k == "id" + && let Some(id) = id_of(x) + && !index.contains_key(&id) + && !paths.contains_key(&id) + { + out.insert(format!("id {id} (in {key}) in neither index nor paths")); + } + walk_ids(x, k, index, paths, out); + } + } + Value::Array(a) => { + for x in a { + if LISTS.contains(&key) + && let Some(id) = id_of(x) + && !index.contains_key(&id) + { + out.insert(format!("{key} lists id {id}, not in the index")); + } + walk_ids(x, key, index, paths, out); + } + } + _ => {} + } +} + +/// The items reachable from the root through any id the index holds (jsondoclint's walk). +fn reachable(doc: &Value) -> BTreeSet { + fn ids(v: &Value, out: &mut Vec) { + match v { + Value::Object(m) => m.iter().for_each(|(k, x)| { + if k == "id" + && let Some(id) = id_of(x) + { + out.push(id); + } + ids(x, out) + }), + Value::Array(a) => a.iter().for_each(|x| { + if let Some(id) = id_of(x) { + out.push(id); + } + ids(x, out) + }), + _ => {} + } + } + let index = &doc["index"]; + let mut seen = BTreeSet::new(); + let mut todo: Vec = id_of(&doc["root"]).into_iter().collect(); + while let Some(id) = todo.pop() { + if index.get(&id).is_none() || !seen.insert(id.clone()) { + continue; + } + let mut next = Vec::new(); + ids(&index[&id]["inner"], &mut next); + if let Some(links) = index[&id]["links"].as_object() { + next.extend(links.values().filter_map(id_of)); + } + todo.extend(next); + } + seen +} + +/// Problems in the items reachable from the root, and how many unreachable items have them. +fn consistency(doc: &Value) -> (Vec, usize) { + let empty = serde_json::Map::new(); + let index = doc["index"].as_object().unwrap_or(&empty); + let paths = doc["paths"].as_object().unwrap_or(&empty); + let live = reachable(doc); + let mut out = BTreeSet::new(); + let mut orphans = 0; + if let Some(root) = id_of(&doc["root"]) + && !index.contains_key(&root) + { + out.insert(format!("root {root} not in the index")); + } + for (id, item) in index { + let mut problems = BTreeSet::new(); + walk_ids(&item["inner"], "inner", index, paths, &mut problems); + if let Some(links) = item["links"].as_object() { + for (text, target) in links { + if let Some(t) = id_of(target) + && !index.contains_key(&t) + && !paths.contains_key(&t) + { + problems.insert(format!("link {text:?} -> {t} in neither index nor paths")); + } + } + } + if live.contains(id) { + out.extend(problems); + } else if !problems.is_empty() { + orphans += 1; + } + } + (out.into_iter().collect(), orphans) +} + +// ---- re-exports ---- + +/// `#[doc(hidden)]`, or conditional: rustdoc may rightly leave it out. +fn doc_hidden(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|a| match &a.meta { + syn::Meta::List(l) if a.path().is_ident("doc") => l.tokens.to_string().contains("hidden"), + _ => a.path().is_ident("cfg") || a.path().is_ident("cfg_attr"), + }) +} + +fn use_names(tree: &syn::UseTree, out: &mut Vec) { + match tree { + syn::UseTree::Path(p) => use_names(&p.tree, out), + syn::UseTree::Name(n) if n.ident != "self" => out.push(n.ident.to_string()), + syn::UseTree::Rename(r) if r.rename != "_" => out.push(r.rename.to_string()), + syn::UseTree::Group(g) => g.items.iter().for_each(|t| use_names(t, out)), + _ => {} + } +} + +/// Names the crate root re-exports publicly, from the source. +fn root_reexports(text: &str) -> Vec { + let Ok(file) = syn::parse_file(text) else { return Vec::new() }; + let mut out = Vec::new(); + for item in &file.items { + if let syn::Item::Use(u) = item + && matches!(u.vis, syn::Visibility::Public(_)) + && !doc_hidden(&u.attrs) + { + use_names(&u.tree, &mut out); + } + } + out +} + +fn reexport_missing(doc: &Value, names: &[String]) -> Vec { + let root = id_of(&doc["root"]).unwrap_or_default(); + let index = &doc["index"]; + let present: HashSet = index[&root]["inner"]["module"]["items"] + .as_array() + .into_iter() + .flatten() + .filter_map(|id| index.get(id_of(id)?)) + .filter_map(|item| item["inner"]["use"]["name"].as_str().or_else(|| item["name"].as_str()).map(str::to_owned)) + .collect(); + names.iter().filter(|n| !present.contains(*n)).map(|n| format!("`pub use … {n}` not in the root module")).collect() +} + +// ---- auto-trait probes ---- + +struct Render<'a> { + paths: &'a Value, + ok: bool, +} + +impl Render<'_> { + fn path(&mut self, p: &Value) -> String { + let id = id_of(&p["id"]).unwrap_or_default(); + let base = match self.paths[&id]["path"].as_array() { + Some(segs) => { + let segs: Vec<&str> = segs.iter().filter_map(Value::as_str).collect(); + let crate_id = self.paths[&id]["crate_id"].as_u64().unwrap_or(1); + match (crate_id, segs.split_first()) { + (0, Some((_, rest))) => format!("crate::{}", rest.join("::")), + (_, Some((_, rest))) if rest.last().is_some_and(|l| AUTO_TRAITS.contains(l) || *l == "Sized") => { + let l = rest.last().unwrap(); + if l.ends_with("UnwindSafe") { format!("std::panic::{l}") } else { format!("std::marker::{l}") } + } + (_, Some((krate, rest))) if matches!(*krate, "core" | "alloc" | "std") => { + // The canonical path can go through private modules (core::ops::function): + // std re-exports nearly everything at its second level. + match rest { + [m, .., last] if rest.len() > 2 => format!("std::{m}::{last}"), + _ => format!("std::{}", rest.join("::")), + } + } + (_, Some((krate, rest))) => format!("::{krate}::{}", rest.join("::")), + _ => { + self.ok = false; + String::new() + } + } + } + None => p["path"].as_str().unwrap_or("").to_owned(), + }; + format!("{base}{}", self.args(&p["args"])) + } + + fn args(&mut self, a: &Value) -> String { + if let Some(ab) = a.get("angle_bracketed") { + let mut parts: Vec = ab["args"] + .as_array() + .into_iter() + .flatten() + .map(|g| { + if let Some(l) = g.get("lifetime") { + l.as_str().unwrap_or("'_").to_owned() + } else if let Some(t) = g.get("type") { + self.ty(t) + } else if let Some(c) = g.get("const") { + let e = c["expr"].as_str().unwrap_or("_"); + if e.contains(|ch: char| !ch.is_alphanumeric() && ch != '_') { format!("{{ {e} }}") } else { e.to_owned() } + } else { + "_".into() + } + }) + .collect(); + for c in ab["constraints"].as_array().into_iter().flatten() { + let name = format!("{}{}", c["name"].as_str().unwrap_or(""), self.args(&c["args"])); + if let Some(t) = c["binding"].get("equality").and_then(|e| e.get("type")) { + parts.push(format!("{name} = {}", self.ty(t))); + } else if let Some(bs) = c["binding"].get("constraint") { + parts.push(format!("{name}: {}", self.bounds(bs))); + } else { + self.ok = false; + } + } + if parts.is_empty() { String::new() } else { format!("<{}>", parts.join(", ")) } + } else if let Some(p) = a.get("parenthesized") { + let inputs: Vec = p["inputs"].as_array().into_iter().flatten().map(|t| self.ty(t)).collect(); + let out = if p["output"].is_null() { String::new() } else { format!(" -> {}", self.ty(&p["output"])) }; + format!("({}){out}", inputs.join(", ")) + } else if a.is_null() { + String::new() + } else { + self.ok = false; + String::new() + } + } + + fn bound(&mut self, b: &Value) -> String { + if let Some(t) = b.get("trait_bound") { + let hr = self.binder(&t["generic_params"]); + let m = match t["modifier"].as_str() { + Some("maybe") => "?", + Some("none") | None => "", + _ => { + self.ok = false; + "" + } + }; + format!("{hr}{m}{}", self.path(&t["trait"])) + } else if let Some(l) = b.get("outlives") { + l.as_str().unwrap_or("'_").to_owned() + } else { + self.ok = false; + String::new() + } + } + + fn bounds(&mut self, bs: &Value) -> String { + let v: Vec = bs.as_array().into_iter().flatten().map(|b| self.bound(b)).collect(); + v.join(" + ") + } + + fn binder(&mut self, params: &Value) -> String { + let ps: Vec = params.as_array().into_iter().flatten().filter_map(|p| p["name"].as_str().map(str::to_owned)).collect(); + if ps.is_empty() { String::new() } else { format!("for<{}> ", ps.join(", ")) } + } + + fn ty(&mut self, t: &Value) -> String { + if let Some(p) = t.get("resolved_path") { + return self.path(p); + } + if let Some(g) = t.get("generic").and_then(Value::as_str) { + return g.to_owned(); + } + if let Some(p) = t.get("primitive").and_then(Value::as_str) { + return if p == "never" { "!".into() } else { p.to_owned() }; + } + if let Some(ts) = t.get("tuple").and_then(Value::as_array) { + let v: Vec = ts.iter().map(|x| self.ty(x)).collect(); + return if v.len() == 1 { format!("({},)", v[0]) } else { format!("({})", v.join(", ")) }; + } + if let Some(s) = t.get("slice") { + return format!("[{}]", self.ty(s)); + } + if let Some(a) = t.get("array") { + let len = a["len"].as_str().unwrap_or("_"); + let len = if len.contains(|c: char| !c.is_alphanumeric() && c != '_') { format!("{{ {len} }}") } else { len.to_owned() }; + return format!("[{}; {len}]", self.ty(&a["type"])); + } + if let Some(r) = t.get("borrowed_ref") { + let l = r["lifetime"].as_str().map(|l| format!("{l} ")).unwrap_or_default(); + let m = if r["is_mutable"].as_bool() == Some(true) { "mut " } else { "" }; + return format!("&{l}{m}{}", self.ty(&r["type"])); + } + if let Some(r) = t.get("raw_pointer") { + let m = if r["is_mutable"].as_bool() == Some(true) { "mut" } else { "const" }; + return format!("*{m} {}", self.ty(&r["type"])); + } + if let Some(d) = t.get("dyn_trait") { + let mut parts: Vec = d["traits"] + .as_array() + .into_iter() + .flatten() + .map(|p| { + let hr = self.binder(&p["generic_params"]); + format!("{hr}{}", self.path(&p["trait"])) + }) + .collect(); + if let Some(l) = d["lifetime"].as_str() { + parts.push(l.to_owned()); + } + return format!("dyn {}", parts.join(" + ")); + } + if let Some(q) = t.get("qualified_path") { + let st = self.ty(&q["self_type"]); + let name = format!("{}{}", q["name"].as_str().unwrap_or(""), self.args(&q["args"])); + return if q["trait"].is_null() { format!("{st}::{name}") } else { format!("<{st} as {}>::{name}", self.path(&q["trait"])) }; + } + if let Some(f) = t.get("function_pointer") { + let hr = self.binder(&f["generic_params"]); + let unsafety = if f["header"]["is_unsafe"].as_bool() == Some(true) { "unsafe " } else { "" }; + let abi = match &f["header"]["abi"] { + Value::String(s) if s == "Rust" => String::new(), + Value::Object(m) => m.keys().next().map(|k| format!("extern {k:?} ")).unwrap_or_default(), + _ => String::new(), + }; + let inputs: Vec = f["sig"]["inputs"].as_array().into_iter().flatten().map(|p| self.ty(&p[1])).collect(); + let out = if f["sig"]["output"].is_null() { String::new() } else { format!(" -> {}", self.ty(&f["sig"]["output"])) }; + return format!("{hr}{unsafety}{abi}fn({}){out}", inputs.join(", ")); + } + // impl Trait, inferred, patterns: not nameable in a probe. + self.ok = false; + "_".into() + } + + fn generics(&mut self, g: &Value) -> (String, Vec) { + let mut params = Vec::new(); + let mut preds = Vec::new(); + for p in g["params"].as_array().into_iter().flatten() { + let name = p["name"].as_str().unwrap_or("_").to_owned(); + let k = &p["kind"]; + if let Some(l) = k.get("lifetime") { + let o: Vec<&str> = l["outlives"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + params.push(if o.is_empty() { name } else { format!("{name}: {}", o.join(" + ")) }); + } else if let Some(t) = k.get("type") { + if t["is_synthetic"].as_bool() == Some(true) { + self.ok = false; + } + let b = self.bounds(&t["bounds"]); + params.push(if b.is_empty() { name } else { format!("{name}: {b}") }); + } else if let Some(c) = k.get("const") { + params.push(format!("const {name}: {}", self.ty(&c["type"]))); + } + } + for w in g["where_predicates"].as_array().into_iter().flatten() { + if let Some(b) = w.get("bound_predicate") { + let hr = self.binder(&b["generic_params"]); + let bs = self.bounds(&b["bounds"]); + if !bs.is_empty() { + preds.push(format!("{hr}{}: {bs}", self.ty(&b["type"]))); + } + } else if let Some(r) = w.get("lifetime_predicate") { + let o: Vec<&str> = r["outlives"].as_array().into_iter().flatten().filter_map(Value::as_str).collect(); + preds.push(format!("{}: {}", r["lifetime"].as_str().unwrap_or("'_"), o.join(" + "))); + } else { + self.ok = false; + } + } + (params.join(", "), preds) + } +} + +struct Probe { + what: String, + negative: bool, + line: usize, + tname: String, +} + +/// Whether an E0277 is about the auto trait itself (its name, or its `on_unimplemented` text), +/// not about a bound the type needs to be named at all. +fn about(message: &str, tname: &str) -> bool { + let phrase = match tname { + "Send" => "cannot be sent between threads safely", + "Sync" => "cannot be shared between threads safely", + "Unpin" => "cannot be unpinned", + "UnwindSafe" => "may not be safely transferred across an unwind boundary", + _ => "may contain interior mutability", + }; + message.contains(phrase) || message.contains(&format!("{tname}`")) || message.contains(&format!("{tname}>")) +} + +/// The probe module starts after the test's last line. +fn in_probe_module(test: &Test, line: usize) -> bool { + line > test.text.lines().count() +} + +fn auto_trait_probes(doc: &Value, base_lines: usize) -> (String, Vec) { + // `use std;` makes `std::` paths work in every edition (2015 paths are module-relative), and + // `dyn std::…` parses where `dyn ::std::…` does not in 2015. It takes no line of its own. + let mut code = String::from("\n#[allow(warnings, clippy::all)]\nmod __mirth_probe { use std;\n"); + // The block starts with an empty line, the attribute and `mod`: the first probe is N + 4. + let mut line = base_lines + 4; + let mut probes = Vec::new(); + let paths = &doc["paths"]; + let mut traits_needed: BTreeSet = BTreeSet::new(); + for item in doc["index"].as_object().into_iter().flat_map(|m| m.values()) { + let Some(imp) = item["inner"].get("impl") else { continue }; + if imp["is_synthetic"].as_bool() != Some(true) || imp["trait"].is_null() { + continue; + } + let tname = imp["trait"]["path"].as_str().unwrap_or(""); + if !AUTO_TRAITS.contains(&tname) { + continue; + } + let mut r = Render { paths, ok: true }; + // The canonical path can go through a private module (core::panic::unwind_safe). + let trait_path = if tname.ends_with("UnwindSafe") { format!("std::panic::{tname}") } else { format!("std::marker::{tname}") }; + let for_ty = r.ty(&imp["for"]); + let (params, mut preds) = r.generics(&imp["generics"]); + // rustdoc leaves the type's own bounds implied; the probe must state them to name it. + let names: HashSet<&str> = imp["generics"]["params"].as_array().into_iter().flatten().filter_map(|p| p["name"].as_str()).collect(); + let own = id_of(&imp["for"]["resolved_path"]["id"]).and_then(|id| doc["index"].get(&id)).and_then(|t| { + let inner = &t["inner"]; + inner.get("struct").or_else(|| inner.get("enum")).or_else(|| inner.get("union")).map(|k| k["generics"].clone()) + }); + if let Some(g) = own { + let ps: Vec<&Value> = g["params"].as_array().into_iter().flatten().collect(); + if ps.iter().all(|p| p["name"].as_str().is_some_and(|n| names.contains(n))) { + for p in &ps { + if let Some(t) = p["kind"].get("type") { + let b = r.bounds(&t["bounds"]); + if !b.is_empty() { + preds.push(format!("{}: {b}", p["name"].as_str().unwrap_or("_"))); + } + } + } + let (_, wh) = r.generics(&serde_json::json!({"params": [], "where_predicates": g["where_predicates"]})); + preds.extend(wh); + } + } + if !r.ok || !for_ty.starts_with("crate::") { + continue; + } + let negative = imp["is_negative"].as_bool() == Some(true); + let n = probes.len(); + let req = format!("__req_{}", tname); + traits_needed.insert(format!(" fn {req}() {{}}\n")); + let wh = if preds.is_empty() { String::new() } else { format!(" where {}", preds.join(", ")) }; + let what = format!("{}impl<{params}> {tname} for {for_ty}{wh}", if negative { "!" } else { "" }); + let tname = tname.to_owned(); + code.push_str(&format!(" fn __p{n}<{params}>(){wh} {{ {req}::<{for_ty}>(); }}\n")); + probes.push(Probe { what, negative, line, tname }); + line += 1; + } + if probes.is_empty() { + return (String::new(), probes); + } + for t in traits_needed { + code.push_str(&t); + } + code.push_str("}\n"); + (code, probes) +} + +/// Compile the test with the probes appended; findings and notes per probe. +fn run_probes(tools: &Tools, test: &Test, dir: &Path, code: &str, probes: &[Probe], found: &mut Vec, notes: &mut Vec) -> Option> { + let src = dir.join(test.file_name()); + let mut text = test.text.clone(); + if !text.ends_with('\n') { + text.push('\n'); + } + text.push_str(code); + std::fs::write(&src, &text).ok()?; + let out = dir.join("probe-out"); + let c = Compile::new(&tools.rustc, &src, &out, &test.flags, test.edition()).emit("metadata").json().timeout(120).run(); + let diags = rustc::diagnostics(&c.stderr); + let mut e0277: Vec<(usize, String)> = Vec::new(); + let mut other: BTreeSet = BTreeSet::new(); + let mut outside = false; + for d in diags.iter().filter(|d| d.level == "error") { + let line = d.primary().map(|s| s.line_start).unwrap_or(0); + if probes.iter().all(|p| p.line != line) { + if d.message.starts_with("aborting") { + continue; + } + if in_probe_module(test, line) { + notes.push(format!("probe module broken: {}", d.message.chars().take(120).collect::())); + return None; + } + outside = true; + continue; + } + if d.code() == "E0277" { + e0277.push((line, d.message.clone())); + } else { + other.insert(line); + } + } + // Any other error (resolution, privacy) can stop rustc before it checks the probes at all: + // then a missing E0277 proves nothing. + if !other.is_empty() && probes.iter().any(|p| p.negative) { + notes.push("probe module broken: no verdict for the negative impls".into()); + return None; + } + if outside || c.status == Status::Ice || c.status == Status::Timeout { + let first = diags.iter().find(|d| d.level == "error").map_or(String::new(), |d| d.message.chars().take(120).collect()); + notes.push(format!("probe compile failed outside the probes ({:?}): {first}", c.status)); + return None; + } + let mut bad = false; + for p in probes { + let mine: Vec<&String> = e0277.iter().filter(|(l, _)| *l == p.line).map(|(_, m)| m).collect(); + let fails = mine.iter().any(|m| about(m, &p.tname)); + if other.contains(&p.line) || (!fails && !mine.is_empty()) { + notes.push(format!("probe broken: {}", p.what)); + } else if p.negative && !fails { + found.push(format!("auto-trait: holds although rustdoc shows {}", p.what)); + bad = true; + } else if !p.negative && fails { + found.push(format!("auto-trait: does not hold under rustdoc's bounds: {}", p.what)); + bad = true; + } + } + bad.then(|| text.into_bytes()) +} + +fn check(args: &Args, tools: &Tools, test: &Test) -> Rec { + let mut rec = Rec { test: test.rel.clone(), skip: None, rustc: None, found: Vec::new(), notes: Vec::new(), probes: 0 }; + if SKIP_FLAG.is_match(&test.flags.join(" ")) { + rec.skip = Some("flags".into()); + return rec; + } + let mut flags: Vec = test.flags.iter().filter(|f| !DROP_FLAG.is_match(f)).cloned().collect(); + // rustc's default crate type is bin, rustdoc's lib; and rustdoc does not read + // `#![crate_type]` (Cargo always passes --crate-type), so pass what rustc would use. + if !test.flags.iter().any(|f| CRATE_TYPE.is_match(f)) { + let attr: Vec = CRATE_TYPE_ATTR.captures_iter(&test.text).map(|c| c[1].to_owned()).collect(); + if attr.is_empty() && !CRATE_TYPE.is_match(&test.text) { + flags.extend(["--crate-type".into(), "bin".into()]); + } + for t in attr { + flags.extend(["--crate-type".into(), t]); + } + } + let dir = driver::scratch_dir(&args.sweep); + let c = Compile::new(&tools.rustc, &test.path, &dir.path().join("rustc"), &test.flags, test.edition()).emit("metadata").timeout(120).run(); + rec.rustc = Some(c.status); + if c.status == Status::Timeout { + rec.skip = Some("rustc timeout".into()); + return rec; + } + let accepted = c.status == Status::Ok && test.kind.is_none_or(|k| !matches!(k, Kind::CheckFail | Kind::BuildFail)); + let mut files: Vec<(String, Vec)> = Vec::new(); + let runs: &[(&str, &[&str])] = if accepted { + &[("html", &[]), ("html-private", &["--document-private-items"]), ("json", &["--output-format", "json"]), ("json-private", &["--output-format", "json", "--document-private-items"])] + } else { + &[("html", &[])] + }; + let mut public_json = None; + for (name, extra) in runs { + let out = dir.path().join(name); + let d = rustdoc(tools, test, &flags, &out, extra); + if d.status == Status::Error && UNKNOWN_OPTION.is_match(&d.stderr) { + rec.skip = Some(format!("rustdoc option: {}", rustc::first_error(&d.stderr))); + return rec; + } + match d.status { + Status::Ice => { + match KNOWN_ICE.iter().find(|(pat, _)| d.stderr.contains(pat)) { + Some((_, issue)) => rec.notes.push(format!("ice ({name}): known, {issue}")), + None => { + rec.found.push(format!("ice ({name}): {}", ice_line(&d.stderr))); + files.push((format!("rustdoc-{name}.stderr"), d.stderr.into_bytes())); + } + } + break; + } + Status::Timeout => rec.notes.push(format!("rustdoc timeout ({name})")), + Status::Error if accepted => { + let names = error_names(&d.stderr); + // A test that sets a tiny recursion limit: rustdoc's extra trait work (blanket + // impls over std types) passes a depth rustc happens to stay under. + let overflow_only = test.text.contains("#![recursion_limit") && d.stderr.contains("overflow"); + match noise(&names).or(overflow_only.then_some("overflow under the test's recursion_limit")) { + Some(why) => rec.notes.push(format!("{name}: {why}")), + None => { + rec.found.push(format!("rejects ({name}): {}", names.join(", ").chars().take(150).collect::())); + files.push((format!("rustdoc-{name}.stderr"), d.stderr.into_bytes())); + } + } + } + _ => {} + } + if let Some(j) = d.json { + let (problems, orphans) = consistency(&j); + if orphans > 0 { + rec.notes.push(format!("json ({name}): {orphans} unreachable items with dangling ids")); + } + for problem in problems { + // Without private items, an id can name a stripped private item: known, open + // upstream (#113674, #119626, #117718, #112852). With them, nothing is stripped. + if *name == "json" && problem.starts_with("id ") { + rec.notes.push(format!("json: {problem} (stripped item, #113674 family)")); + } else { + rec.found.push(format!("json ({name}): {problem}")); + } + } + if *name == "json" { + public_json = Some(j); + } + } + } + if let Some(doc) = public_json { + for m in reexport_missing(&doc, &root_reexports(&test.text)) { + rec.found.push(format!("reexport: {m}")); + } + if !test.text.contains("no_core") && !test.text.contains("no_std") { + let (code, probes) = auto_trait_probes(&doc, test.text.lines().count()); + rec.probes = probes.len(); + if !probes.is_empty() + && let Some(src) = run_probes(tools, test, dir.path(), &code, &probes, &mut rec.found, &mut rec.notes) + { + files.push(("probe.rs".into(), src)); + } + } + } + if !rec.found.is_empty() { + driver::write_finding(&args.sweep.work, test, &files, &serde_json::json!({ "found": rec.found, "notes": rec.notes, "rustdoc_flags": flags })); + } + rec +} + +fn ice_line(stderr: &str) -> String { + stderr + .lines() + .find(|l| l.contains("panicked at") || l.contains("internal compiler error")) + .map(|l| l.chars().take(160).collect()) + .unwrap_or_default() +} + +pub fn run(args: Args) -> anyhow::Result { + let bin = PathBuf::from(std::env::var("HOME")?).join(format!(".rustup/toolchains/{}-x86_64-unknown-linux-gnu/bin", args.toolchain)); + let tools = Tools { rustc: bin.join("rustc"), rustdoc: bin.join("rustdoc") }; + anyhow::ensure!(tools.rustdoc.exists(), "no rustdoc at {}", tools.rustdoc.display()); + let tests = uitest::tests(&args.sweep.tests, uitest::ALL, |_| false); + let tests = args.sweep.select(tests); + println!("{} tests", tests.len()); + Ok(driver::drive(&tests, &args.sweep, |t| check(&args, &tools, t))) +} diff --git a/docs/checks.md b/docs/checks.md index 1e149f5..c20ae65 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -370,6 +370,28 @@ Ten new findings (19–28) in [`hunt.md`](hunt.md), none from the checks mirth h | determinism (15) | `mirth-lab repro-diff` | 6,886 tests × repeat, other directory with `--remap-path-prefix`, `-Zthreads=8`, decoy `-L` library | nothing new: only `-Zthreads` differences, all in the known async fn (#162202) and RPIT (#163878) families | | feature gates (17) | `mirth-lab gate-check` | 143 unstable attributes × 14 positions; 156 unstable library items with resolvable paths × use, renamed use, glob, impl, value, type | every library spelling gated; finding 31 (an ICE after the gate error for `#[rustc_main]` on non-functions); `#[feature]` outside the crate root only warns (intended) | + +### Third batch (2026-10-10): rustdoc consistency (6) + +`mirth-lab rustdoc-diff` runs rustdoc (nightly-2026-10-06, the same commit as the campaign +rustc) on every standalone UI test, in HTML, HTML with `--document-private-items`, JSON and +JSON with private items, next to `rustc --emit=metadata`. Findings: rustdoc panics (any test); +rustdoc rejects what rustc accepts; the JSON's reachable ids do not resolve (jsondoclint's +rules); a root `pub use` is missing; an auto-trait impl rustdoc shows does not hold under its +bounds, or a negative one does (a probe appended to the test, compiled by rustc, #162274). + +| swept | result | +|---|---| +| 18,624 tests: rustc accepts 7,473 (four rustdoc runs each), rejects 10,969 (one run, for panics); 207 skipped (flags rustdoc does not take, or that stop rustc early); 4,562 auto-trait probes in 577 tests | 19 tests with findings, 7 findings (33–39): two rustdoc panics on code rustc accepts (33: `use {{}}`, a regression from nightly-2026-09-26; 36: const binding through a supertrait), rustdoc rejecting accepted code (34, 35, 37), panics on rejected code (38, stable: a too-large static), dangling JSON ids (39). No re-export missing; no auto-trait impl contradicted by its probe | + +Expected, and labelled rather than reported: rustdoc's own lints denied by a test; a test's tiny +`recursion_limit` (rustdoc does more trait work); dangling ids for stripped private items in the +public JSON (open upstream: #113674, #119626, #117718, #112852); the `fn_delegation` ICE +(#155728, 11 tests); rustdoc JSON's `unimplemented!()` for unsafe binders (a FIXME, 5 tests). +rustdoc does not read `#![crate_type]` (only `--crate-type`, as Cargo passes it), so the check +passes the attribute's value. 345 probes are inconclusive (a type that cannot be named from the +crate root, an unrenderable bound). + ## Running the checks The checks are subcommands of `mirth-lab` (`crates/mirth-lab`; `mirth-lab --help` lists them): @@ -379,6 +401,7 @@ cargo build --release -p mirth-lab R=~/mirth-work/campaign/rustc/bin/rustc T=~/mirth-work/rust/tests/ui target/release/mirth-lab opt-diff --rustc $R --cranelift "$(rustup +nightly-2026-10-06 which rustc)" --tests $T --work target/release/mirth-lab solver-diff --rustc $R --tests $T --work +target/release/mirth-lab rustdoc-diff --toolchain nightly-2026-10-06 --tests $T --work target/release/mirth-lab abi-diff --rustc $R --rust ~/mirth-work/rust --work --seed 3 target/release/mirth-lab release-diff --corpus ~/proofhouse-repos/rust --old nightly-2026-07-18 --new nightly-2026-10-06 --work ``` diff --git a/docs/hunt.md b/docs/hunt.md index e52fcc5..1868d02 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -56,6 +56,13 @@ with `-Zthreads=8`. `rustc/check.sh wide` runs the ordinary checks. | 30 | compiler-internal debug output in user-facing diagnostics: under the default (new) solver, E0308 help suggests `as fn(?0t) -> ?0t`; an E0391 cycle note prints `Binder { value: ConstEvaluatable(AliasConst(… DefId(0:7 ~ …` (blessed in `offset-of/inside-array-length.stderr`) | low, diagnostics; found by the diagnostic-invariants check over 18,374 UI tests (excluding tests that ask for verbose output); the first not in CI because of the solver pin ([`solver-triage.md`](solver-triage.md) item I) | | 31 | `#[rustc_main]` on a struct, impl, trait or module, on stable: after the expected E0658 and "cannot be used on structs", rustc ICEs ("unexpected sort of node in fn_sig()", `collect.rs`): the item is still taken as the entry point | **looks new**, low (error recovery, internal attribute); regression between 1.91.0 and 1.93.0; found by the feature-gate check; [repro](hunt/tests/rustc-main-on-struct.rs) | | 32 | new-solver compile-time regression: a chain of N `.map()` calls type-checks in 4.5 s / 520 MB at N=200 on nightly-2026-08-03 and 37–58 s / 2.0–2.9 GB from nightly-2026-08-04, with a new "overflow evaluating the requirement `Map<…>: Iterator`" future-compat warning; nightly's default solver is the new one, so default builds regressed from 2.2 s (old solver, July) to 53 s | **looks new**, medium (compile time, realistic code shape); bisected over nightlies to #160254 (the only solver PR in the range); found by the scaling check; [facts](hunt/iter-chain-solver-regression.md) | +| 33 | rustdoc panics on an empty nested `use` group (`use {{}};`, `use {{}, {}};`) that rustc accepts: `Option::unwrap()` on `None` in `clean_use_statement_inner` (`clean/mod.rs:3224`) | **looks new**, medium (`cargo doc` crash on valid code); regression on nightly-2026-09-26, range contains #161349 ("Unflatten `use` statements in HIR"); still in nightly-2026-10-10; found by the rustdoc check; [facts](hunt/rustdoc-empty-nested-use.md) | +| 34 | rustdoc builds its session options without rustc's post-parse adjustments: `-Ccodegen-units=1` is ignored (`-Zsanitizer=cfi -Clto` rejected), and `-Zassumptions-on-binders` does not switch on the next solver (ICE: `assertion failed: self.next_trait_solver()`) | **looks new**, low (unstable flags); since at least 1.80.0 (codegen units); found by the rustdoc check; [facts](hunt/rustdoc-session-options.md) | +| 35 | rustdoc rejects an associated-const binding rustc accepts (generic const items): "anonymous constants referencing generics are not yet supported"; `clean_hir_term` types the constant with identity arguments (its own FIXME) | low (incomplete features); found by the rustdoc check; [facts](hunt/rustdoc-gca-anon-const.md) | +| 36 | rustdoc panics on an associated-const binding through a supertrait (`T: C` with `CONST` in `C`'s supertrait): the lookup searches only `C`'s own items, then `assoc_item.unwrap()` (`clean/mod.rs:539`) | **looks new**, low (incomplete features); found by the rustdoc check; [facts](hunt/rustdoc-supertrait-assoc-const.md) | +| 37 | rustdoc fails with E0080 on a type alias whose array length does not evaluate (`type D = [(); panic!()];`), which rustc accepts (aliases are not checked): `clean_ty` normalizes the length | **looks new**, low; since at least 1.80.0; found by the rustdoc check; [facts](hunt/rustdoc-alias-array-length.md) | +| 38 | rustdoc ICEs where rustc reports an error, on delayed bugs whose real error comes from a step rustdoc skips: a static too large for the target (stable, `static X: [u8; 1 << 61] = …`; rustc E0080), an invalid pattern type, a `generic_const_exprs` case | **looks new**, low (code that does not compile); stable since at least 1.80.0; found by the rustdoc check; [facts](hunt/rustdoc-delayed-bugs.md) | +| 39 | rustdoc JSON (even `--document-private-items`): an impl inside a function body names a type local to that body, and its id is in neither `index` nor `paths` (jsondoclint would reject the output) | low (JSON consumers); since at least 1.98.0; related to the open stripped-item dangling-id issues (#113674 family) but nothing is stripped here; found by the rustdoc check; [facts](hunt/rustdoc-json-body-local.md) | Findings 1 and 2 are single-threaded: an ordinary `cargo build`, an edit, another `cargo build`, and the metadata differs from a clean build of the edited source. Both come diff --git a/docs/hunt/rustdoc-alias-array-length.md b/docs/hunt/rustdoc-alias-array-length.md new file mode 100644 index 0000000..9955337 --- /dev/null +++ b/docs/hunt/rustdoc-alias-array-length.md @@ -0,0 +1,45 @@ +# rustdoc evaluates an array length rustc never evaluates (type aliases) + +Facts for finding 37. Found by the rustdoc consistency check (`mirth-lab rustdoc-diff`) on +`type-alias/lack-of-wfcheck.rs` and `type-alias/lack-of-wfcheck-generic-const-args.rs` +(check-pass). + +## What happens + +```rust +type Diverging = [(); panic!()]; +fn main() {} +``` + +`rustc` compiles it (type aliases are not checked for well-formedness, and nothing uses the +alias). `rustdoc` fails: + +``` +error[E0080]: evaluation panicked: explicit panic +``` + +The same for `pub type Diverging = [(); panic!()];` in a library, with 1.80.0 ("evaluation of +constant value failed"), 1.90.0, 1.98.0 and nightly-2026-10-06. + +## Where + +`src/librustdoc/clean/mod.rs`, `clean_ty` (line 1925), for an array type whose length is an +anonymous constant (line 1963): + +```rust + let ct = lower_const_arg_for_rustdoc(cx.tcx, const_arg, cx.tcx.types.usize); + let typing_env = ty::TypingEnv::post_analysis(cx.tcx, *def_id); + let ct = + cx.tcx.normalize_erasing_regions(typing_env, Unnormalized::new_wip(ct)); +``` + +Normalizing evaluates the constant (`const_eval_resolve_for_typeck`), and a failed evaluation +reports E0080 (backtrace under `-Ztreat-err-as-bug=1`: `rustdoc::clean::clean_ty` → +`normalize_erasing_regions` → `eval_to_valtree` → `report_eval_error`). rustc evaluates the +length only where the alias is used. + +## Scope + +Documenting a crate with a type alias whose array length fails to evaluate (panics, overflows, +indexes out of bounds) and that the crate never uses: `cargo doc` fails although `cargo build` +succeeds. Low. Not found in the issue tracker (searched 2026-10-10). diff --git a/docs/hunt/rustdoc-delayed-bugs.md b/docs/hunt/rustdoc-delayed-bugs.md new file mode 100644 index 0000000..b701052 --- /dev/null +++ b/docs/hunt/rustdoc-delayed-bugs.md @@ -0,0 +1,62 @@ +# rustdoc ICEs where rustc reports an error: delayed bugs whose error comes from a step rustdoc skips + +Facts for finding 38. Found by the rustdoc consistency check (`mirth-lab rustdoc-diff`) on three +UI tests rustc rejects: `limits/huge-static.rs`, `type/pattern_types/nested.rs`, +`layout/gce-rigid-const-in-array-len.rs`. In each, rustc reports an ordinary error and rustdoc +reports none and ends with + +``` +note: no errors encountered even though delayed bugs were created +note: those delayed bugs will now be shown as internal compiler errors +error: internal compiler error: … +``` + +A part of the compiler both run records a delayed bug, relying on a later step to report the +real error; rustc runs that step, rustdoc does not. + +## 1. A static too large for the target (stable) + +```rust +static X: [u8; 1 << 61] = [0; 1 << 61]; +fn main() {} +``` + +rustc: `error[E0080]: values of the type `[u8; 2305843009213693952]` are too big for the target +architecture` (1.80.0: "could not evaluate static initializer"). rustdoc: `error: internal +compiler error: SizeOverflow([u8; 2305843009213693952_usize])`, "delayed at +compiler/rustc_hir_analysis/src/check/check.rs:225:23". The same with `pub static`, with 1.80.0, +1.98.0 and nightly-2026-10-06. A `const` of that type, and a struct with such a field, document +fine. + +`check_static_inhabited` (`check.rs:205-227`) computes the static's layout and, for any layout +error other than the foreign-static and SIMD cases, calls `span_delayed_bug` ("Generic statics +are rejected, but we still reach this case."). In rustc, evaluating the initializer then +reports E0080. rustdoc does not evaluate statics. + +## 2. An invalid pattern type (internal feature) + +```rust +#![feature(pattern_types, pattern_type_macro)] +use std::pat::pattern_type; +type X = pattern_type!(() is ..0); +fn main() {} +``` + +rustc: `error[E0277]: `()` is not a valid base type for range patterns`. rustdoc: `error: +internal compiler error: invalid base type for range pattern`, the delayed bug in +`compiler/rustc_hir_analysis/src/hir_ty_lowering/mod.rs:3465`; the E0277 comes from the +well-formedness check, which rustdoc does not run on the alias. Also with +`const X: pattern_type!(() is ..0) = todo!();` and with +`pattern_type!(pattern_type!(u32 is 1..) is 0..)` (rustc: E0308). + +## 3. `generic_const_exprs` (incomplete feature) + +`layout/gce-rigid-const-in-array-len.rs`: rustdoc ICEs with "Missing value for constant, but no +error reported?". Not reduced. + +## Scope + +Documenting code that does not compile: no `cargo doc` of a crate that builds is affected, but +rustdoc panics instead of reporting the error. Case 1 is stable Rust since at least 1.80.0. +Not found in the issue tracker (searched 2026-10-10 for rustdoc with SizeOverflow, too-large +statics, and the pattern-type message). diff --git a/docs/hunt/rustdoc-empty-nested-use.md b/docs/hunt/rustdoc-empty-nested-use.md new file mode 100644 index 0000000..1c35f87 --- /dev/null +++ b/docs/hunt/rustdoc-empty-nested-use.md @@ -0,0 +1,65 @@ +# rustdoc panics on an empty nested `use` group + +Facts for finding 33. Found by the rustdoc consistency check (`mirth-lab rustdoc-diff`, +[`checks.md`](../checks.md) check 6) on three UI tests rustc accepts: +`imports/duplicate-empty-imports.rs`, `imports/empty-import-prefix-pass.rs`, +`imports/empty-import-prefix-pass-2015.rs`. + +## What happens + +```rust +use {{}}; +fn main() {} +``` + +`rustc` compiles it (one `unused_imports` warning). `rustdoc` (nightly-2026-10-06, and today's +nightly-2026-10-10, 32dba69d6) panics: + +``` +thread 'rustc' panicked at src/librustdoc/clean/mod.rs:3224:36: +called `Option::unwrap()` on a `None` value +``` + +| input | rustc | rustdoc | +|---|---|---| +| `use {{}};` | accepts | panics | +| `use {{}, {}};` | accepts | panics | +| `mod m { use {{}}; }` | accepts | panics | +| `use {};` | accepts | ok | +| `pub use {{}};` | accepts | ok | +| `use std::{{}, {}};` | accepts | ok | +| `use {self::{}};` | accepts | ok | + +The `--crate-type`, `--document-private-items` and `--output-format json` options make no +difference. + +## Where + +`src/librustdoc/clean/mod.rs`, `clean_use_statement_inner`, the `hir::UseKind::Nested` arm: + +```rust + for (tree, hir_id, def_id) in items { + let mut segments = path.segments.to_vec(); + segments.extend(tree.prefix.segments.iter()); + if segments.last().unwrap().ident.name == kw::SelfLower { +``` + +For `use {{}}`, both the outer path and the nested tree's prefix have no segments, so +`segments` is empty. + +## Versions + +Bisected over nightlies: nightly-2026-09-25 (f7575a9da) does not panic, nightly-2026-09-26 +(5ceaf6608) does. 1.80.0, 1.90.0 and 1.98.0 do not panic. The range includes #161349 +("Unflatten `use` statements in HIR", merged 2026-09-25), which introduced the +`UseKind::Nested` arm and changed `librustdoc/clean/mod.rs`; no other PR in the range changes +`librustdoc/clean`. Not confirmed by building with it reverted. + +Not found in the issue tracker (searched 2026-10-10 for rustdoc ICEs on empty and nested +imports, and for issues mentioning #161349). + +## Scope + +`cargo doc` on any crate, or any dependency documented with it, containing a private +`use {{}}` or `use {{}, …}` group in any module. The pattern is unusual in hand-written code +and is accepted by rustc (with `unused_imports`). diff --git a/docs/hunt/rustdoc-gca-anon-const.md b/docs/hunt/rustdoc-gca-anon-const.md new file mode 100644 index 0000000..00a798b --- /dev/null +++ b/docs/hunt/rustdoc-gca-anon-const.md @@ -0,0 +1,56 @@ +# rustdoc rejects an associated-const binding rustc accepts (generic const items) + +Facts for finding 35. Found by the rustdoc consistency check (`mirth-lab rustdoc-diff`) on +`generic-const-items/assoc-const-bindings.rs` (check-pass). Reduced: +[`tests/rustdoc-gca-anon-const.rs`](tests/rustdoc-gca-anon-const.rs). + +## What happens + +```rust +trait Owner { + #[rustc_always_gca] + const Q: Maybe; +} +fn take2 = { Maybe::Just::<()>(()) }>>(_: O) {} +``` + +(with the features, the impl and `enum Maybe` of the test file). `rustc` (nightly-2026-10-10, +`RUSTC_BOOTSTRAP=1`) accepts it. `rustdoc` on the same file reports, at the `{ … }` of the +binding: + +``` +error: anonymous constants referencing generics are not yet supported +``` + +The constant does not mention any generic parameter; the binding is for `Q<()>`, whose type +is `Maybe<()>`. + +## Where + +`src/librustdoc/clean/mod.rs:530`, `clean_hir_term`, for a constant on the right of an +associated-item binding: + +```rust + hir::Term::Const(c) => { + // FIXME(generic_const_items): this should instantiate with the alias item's args + let ty = cx.tcx.type_of(assoc_item.unwrap()).instantiate_identity().skip_norm_wip(); + let ct = lower_const_arg_for_rustdoc(cx.tcx, c, ty); +``` + +The type passed on is `Maybe` (identity arguments), not `Maybe<()>`, and lowering the +anonymous constant against it reports +`compiler/rustc_hir_analysis/src/hir_ty_lowering/mod.rs:2434` (the expected type +`has_non_region_param()`). The FIXME records the missing instantiation; this is what it does +to a program rustc accepts. Also `const-generics/associated-const-bindings/bound-var-in-ty.rs`. + +## Versions + +The test needs nightly-2026-10-10's feature names (`gca_min_const_items`); on nightly-2026-09-25 +and nightly-2026-08-27 both tools reject the file for unrelated reasons (feature names, +`rustc_*` attributes). So the difference is visible from nightly-2026-10-06 on (the mirth pin) +and not earlier with this file. + +## Scope + +Incomplete features only (`generic_const_items`, `gca_min_const_items`, +`generic_const_parameter_types`). Low: documentation of crates using them. diff --git a/docs/hunt/rustdoc-json-body-local.md b/docs/hunt/rustdoc-json-body-local.md new file mode 100644 index 0000000..8bfd1f5 --- /dev/null +++ b/docs/hunt/rustdoc-json-body-local.md @@ -0,0 +1,45 @@ +# rustdoc JSON: an impl in a function body names a type the output does not contain + +Facts for finding 39. Found by the rustdoc consistency check (`mirth-lab rustdoc-diff`, the JSON +self-consistency part) on `lint/non-local-defs/{exhaustive,from-local-for-global,generics}.rs` +and `privacy/required-pub-in-blocks.rs`. Reduced: +[`tests/rustdoc-json-body-local.rs`](tests/rustdoc-json-body-local.rs). + +## What happens + +```rust +pub struct S; +pub trait Tr { type A; } +fn f() { + pub struct Z; + impl Tr for S { type A = Z; } +} +``` + +`rustdoc --crate-type lib -Zunstable-options --output-format json --document-private-items` +documents the impl (an impl applies to its types wherever it is written), so the associated +type `A` appears with + +```json +"type": {"resolved_path": {"path": "Z", "id": 42, "args": null}} +``` + +and id 42 is in neither `index` nor `paths`: items defined inside a function body are not +documented, even with `--document-private-items`. The impl is reachable from the root (through +`S`'s impls), so rustdoc's own JSON checker, `src/tools/jsondoclint`, would report it +(`NotFound`, and "No entry in '$.paths'" for a path). + +Same with nightly-2026-07-18 and 1.98.0 (format version 60) and nightly-2026-10-06 (61). + +## Related + +Open upstream issues report dangling ids for items rustdoc strips: a private item used in a +public signature (#113674, #119626), an item from a `#[doc(hidden)]` module (#117718, #112852). +This case differs: nothing is stripped (`--document-private-items`); the item is simply never +documented because it is local to a body. Not found in the issue tracker (searched 2026-10-10). + +## Scope + +Consumers of rustdoc JSON (semver checkers, documentation tools) that follow ids, on crates with +impls inside function bodies naming body-local types (which the `non_local_definitions` lint +warns about for some forms). Low. diff --git a/docs/hunt/rustdoc-session-options.md b/docs/hunt/rustdoc-session-options.md new file mode 100644 index 0000000..074579f --- /dev/null +++ b/docs/hunt/rustdoc-session-options.md @@ -0,0 +1,62 @@ +# rustdoc builds its session options without rustc's post-parse adjustments + +Facts for finding 34. Found by the rustdoc consistency check (`mirth-lab rustdoc-diff`) on two +UI tests rustc accepts: `sanitizer/cfi/fn-ptr-type-mismatch-recover.rs` (rustdoc rejects it) and +`assumptions_on_binders/next-solver-no-overridden.rs` (rustdoc panics). + +rustc turns the parsed command line into session options in `build_session_options` +(`compiler/rustc_session/src/config.rs:2697`), which also adjusts options after parsing. +rustdoc parses `-C` and `-Z` itself (`src/librustdoc/config.rs:415-416`, `CodegenOptions::build` +and `UnstableOptions::build`) and fills the session options in `src/librustdoc/core.rs:271` with +`..Options::default()` for the rest. Two of the adjustments it misses show as differences: + +## 1. `-Ccodegen-units` is ignored: CFI with LTO is rejected + +```rust +fn main() {} +``` + +With `-Ccodegen-units=1 -Clto -Zsanitizer=cfi -Ctarget-feature=-crt-static`, `rustc +--emit=metadata` succeeds and `rustdoc` fails: + +``` +error: `-Zsanitizer=cfi` with `-Clto` requires `-Ccodegen-units=1` +error: Compilation failed, aborting rustdoc +``` + +`Session::codegen_units` (`session.rs:1192`) reads `opts.cli_forced_codegen_units`. rustc sets +it from `-Ccodegen-units` (`config.rs:2783`, `should_override_cgus_and_disable_thinlto`, then +`cli_forced_codegen_units: codegen_units` at `config.rs:3084`). Under rustdoc it stays `None` +although `cg.codegen_units` is `Some(1)`, so `codegen_units()` is the default 16 and the CFI +check in `validate_commandline_args_with_session_available` (`session.rs:1666`) fails. The same +with 1.80.0, 1.90.0, 1.98.0 (`RUSTC_BOOTSTRAP=1`), nightly-2026-10-06 and nightly-2026-10-10. + +## 2. `-Zassumptions-on-binders` does not switch on the next solver: ICE + +```rust +#![crate_type = "lib"] +``` + +With `-Zassumptions-on-binders -Znext-solver=no`, rustc warns "-Zassumptions-on-binders +unconditionally enables the next trait solver; `-Znext-solver=no` is ignored" and compiles. +rustdoc panics: + +``` +thread 'rustc' panicked at compiler/rustc_infer/src/infer/outlives/obligations.rs:280:9: +assertion failed: self.next_trait_solver() +``` + +`build_session_options` sets `unstable_opts.next_solver = NextSolverConfig::Globally` whenever +`assumptions_on_binders` is on (`config.rs:2733-2751`); rustdoc's `UnstableOptions::build` result +keeps `next_solver` as given, and `destructure_solver_region_constraints` asserts both flags. +Also on nightly-2026-08-27 (then `obligations.rs:255`). Without `-Znext-solver=no` rustdoc +does not panic. + +## Scope + +`rustdoc` (and `cargo doc` with these flags in `RUSTDOCFLAGS`) with flags whose effect rustc +derives after parsing. Other adjustments in `build_session_options` were not checked one by one. +Low: both flags are unstable, and rustdoc does no codegen. + +Not found in the issue tracker (searched 2026-10-10 for rustdoc with +`-Zassumptions-on-binders`, `destructure_solver_region_constraints`, and the CFI message). diff --git a/docs/hunt/rustdoc-supertrait-assoc-const.md b/docs/hunt/rustdoc-supertrait-assoc-const.md new file mode 100644 index 0000000..f11a742 --- /dev/null +++ b/docs/hunt/rustdoc-supertrait-assoc-const.md @@ -0,0 +1,62 @@ +# rustdoc panics on an associated-const binding through a supertrait + +Facts for finding 36. Found by the rustdoc consistency check (`mirth-lab rustdoc-diff`) on +`supertrait-shadowing/assoc-const.rs`, `const-generics/associated-const-bindings/supertraits.rs` +and `const-generics/associated-const-bindings/coexisting-with-type-binding.rs`, all accepted by +rustc. Reduced: [`tests/rustdoc-supertrait-assoc-const.rs`](tests/rustdoc-supertrait-assoc-const.rs). + +## What happens + +```rust +#![feature(gca_min_const_items)] +use std::gca; +trait B { + #[rustc_always_gca] + const CONST: i32; +} +trait C: B {} +fn generic>() {} +fn main() {} +``` + +`rustc` (nightly-2026-10-06 and nightly-2026-10-10, `RUSTC_BOOTSTRAP=1`) accepts it. `rustdoc` +panics: + +``` +thread 'rustc' panicked at src/librustdoc/clean/mod.rs:539:48: +called `Option::unwrap()` on a `None` value +``` + +The same shape with an associated type (`trait B { type T; } trait C: B {} +fn generic>() {}`) documents fine. + +## Where + +`src/librustdoc/clean/mod.rs`, `clean_assoc_item_constraint` (line 3427) looks the bound name up +among the bound trait's own items only: + +```rust + let assoc_item = cx + .tcx + .associated_items(trait_did) + .find_by_ident_and_kind(cx.tcx, constraint.ident, assoc_tag, trait_did) + .map(|item| item.def_id); + AssocItemConstraintKind::Equality { term: clean_hir_term(assoc_item, term, cx) } +``` + +For `C` the constant is declared in the supertrait `B`, so `assoc_item` is `None`; +`clean_hir_term` unwraps it for a constant term (`type_of(assoc_item.unwrap())`, line 539). A +type term does not use `assoc_item`. + +## Versions + +The program compiles with rustc from nightly-2026-10-06 (the gca spelling); on nightly-2026-09-25 +and earlier both tools reject it (E0658), and the older `associated_const_equality` feature is +removed (E0557). So it cannot be compared before the pin with this program. + +Not found in the issue tracker (searched 2026-10-10). + +## Scope + +Incomplete features (`gca_min_const_items`, generic const arguments). An associated-const +equality bound naming a supertrait's constant through a subtrait. diff --git a/docs/hunt/tests/rustdoc-gca-anon-const.rs b/docs/hunt/tests/rustdoc-gca-anon-const.rs new file mode 100644 index 0000000..4629bfa --- /dev/null +++ b/docs/hunt/tests/rustdoc-gca-anon-const.rs @@ -0,0 +1,16 @@ +#![feature(generic_const_items, gca_min_const_items)] +#![feature(adt_const_params, const_param_ty_trait, generic_const_parameter_types)] +#![allow(incomplete_features)] +use std::gca; +use std::marker::{ConstParamTy, ConstParamTy_}; +trait Owner { + #[rustc_always_gca] + const Q: Maybe; +} +impl Owner for () { + const Q: Maybe = gca!(Maybe::Nothing::); +} +fn take2 = { Maybe::Just::<()>(()) }>>(_: O) {} +fn main() {} +#[derive(PartialEq, Eq, ConstParamTy)] +enum Maybe { Nothing, Just(T) } diff --git a/docs/hunt/tests/rustdoc-json-body-local.rs b/docs/hunt/tests/rustdoc-json-body-local.rs new file mode 100644 index 0000000..34538e8 --- /dev/null +++ b/docs/hunt/tests/rustdoc-json-body-local.rs @@ -0,0 +1,6 @@ +pub struct S; +pub trait Tr { type A; } +fn f() { + pub struct Z; + impl Tr for S { type A = Z; } +} diff --git a/docs/hunt/tests/rustdoc-supertrait-assoc-const.rs b/docs/hunt/tests/rustdoc-supertrait-assoc-const.rs new file mode 100644 index 0000000..89db46e --- /dev/null +++ b/docs/hunt/tests/rustdoc-supertrait-assoc-const.rs @@ -0,0 +1,9 @@ +#![feature(gca_min_const_items)] +use std::gca; +trait B { + #[rustc_always_gca] + const CONST: i32; +} +trait C: B {} +fn generic>() {} +fn main() {} diff --git a/rustc/big-run.sh b/rustc/big-run.sh index fc99069..ea6bd43 100755 --- a/rustc/big-run.sh +++ b/rustc/big-run.sh @@ -41,6 +41,7 @@ run suggest-diff suggest-diff --rustc "$rustc" "${sweep[@]}" --work "$dir/sugges run opt-diff opt-diff --rustc "$rustc" --cranelift "$(rustup +$pin which rustc)" "${sweep[@]}" --work "$dir/opt-diff" run rewrite-diff rewrite-diff --rustc "$rustc" "${sweep[@]}" --work "$dir/rewrite-diff" run miri-diff miri-diff --rustc "$rustc" --miri-toolchain "$pin" "${sweep[@]}" --work "$dir/miri-diff" +run rustdoc-diff rustdoc-diff --toolchain "$pin" "${sweep[@]}" --work "$dir/rustdoc-diff" run instr-check instr-check --toolchain "$pin" "${sweep[@]}" --work "$dir/instr-check" for s in 1 2 3 4 5 6 7 8 9 10; do run abi-diff-$s abi-diff --rustc "$rustc" --rust "$rust" --all --count 300 --seed $s --jobs "$jobs" --work "$dir/abi-diff-$s"