diff --git a/Cargo.lock b/Cargo.lock index 2eb3cb9..81f7e43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -382,6 +382,8 @@ dependencies = [ "flate2", "libc", "mirth-rewrite", + "proc-macro2", + "quote", "rand", "rayon", "regex", @@ -389,6 +391,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..36232be 100644 --- a/crates/mirth-lab/Cargo.toml +++ b/crates/mirth-lab/Cargo.toml @@ -27,3 +27,6 @@ libc = "0.2" flate2 = "1" tar = "0.4" mirth-rewrite = { path = "../mirth-rewrite" } +syn = { version = "2.0.119", features = ["full", "visit"] } +proc-macro2 = { version = "1", features = ["span-locations"] } +quote = "1" diff --git a/crates/mirth-lab/src/main.rs b/crates/mirth-lab/src/main.rs index aabd4c0..5ef972f 100644 --- a/crates/mirth-lab/src/main.rs +++ b/crates/mirth-lab/src/main.rs @@ -27,6 +27,7 @@ mod tools { pub mod gate_check; pub mod grammar_coverage; pub mod instr_check; + pub mod lint_check; pub mod miri_diff; pub mod motivating; pub mod opt_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), + /// A lint fires only when its premise holds; allowing it or removing what it flags changes nothing else. + LintCheck(tools::lint_check::Args), } fn main() -> ExitCode { @@ -144,6 +147,7 @@ fn main() -> ExitCode { Check::ReproDiff(a) => tools::repro_diff::run(a), Check::GateCheck(a) => tools::gate_check::run(a), Check::InstrCheck(a) => tools::instr_check::run(a), + Check::LintCheck(a) => tools::lint_check::run(a), Check::ReleaseDiff(a) => tools::release_diff::run(a), Check::Xlink(a) => tools::xlink::run(a), Check::ScaleCheck(a) => tools::scale_check::run(a), diff --git a/crates/mirth-lab/src/tools/lint_check.rs b/crates/mirth-lab/src/tools/lint_check.rs new file mode 100644 index 0000000..dc56277 --- /dev/null +++ b/crates/mirth-lab/src/tools/lint_check.rs @@ -0,0 +1,986 @@ +//! Lint oracles: a lint fires only when its premise holds, and acting on it (allowing it, or +//! removing what it flags) keeps the program compiling with the same behavior. +//! +//! For each standalone UI test that compiles without errors (lints capped to warnings where the +//! test denies them), with a column of allow-by-default lints turned on (`WIDEN`), and for each +//! lint that warns: +//! +//! - allow: `#![allow()]` added to the crate removes that lint's warnings and changes no +//! other diagnostic (a lint with side effects on others, or an allow that errors) +//! - delete: what a premise lint flags, removed independently of its suggestion, still compiles, +//! and a run-pass test still prints the same: every item `dead_code` calls unused at once (with +//! impls naming a dead type or trait, impl items of dead trait items, and unused imports of +//! them), each arm `unreachable_patterns` flags, the statements from an `unreachable_code` +//! statement up to the block's tail, each `unused_extern_crates` item +//! - premise: `trivial_numeric_casts` says the expression already has the type, so the cast +//! goes; `trivial_casts` says a coercion would do, so `let p = e as T;` becomes +//! `let c = e; let p: T = c;`; `ambiguous_wide_pointer_comparisons` says the operands are wide, +//! so the left operand goes through a const assertion that it is two words, peeling up to three +//! references (#163840); `missing_copy_implementations` says the type could be `Copy`, so +//! `impl Copy` (and `Clone` if needed) is added after it +//! - widen-fix: a machine-applicable suggestion of an allow-by-default lint, applied alone, +//! compiles and fixes the warning (suggest-diff never sees these lints); when it breaks alone +//! but the same alternative of all of that lint's warnings applied together compiles (what +//! `cargo fix` does), it is not a finding +//! +//! Edits are found with syn on the test file (byte ranges of the flagged spans); flagged code +//! inside macro definitions or invocations matches no syntax node and is left alone. Failures +//! the lint's design or the edit explains (`EXPLAINED`, exempt dead code, re-exports, +//! macro-generated users) are listed under `expected` instead of counted. + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::LazyLock; + +use mirth_lab::driver::{self, Record, Sweep}; +use mirth_lab::rustc::{self, Compile, Diagnostic, Observed, Status, observe}; +use mirth_lab::uitest::{self, Kind, Test}; +use mirth_lab::normalize; +use regex::Regex; +use serde::Serialize; +use syn::spanned::Spanned; +use syn::visit::{self, Visit}; + +#[derive(clap::Args, Debug)] +pub struct Args { + #[arg(long)] + rustc: PathBuf, + /// Leave the allow-by-default lints off. + #[arg(long)] + no_widen: bool, + /// Lints tried with `allow` per test. + #[arg(long, default_value_t = 6)] + max_allow: usize, + /// Suggestions of allow-by-default lints tried per test. + #[arg(long, default_value_t = 4)] + max_fix: usize, + #[command(flatten)] + sweep: Sweep, +} + +/// Allow-by-default lints with an oracle here: a premise, a deletion or a suggestion to apply. +const WIDEN: &[&str] = &[ + "trivial_casts", + "trivial_numeric_casts", + "unused_qualifications", + "unused_import_braces", + "unused_lifetimes", + "redundant_lifetimes", + "single_use_lifetimes", + "explicit_outlives_requirements", + "redundant_imports", + "elided_lifetimes_in_paths", + "unreachable_pub", + "unused_extern_crates", + "missing_copy_implementations", + "let_underscore_drop", + "unit_bindings", + "ambiguous_negative_literals", +]; + +/// A failed edit explained without a finding: known upstream, or what the edit cannot express. +/// (lint, what the test's source contains, new error codes all among these, explanation) +const EXPLAINED: &[(&str, &str, &[&str], &str)] = &[ + ("dead_code", "inherent_associated_types", &[], "known: rust-lang/rust#110332 (inherent associated types always dead)"), + // Every impl in a staged_api crate needs a stability attribute, the added `impl Copy` too. + ("missing_copy_implementations", "staged_api", &[], "edit: staged_api wants stability attributes"), + // Unreachable statements still take part in type inference. + ("unreachable_code", "", &["E0282", "E0283", "E0284"], "expected: unreachable code takes part in inference"), + ("unreachable_patterns", "", &["E0282", "E0283", "E0284"], "expected: unreachable arms take part in inference"), + // `match x {}` reads `x`; an arm with a wildcard pattern did not. + ("unreachable_patterns", "", &["E0381"], "expected: without its last arm, a match reads its scrutinee"), +]; + +static BY_PATH: LazyLock = + LazyLock::new(|| Regex::new(r"(?m)^\s*(pub(\([^)]*\))?\s+)?mod\s+\w+\s*;|include(_str|_bytes)?!|#\[path").unwrap()); +static SUMMARY: LazyLock = LazyLock::new(|| Regex::new(r"^(\d+ warnings? emitted|aborting due to)").unwrap()); +static CLOSURE: LazyLock = LazyLock::new(|| Regex::new(r"\{(closure|coroutine|async block|async closure)@[^}]*\}").unwrap()); +static BACKTICKED: LazyLock = LazyLock::new(|| Regex::new(r"`[^`]*`").unwrap()); +static NO_CORE: LazyLock = LazyLock::new(|| Regex::new(r"#!\[no_core\]").unwrap()); + +#[derive(Serialize)] +struct Rec { + test: String, + #[serde(skip_serializing_if = "Option::is_none")] + skip: Option, + lints: Vec, + compiles: usize, + found: Vec, + /// Differences explained without a finding. + #[serde(skip_serializing_if = "Vec::is_empty")] + expected: Vec, +} + +impl Record for Rec { + fn findings(&self) -> Vec { + self.found.clone() + } + fn test(&self) -> &str { + &self.test + } +} + +/// A diagnostic as compared across edits: level, lint or code, message, primary line. +type Key = (String, String, String, usize); + +/// `line_shift` lines were added at the top of `file`: its line numbers, in spans and in +/// messages (`{closure@file.rs:7:13}`), are moved back. +fn keys(diags: &[Diagnostic], line_shift: usize, file: &str) -> BTreeMap { + let in_message = Regex::new(&format!(r"{}:(\d+):", regex::escape(file))).unwrap(); + let mut out = BTreeMap::new(); + for d in diags { + if SUMMARY.is_match(&d.message) { + continue; + } + let line = d.primary().map_or(0, |s| { + let ours = Path::new(&s.file_name).file_name().and_then(|n| n.to_str()) == Some(file); + if ours { s.line_start.saturating_sub(line_shift) } else { s.line_start } + }); + let message = in_message + .replace_all(&d.message, |c: ®ex::Captures| format!("{file}:{}:", c[1].parse::().unwrap_or(0).saturating_sub(line_shift))) + .into_owned(); + // Closures print as `{closure@:L:C: L:C}`, the path absolute or not. + let message = CLOSURE.replace_all(&message, "{closure}").into_owned(); + *out.entry((d.level.clone(), d.code().to_owned(), message, line)).or_insert(0) += 1; + } + out +} + +/// Lint names are snake_case words; error codes are `E` and digits. +fn is_lint(code: &str) -> bool { + !code.is_empty() && !(code.starts_with('E') && code[1..].chars().all(|c| c.is_ascii_digit())) +} + +fn errors(diags: &[Diagnostic]) -> BTreeSet { + diags + .iter() + .filter(|d| d.level == "error" && !SUMMARY.is_match(&d.message)) + .map(|d| if d.code().is_empty() { d.message.clone() } else { d.code().to_owned() }) + .collect() +} + +// ---- finding the flagged syntax ---- + +type Range = std::ops::Range; + +fn range(s: &impl Spanned) -> Range { + s.span().byte_range() +} + +/// The syntax the edits need, by byte range. +#[derive(Default)] +struct Index { + /// Items, impl items and trait items: (range of the whole item, range of its name). + items: Vec<(Range, Range, ItemKind)>, + /// Inherent and trait impls: (range, self type name, item names, is a trait impl). + impls: Vec<(Range, String, Vec)>, + /// Trait impls: (range, trait name, self type name). + trait_impls: Vec<(Range, String, String)>, + /// `let` statements without a type: (statement range, pattern range, initializer range). + lets: Vec<(Range, Range, Range)>, + /// Items dead code may use without being reported: `#[allow(dead_code)]` or `_` names. + exempt: Vec, + /// Types with `#[allow(dead_code)]`: their impls are exempt too. + exempt_types: Vec, + /// Macro definitions and invocations: code syn does not see. + macros: Vec, + /// Match arms: (arm range, pattern range, or-pattern alternatives). + arms: Vec<(Range, Range, Vec)>, + /// Blocks: the ranges of their statements, and whether the last is a tail expression. + blocks: Vec<(Vec, bool)>, + /// Casts: (range, expression range, type range). + casts: Vec<(Range, Range, Range)>, + /// Comparisons: (range, left operand range). + comparisons: Vec<(Range, Range)>, + /// Trait items: (name range, trait, item name). + trait_items: Vec<(Range, String, String)>, + /// Items of trait impls: (range, trait, item name). + trait_impl_items: Vec<(Range, String, String)>, + /// Every impl: (range, the words of its self type, the words of its trait and bounds). + all_impls: Vec<(Range, BTreeSet, BTreeSet)>, + /// Inline modules. + mods: Vec, + /// `use` items: (range, range of the tree). + uses: Vec<(Range, Range)>, + /// Non-generic structs and enums: (range, name, derives Clone). + plain_types: Vec<(Range, String, bool)>, +} + +#[derive(Clone, Copy, PartialEq)] +enum ItemKind { + Type, + Trait, + Other, +} + +impl<'a> Visit<'a> for Index { + fn visit_item(&mut self, i: &'a syn::Item) { + let name = match i { + syn::Item::Fn(f) => Some((range(&f.sig.ident), ItemKind::Other)), + syn::Item::Struct(s) => Some((range(&s.ident), ItemKind::Type)), + syn::Item::Enum(e) => Some((range(&e.ident), ItemKind::Type)), + syn::Item::Union(u) => Some((range(&u.ident), ItemKind::Type)), + syn::Item::Const(c) => Some((range(&c.ident), ItemKind::Other)), + syn::Item::Static(s) => Some((range(&s.ident), ItemKind::Other)), + syn::Item::Type(t) => Some((range(&t.ident), ItemKind::Type)), + syn::Item::Trait(t) => Some((range(&t.ident), ItemKind::Trait)), + syn::Item::TraitAlias(t) => Some((range(&t.ident), ItemKind::Trait)), + syn::Item::Mod(m) => Some((range(&m.ident), ItemKind::Other)), + syn::Item::ExternCrate(e) => Some((range(e), ItemKind::Other)), + syn::Item::Macro(m) => m.ident.as_ref().map(|id| (range(id), ItemKind::Other)), + _ => None, + }; + if let Some((n, k)) = name { + self.items.push((range(i), n, k)); + } + let attrs: &[syn::Attribute] = match i { + syn::Item::Fn(f) => &f.attrs, + syn::Item::Struct(s) => &s.attrs, + syn::Item::Enum(e) => &e.attrs, + syn::Item::Impl(m) => &m.attrs, + syn::Item::Trait(t) => &t.attrs, + syn::Item::Const(c) => &c.attrs, + syn::Item::Static(s) => &s.attrs, + syn::Item::Mod(m) => &m.attrs, + _ => &[], + }; + if let syn::Item::Mod(m) = i + && m.content.is_some() + { + self.mods.push(range(i)); + } + let underscore = match i { + syn::Item::Type(t) => t.ident.to_string().starts_with('_'), + syn::Item::Fn(f) => f.sig.ident.to_string().starts_with('_'), + syn::Item::Const(c) => c.ident.to_string().starts_with('_'), + syn::Item::Static(s) => s.ident.to_string().starts_with('_'), + syn::Item::Struct(s) => s.ident.to_string().starts_with('_'), + _ => false, + }; + // Trait aliases: dead_code does not report them, so what they use is not dead to it. + if underscore || allows_dead(attrs) || matches!(i, syn::Item::TraitAlias(_)) { + self.exempt.push(range(i)); + if let syn::Item::Struct(s) = i { + self.exempt_types.push(s.ident.to_string()); + } + if let syn::Item::Enum(e) = i { + self.exempt_types.push(e.ident.to_string()); + } + } + if let syn::Item::Trait(t) = i { + for it in &t.items { + let id = match it { + syn::TraitItem::Fn(f) => &f.sig.ident, + syn::TraitItem::Const(c) => &c.ident, + syn::TraitItem::Type(ty) => &ty.ident, + _ => continue, + }; + self.trait_items.push((range(id), t.ident.to_string(), id.to_string())); + } + } + match i { + syn::Item::Struct(s) if s.generics.params.is_empty() => self.plain_types.push((range(i), s.ident.to_string(), derives(&s.attrs, "Clone"))), + syn::Item::Enum(e) if e.generics.params.is_empty() => self.plain_types.push((range(i), e.ident.to_string(), derives(&e.attrs, "Clone"))), + syn::Item::Use(u) => self.uses.push((range(i), range(&u.tree))), + syn::Item::Impl(imp) => { + fn words_of(ts: proc_macro2::TokenStream) -> BTreeSet { + let mut words = BTreeSet::new(); + let mut collect = |ts: proc_macro2::TokenStream| { + fn walk(ts: proc_macro2::TokenStream, out: &mut BTreeSet) { + for t in ts { + match t { + proc_macro2::TokenTree::Ident(i) => { + out.insert(i.to_string()); + } + proc_macro2::TokenTree::Group(g) => walk(g.stream(), out), + _ => {} + } + } + } + walk(ts, &mut words); + }; + collect(ts); + words + } + let self_words = words_of(quote::ToTokens::to_token_stream(&*imp.self_ty)); + let mut other = words_of(quote::ToTokens::to_token_stream(&imp.generics)); + if let Some(w) = &imp.generics.where_clause { + other.extend(words_of(quote::ToTokens::to_token_stream(w))); + } + if let Some((_, path, _)) = &imp.trait_ { + other.extend(words_of(quote::ToTokens::to_token_stream(path))); + } + self.all_impls.push((range(i), self_words, other)); + if let syn::Type::Path(p) = &*imp.self_ty + && let Some(seg) = p.path.segments.last() + { + let names = imp + .items + .iter() + .filter_map(|it| match it { + syn::ImplItem::Fn(f) => Some(range(&f.sig.ident)), + syn::ImplItem::Const(c) => Some(range(&c.ident)), + syn::ImplItem::Type(t) => Some(range(&t.ident)), + _ => None, + }) + .collect(); + if imp.trait_.is_none() { + self.impls.push((range(i), seg.ident.to_string(), names)); + } else { + self.impls.push((range(i), seg.ident.to_string(), Vec::new())); + } + } + if let Some((_, path, _)) = &imp.trait_ + && let Some(seg) = path.segments.last() + { + let self_name = match &*imp.self_ty { + syn::Type::Path(p) => p.path.segments.last().map(|s| s.ident.to_string()).unwrap_or_default(), + _ => String::new(), + }; + self.trait_impls.push((range(i), seg.ident.to_string(), self_name)); + for it in &imp.items { + let name = match it { + syn::ImplItem::Fn(f) => f.sig.ident.to_string(), + syn::ImplItem::Const(c) => c.ident.to_string(), + syn::ImplItem::Type(t) => t.ident.to_string(), + _ => continue, + }; + self.trait_impl_items.push((range(it), seg.ident.to_string(), name)); + } + } + } + _ => {} + } + visit::visit_item(self, i); + } + fn visit_impl_item(&mut self, i: &'a syn::ImplItem) { + let name = match i { + syn::ImplItem::Fn(f) => Some(range(&f.sig.ident)), + syn::ImplItem::Const(c) => Some(range(&c.ident)), + syn::ImplItem::Type(t) => Some(range(&t.ident)), + _ => None, + }; + if let Some(n) = name { + self.items.push((range(i), n, ItemKind::Other)); + } + visit::visit_impl_item(self, i); + } + fn visit_trait_item(&mut self, i: &'a syn::TraitItem) { + let name = match i { + syn::TraitItem::Fn(f) => Some(range(&f.sig.ident)), + syn::TraitItem::Const(c) => Some(range(&c.ident)), + syn::TraitItem::Type(t) => Some(range(&t.ident)), + _ => None, + }; + if let Some(n) = name { + self.items.push((range(i), n, ItemKind::Other)); + } + visit::visit_trait_item(self, i); + } + fn visit_foreign_item(&mut self, i: &'a syn::ForeignItem) { + let name = match i { + syn::ForeignItem::Fn(f) => Some(range(&f.sig.ident)), + syn::ForeignItem::Static(s) => Some(range(&s.ident)), + syn::ForeignItem::Type(t) => Some(range(&t.ident)), + _ => None, + }; + if let Some(n) = name { + self.items.push((range(i), n, ItemKind::Other)); + } + visit::visit_foreign_item(self, i); + } + fn visit_macro(&mut self, m: &'a syn::Macro) { + self.macros.push(range(m)); + visit::visit_macro(self, m); + } + fn visit_arm(&mut self, a: &'a syn::Arm) { + let alts = match &a.pat { + syn::Pat::Or(o) => o.cases.iter().map(range).collect(), + _ => Vec::new(), + }; + self.arms.push((range(a), range(&a.pat), alts)); + visit::visit_arm(self, a); + } + fn visit_local(&mut self, l: &'a syn::Local) { + if !matches!(l.pat, syn::Pat::Type(_)) + && let Some(init) = &l.init + { + self.lets.push((range(l), range(&l.pat), range(&*init.expr))); + } + visit::visit_local(self, l); + } + fn visit_trait_item_fn(&mut self, f: &'a syn::TraitItemFn) { + if f.sig.ident.to_string().starts_with('_') || allows_dead(&f.attrs) { + self.exempt.push(range(f)); + } + visit::visit_trait_item_fn(self, f); + } + fn visit_impl_item_fn(&mut self, f: &'a syn::ImplItemFn) { + if f.sig.ident.to_string().starts_with('_') || allows_dead(&f.attrs) { + self.exempt.push(range(f)); + } + visit::visit_impl_item_fn(self, f); + } + fn visit_block(&mut self, b: &'a syn::Block) { + self.blocks.push((b.stmts.iter().map(range).collect(), matches!(b.stmts.last(), Some(syn::Stmt::Expr(_, None))))); + visit::visit_block(self, b); + } + fn visit_expr_cast(&mut self, c: &'a syn::ExprCast) { + self.casts.push((range(c), range(&*c.expr), range(&*c.ty))); + visit::visit_expr_cast(self, c); + } + fn visit_expr_binary(&mut self, b: &'a syn::ExprBinary) { + if matches!(b.op, syn::BinOp::Eq(_) | syn::BinOp::Ne(_) | syn::BinOp::Lt(_) | syn::BinOp::Le(_) | syn::BinOp::Gt(_) | syn::BinOp::Ge(_)) { + self.comparisons.push((range(b), range(&*b.left))); + } + visit::visit_expr_binary(self, b); + } +} + +fn derives(attrs: &[syn::Attribute], name: &str) -> bool { + attrs.iter().any(|a| { + a.path().is_ident("derive") + && a.parse_args_with(syn::punctuated::Punctuated::::parse_terminated) + .is_ok_and(|ps| ps.iter().any(|p| p.segments.last().is_some_and(|s| s.ident == name))) + }) +} + +fn allows_dead(attrs: &[syn::Attribute]) -> bool { + attrs.iter().any(|a| { + (a.path().is_ident("allow") || a.path().is_ident("expect")) + && a.meta.require_list().is_ok_and(|l| { + let t = l.tokens.to_string(); + t.contains("dead_code") || t.split(|c: char| !c.is_alphanumeric() && c != '_').any(|w| w == "unused") + }) + }) +} + +fn index(text: &str) -> Option { + let file = syn::parse_file(text).ok()?; + let mut ix = Index::default(); + ix.visit_file(&file); + Some(ix) +} + +/// Replacements (range, new text), applied together from the end; overlapping ones are dropped +/// in favor of the enclosing one. +fn apply(text: &str, mut edits: Vec<(Range, String)>) -> Option { + edits.sort_by(|a, b| a.0.start.cmp(&b.0.start).then(b.0.end.cmp(&a.0.end))); + let mut kept: Vec<(Range, String)> = Vec::new(); + for e in edits { + if kept.last().is_some_and(|k| e.0.start < k.0.end) { + continue; + } + kept.push(e); + } + if kept.is_empty() { + return None; + } + let mut out = text.to_owned(); + for (r, s) in kept.iter().rev() { + if r.end > out.len() || !out.is_char_boundary(r.start) || !out.is_char_boundary(r.end) { + return None; + } + out.replace_range(r.clone(), s); + } + Some(out) +} + +fn spans(d: &Diagnostic, file: &str) -> Vec { + d.spans + .iter() + .filter(|s| s.is_primary && Path::new(&s.file_name).file_name().and_then(|n| n.to_str()) == Some(file)) + .map(|s| s.byte_start..s.byte_end) + .collect() +} + +/// An edit to try: what it tests, and the new text. +struct Edit { + what: String, + lint: String, + text: String, + /// The lint's warnings must go away (a fix); otherwise only "still compiles, same output". + fixes: Option<(String, usize)>, + /// Compile to an object file (a const assertion needs monomorphization). + mono: bool, + /// A wide-pointer premise: the left operand (range, text), checked through 0-3 derefs. + wide: Option<(Range, String)>, + /// For a fix: every fix of the same lint applied together (what `cargo fix` does). + together: Option, +} + +const WIDE_HELPER: &str = "\n#[allow(dead_code)]\nfn __mirth_wide

(p: &P) -> &P {\n const { assert!(size_of::

() == 2 * size_of::(), \"mirth: not a wide pointer\") };\n p\n}\n"; + +fn edits(args: &Args, text: &str, file: &str, ix: &Index, diags: &[Diagnostic], no_core: bool) -> Vec { + let mut out = Vec::new(); + fn warns<'d>(diags: &'d [Diagnostic], lint: &'static str) -> impl Iterator { + diags.iter().filter(move |d| d.level == "warning" && d.code() == lint) + } + + // dead_code: every unused item at once, with the impls of dead types and traits. + let dead: Vec = warns(diags, "dead_code") + .chain(warns(diags, "dead_code_pub_in_binary")) + .filter(|d| { + // Fields and variants stay (constructors and patterns name them); names in + // backticks are not the kind of item. + let kind = BACKTICKED.replace_all(&d.message, ""); + !(kind.contains("field") || kind.contains("variant") || kind.contains("never read")) + }) + .flat_map(|d| spans(d, file)) + .collect(); + if !dead.is_empty() { + let mut del: Vec<(Range, String)> = Vec::new(); + // (name, the inline module it is in): impls match by name within the same module. + let mut dead_names: BTreeSet<(String, Option<(usize, usize)>)> = BTreeSet::new(); + let mut dead_traits: BTreeSet<(String, Option<(usize, usize)>)> = BTreeSet::new(); + let module = |r: &Range| ix.mods.iter().filter(|m| m.start <= r.start && r.end <= m.end && **m != *r).min_by_key(|m| m.end - m.start).map(|m| (m.start, m.end)); + // All or nothing: a dead item the index lacks (in a macro, or syntax syn does not + // know) may be what another dead item uses. + let all_found = dead.iter().all(|sp| ix.items.iter().any(|(_, n, _)| n == sp)); + for (whole, name, kind) in ix.items.iter().filter(|_| all_found) { + if dead.contains(name) { + let ident = text.get(name.clone()).unwrap_or("").to_owned(); + match kind { + ItemKind::Type => { + // Only when every inherent item of the type is dead too. + let all_dead = ix.impls.iter().filter(|(_, t, _)| *t == ident).all(|(_, _, items)| items.iter().all(|r| dead.contains(r))); + if !all_dead { + continue; + } + dead_names.insert((ident, module(whole))); + } + ItemKind::Trait => { + dead_traits.insert((ident, module(whole))); + } + ItemKind::Other => {} + } + del.push((whole.clone(), String::new())); + } + } + // Imports of what is deleted are unused too. + for sp in warns(diags, "unused_imports").flat_map(|d| spans(d, file)) { + let single = |t: &Range| text.get(t.clone()).is_some_and(|s| !s.contains(',')) && t.start <= sp.start && sp.end <= t.end; + if let Some((r, _)) = ix.uses.iter().find(|(r, t)| *t == sp || *r == sp || single(t)) { + del.push((r.clone(), String::new())); + } + } + // A dead trait item goes with its definitions in the trait's impls. + for (n, tr, item) in &ix.trait_items { + if all_found && dead.contains(n) { + for (r, t2, i2) in &ix.trait_impl_items { + if t2 == tr && i2 == item { + del.push((r.clone(), String::new())); + } + } + } + } + // Impls naming a deleted type or trait anywhere in their header go with it. + for (r, self_words, other) in &ix.all_impls { + let m = module(r); + let named = |set: &BTreeSet<(String, Option<(usize, usize)>)>, w: &String| set.contains(&(w.clone(), m.clone())); + if self_words.iter().any(|w| named(&dead_names, w) || named(&dead_traits, w)) || other.iter().any(|w| named(&dead_traits, w)) { + del.push((r.clone(), String::new())); + } + } + if let Some(t) = apply(text, del).filter(|_| all_found) { + out.push(Edit { what: "delete".into(), lint: "dead_code".into(), text: t, fixes: None, mono: false, wide: None, together: None }); + } + } + + // unreachable_patterns: each flagged arm (or alternative) alone. + for d in warns(diags, "unreachable_patterns").filter(|d| d.message.starts_with("unreachable pattern")) { + for sp in spans(d, file) { + let edit = ix.arms.iter().find_map(|(arm, pat, alts)| { + if *pat == sp { + return Some((arm.clone(), String::new())); + } + let i = alts.iter().position(|a| *a == sp)?; + Some(if i + 1 < alts.len() { (alts[i].start..alts[i + 1].start, String::new()) } else { (alts[i - 1].end..alts[i].end, String::new()) }) + }); + if let Some(t) = edit.and_then(|e| apply(text, vec![e])) { + out.push(Edit { what: "delete".into(), lint: "unreachable_patterns".into(), text: t, fixes: None, mono: false, wide: None, together: None }); + } + } + } + + // unreachable_code: from the flagged statement to the end of its block. + for d in warns(diags, "unreachable_code").filter(|d| d.message.starts_with("unreachable statement") || d.message.starts_with("unreachable expression")) { + // The code that diverges (the secondary span) runs; inside the flagged code, it goes too. + let causes: Vec = d.spans.iter().filter(|s| !s.is_primary).map(|s| s.byte_start..s.byte_end).collect(); + for sp in spans(d, file) { + if causes.iter().any(|c| sp.start <= c.start && c.end <= sp.end) { + continue; + } + // A tail expression stays: unreachable code still gives the block its type. + let edit = ix.blocks.iter().find_map(|(stmts, tail)| { + let i = stmts.iter().position(|s| s.start == sp.start && (s.end == sp.end || s.end == sp.end + 1))?; + let last = if *tail { stmts.len().checked_sub(2)? } else { stmts.len() - 1 }; + (i <= last).then(|| (stmts[i].start..stmts[last].end, String::new())) + }); + if let Some(t) = edit.and_then(|e| apply(text, vec![e])) { + out.push(Edit { what: "delete".into(), lint: "unreachable_code".into(), text: t, fixes: None, mono: false, wide: None, together: None }); + } + } + } + + // unused_extern_crates: delete the item. + let unused_crates: Vec<(Range, String)> = warns(diags, "unused_extern_crates") + .flat_map(|d| spans(d, file)) + .filter_map(|sp| ix.items.iter().find(|(w, n, _)| *w == sp || *n == sp).map(|(w, _, _)| (w.clone(), String::new()))) + .collect(); + if let Some(t) = apply(text, unused_crates) { + out.push(Edit { what: "delete".into(), lint: "unused_extern_crates".into(), text: t, fixes: None, mono: false, wide: None, together: None }); + } + + // trivial_numeric_casts: the expression already has the type, so the cast goes. + let casts: Vec<(Range, String)> = warns(diags, "trivial_numeric_casts") + .flat_map(|d| spans(d, file)) + .filter_map(|sp| ix.casts.iter().find(|(c, _, _)| *c == sp)) + .filter_map(|(c, e, _)| Some((c.clone(), format!("({})", text.get(e.clone())?)))) + .collect(); + if let Some(t) = apply(text, casts) { + out.push(Edit { what: "premise".into(), lint: "trivial_numeric_casts".into(), text: t, fixes: None, mono: false, wide: None, together: None }); + } + // trivial_casts: a coercion would do. Where the cast initializes an untyped `let`, the + // value goes into a `let` of its own and then through the annotated binding: a coercion + // site, with the same temporary lifetimes and no expected type flowing into the value. + let casts: Vec<(Range, String)> = warns(diags, "trivial_casts") + .flat_map(|d| spans(d, file)) + .filter_map(|sp| ix.casts.iter().find(|(c, _, _)| *c == sp)) + .filter_map(|(c, e, t)| { + let (stmt, pat, _) = ix.lets.iter().find(|(_, _, init)| init == c)?; + // A pattern with a `ref` binding makes the initializer an equality, not a coercion. + if text.get(pat.clone())?.split(|ch: char| !ch.is_alphanumeric() && ch != '_').any(|w| w == "ref") { + return None; + } + Some([ + (stmt.start..stmt.start, format!("let __mirth_cast = {}; ", text.get(e.clone())?)), + (pat.end..pat.end, format!(": {}", text.get(t.clone())?)), + (c.clone(), "__mirth_cast".to_owned()), + ]) + }) + .flatten() + .collect(); + if let Some(t) = apply(text, casts) { + out.push(Edit { what: "premise".into(), lint: "trivial_casts".into(), text: t, fixes: None, mono: false, wide: None, together: None }); + } + + // Wide pointer comparisons: each left operand is two words, or a reference to one that is. + if !no_core { + for (l, _) in warns(diags, "ambiguous_wide_pointer_comparisons") + .flat_map(|d| spans(d, file)) + .filter_map(|sp| ix.comparisons.iter().find(|(c, _)| *c == sp).map(|(_, l)| (l.clone(), ()))) + .take(6) + { + if let Some(operand) = text.get(l.clone()) { + out.push(Edit { what: "premise".into(), lint: "ambiguous_wide_pointer_comparisons".into(), text: String::new(), fixes: None, mono: true, wide: Some((l, operand.to_owned())), together: None }); + } + } + } + + // missing_copy_implementations: the type can be Copy. + if !no_core { + for d in warns(diags, "missing_copy_implementations") { + for sp in spans(d, file) { + if let Some((r, name, derived)) = ix.plain_types.iter().find(|(r, _, _)| *r == sp || (r.start <= sp.start && sp.end <= r.end)) { + let has_clone = *derived || ix.trait_impls.iter().any(|(_, t, s)| t == "Clone" && s == name); + let mut add = format!("\nimpl Copy for {name} {{}}\n"); + if !has_clone { + add += &format!("impl Clone for {name} {{ fn clone(&self) -> Self {{ *self }} }}\n"); + } + if let Some(t) = apply(text, vec![(r.end..r.end, add)]) { + out.push(Edit { what: "premise".into(), lint: "missing_copy_implementations".into(), text: t, fixes: None, mono: false, wide: None, together: None }); + } + } + } + } + } + + // The suggestions of allow-by-default lints, one at a time. + let suggestion = |node: &Diagnostic| -> Vec<(Range, String)> { + node.spans + .iter() + .filter(|s| s.suggestion_applicability.as_deref() == Some("MachineApplicable") && Path::new(&s.file_name).file_name().and_then(|n| n.to_str()) == Some(file)) + .filter_map(|s| Some((s.byte_start..s.byte_end, s.suggested_replacement.clone()?))) + .collect() + }; + // The first suggestion of each warning of a lint, together. + // The `alt`-th suggestion of every warning of a lint, together (alternatives stay apart). + let together = |lint: &str, alt: usize| -> Option { + let all: Vec<(Range, String)> = diags + .iter() + .filter(|d| d.level == "warning" && d.code() == lint) + .filter_map(|d| std::iter::once(d).chain(&d.children).map(&suggestion).filter(|p| !p.is_empty()).nth(alt)) + .flatten() + .collect(); + apply(text, all) + }; + let mut fixes = 0; + for d in diags.iter().filter(|d| d.level == "warning" && WIDEN.contains(&d.code())) { + let mut alt = 0; + for node in std::iter::once(d).chain(&d.children) { + if fixes >= args.max_fix { + break; + } + let parts = suggestion(node); + if parts.is_empty() { + continue; + } + alt += 1; + if let Some(t) = apply(text, parts) { + fixes += 1; + let count = diags.iter().filter(|x| x.code() == d.code() && x.message == d.message).count(); + out.push(Edit { what: "widen-fix".into(), lint: d.code().into(), text: t, fixes: Some((d.message.clone(), count)), mono: false, wide: None, together: together(d.code(), alt - 1) }); + } + } + } + out +} + +// ---- the check ---- + +struct Ctx<'a> { + args: &'a Args, + test: &'a Test, + dir: &'a Path, + flags: Vec, +} + +impl Ctx<'_> { + fn diagnose(&self, name: &str, text: &str, extra: &[&str], emit: &str) -> (Status, Vec) { + let d = self.dir.join(name); + let _ = std::fs::create_dir_all(&d); + let src = d.join(self.test.file_name()); + let _ = std::fs::write(&src, text); + let c = Compile::new(&self.args.rustc, &src, &d, &self.flags, self.test.edition()) + .emit(emit) + .extra(extra.iter().copied()) + .json() + .timeout(120) + .run(); + (c.status, rustc::diagnostics(&c.stderr)) + } + + fn run(&self, name: &str, text: &str) -> Option { + let d = self.dir.join(name); + let _ = std::fs::create_dir_all(&d); + let src = d.join(self.test.file_name()); + let _ = std::fs::write(&src, text); + let c = Compile::new(&self.args.rustc, &src, &d, &self.flags, self.test.edition()).extra(["-Awarnings"]).timeout(300).run(); + let mut o = observe(c.binary.as_ref()?, 30, &[]); + o.stdout = normalize::stdout(&o.stdout); + o.stderr = normalize::stderr(&o.stderr); + Some(o) + } +} + +fn check(args: &Args, test: &Test) -> Rec { + let mut rec = Rec { test: test.rel.clone(), skip: None, lints: Vec::new(), compiles: 0, found: Vec::new(), expected: Vec::new() }; + let skip = |rec: &mut Rec, why: &str| rec.skip = Some(why.into()); + if BY_PATH.is_match(&test.text) { + skip(&mut rec, "uses files by path"); + return rec; + } + if test.text.contains('\r') || test.text.starts_with('\u{feff}') || (test.text.starts_with("#!") && !test.text.starts_with("#![")) { + skip(&mut rec, "CR, BOM or shebang"); + return rec; + } + let dir = driver::scratch_dir(&args.sweep); + let widen: Vec = if args.no_widen { Vec::new() } else { WIDEN.iter().map(|l| format!("-W{l}")).collect() }; + let mut ctx = Ctx { args, test, dir: dir.path(), flags: test.flags.iter().cloned().chain(widen.iter().cloned()).collect() }; + let (mut status, mut diags) = ctx.diagnose("base", &test.text, &[], "metadata"); + rec.compiles += 1; + if status == Status::Error && diags.iter().filter(|d| d.level == "error" && !SUMMARY.is_match(&d.message)).all(|d| is_lint(d.code())) { + // The test denies its lints: back to warnings. + ctx.flags.push("--cap-lints=warn".into()); + (status, diags) = ctx.diagnose("base", &test.text, &[], "metadata"); + rec.compiles += 1; + } + if status != Status::Ok { + skip(&mut rec, &format!("original {status:?}").to_lowercase()); + return rec; + } + let lints: BTreeSet = diags.iter().filter(|d| d.level == "warning" && is_lint(d.code())).map(|d| d.code().to_owned()).collect(); + rec.lints = lints.iter().cloned().collect(); + if lints.is_empty() { + return rec; + } + let mut found: Vec = Vec::new(); + let mut kept: Vec<(String, Vec)> = Vec::new(); + let base = keys(&diags, 0, test.file_name()); + + // allow: removes that lint's warnings and nothing else. + for lint in lints.iter().take(args.max_allow) { + let text = format!("#![allow({lint})]\n{}", test.text); + let (st, ad) = ctx.diagnose(&format!("allow-{lint}"), &text, &[], "metadata"); + rec.compiles += 1; + let after = keys(&ad, 1, test.file_name()); + let other = |m: &BTreeMap| -> BTreeMap { m.iter().filter(|(k, _)| k.1 != *lint).map(|(k, v)| (k.clone(), *v)).collect() }; + let (b, a) = (other(&base), other(&after)); + let added: Vec<&Key> = a.iter().filter(|(k, v)| b.get(*k).is_none_or(|x| x < *v)).map(|(k, _)| k).collect(); + let removed: Vec<&Key> = b.iter().filter(|(k, v)| a.get(*k).is_none_or(|x| x < *v)).map(|(k, _)| k).collect(); + let what = if st == Status::Ice { + Some("allow-ice") + } else if st != Status::Ok { + Some("allow-error") + } else if !added.is_empty() || !removed.is_empty() { + Some("allow-changes") + } else { + None + }; + if let Some(what) = what { + let name = format!("allow-{lint}.rs"); + found.push(serde_json::json!({ + "what": what, "lint": lint, "file": name, + "added": added.iter().take(5).map(|k| format!("{} {} {}: {}", k.0, k.1, k.3, k.2)).collect::>(), + "removed": removed.iter().take(5).map(|k| format!("{} {} {}: {}", k.0, k.1, k.3, k.2)).collect::>(), + })); + kept.push((name, text.into_bytes())); + } + } + + // delete, premise and widen-fix edits. + let ix = index(&test.text); + proc_macro2::extra::invalidate_current_thread_spans(); + let Some(ix) = ix else { + rec.found = summarize(&found); + if !found.is_empty() { + driver::write_finding(&args.sweep.work, test, &kept, &serde_json::json!({ "found": found })); + } + return rec; + }; + let runnable = test.kind == Some(Kind::RunPass); + let mut base_run: Option> = None; + let base_errors = errors(&diags); + for (i, e) in edits(args, &test.text, test.file_name(), &ix, &diags, NO_CORE.is_match(&test.text)).into_iter().enumerate() { + let name = format!("{}-{}-{i}", e.what, e.lint); + let mut e = e; + // Lints the edit trips (the test's own `deny`s) are not what is checked. + let mut extra: Vec<&str> = if ctx.flags.iter().any(|f| f.starts_with("--cap-lints")) { vec![] } else { vec!["--cap-lints=warn"] }; + if e.mono { + extra.push("-Clink-dead-code=yes"); + } + let emit = if e.mono { "obj" } else { "metadata" }; + if let Some((l, operand)) = e.wide.clone() { + // Peel references until the assertion holds; a level that cannot be dereferenced + // while the assertion still fails is a thin pointer. + let mut verdict = None; + for k in 0..4 { + let t = apply(&test.text, vec![(l.clone(), format!("(*__mirth_wide(&({}({operand})))))", "*".repeat(k)))]).unwrap_or_default() + WIDE_HELPER; + let (st, ed) = ctx.diagnose(&format!("{name}-{k}"), &t, &extra, emit); + rec.compiles += 1; + let failed = ed.iter().any(|d| d.level == "error" && d.message.contains("mirth: not a wide pointer")); + if st == Status::Ok { + verdict = None; + break; + } + if failed { + e.text = t; + verdict = Some(k); + continue; + } + break; // cannot dereference further: the last failure stands + } + if let Some(k) = verdict { + let file = format!("{name}.rs"); + found.push(serde_json::json!({ "what": "premise-breaks", "lint": e.lint, "file": file, "derefs": k, "first_error": "mirth: not a wide pointer" })); + kept.push((file, e.text.into_bytes())); + } + continue; + } + let (st, ed) = ctx.diagnose(&name, &e.text, &extra, emit); + rec.compiles += 1; + let new: Vec = errors(&ed).difference(&base_errors).cloned().collect(); + let mut what: Option = None; + let explained = (st == Status::Error) + .then(|| { + EXPLAINED.iter().find(|(lint, needle, codes, _)| { + *lint == e.lint && test.text.contains(needle) && (codes.is_empty() || (!new.is_empty() && new.iter().all(|c| codes.contains(&c.as_str())))) + }) + }) + .flatten(); + let together_ok = || { + e.together.as_ref().is_some_and(|t| { + let (st2, ed2) = ctx.diagnose(&format!("{name}-together"), t, &extra, emit); + st2 == Status::Ok && errors(&ed2).difference(&base_errors).next().is_none() + }) + }; + if let Some((_, _, _, why)) = explained { + rec.expected.push(format!("{}: {why}", e.lint)); + } else if e.lint == "dead_code" && st == Status::Error && unresolved_within(&e.text, &ed, test.file_name(), |ix| ix.exempt.clone()) { + // What dead code may use without being reported uses the deleted items. + rec.expected.push("dead_code: used by exempt dead code".into()); + } else if e.lint == "dead_code" && st == Status::Error && unresolved_within(&e.text, &ed, test.file_name(), |ix| ix.uses.iter().map(|(r, _)| r.clone()).collect()) { + // A `use` that is not itself a use (a re-export nothing reaches) names them. + rec.expected.push("dead_code: re-exported by an unreachable import".into()); + } else if e.lint == "dead_code" && st == Status::Error && unresolved_within(&e.text, &ed, test.file_name(), |ix| ix.macros.clone()) { + // Macro-generated code names the deleted items; the edit cannot see it. + rec.expected.push("dead_code: used by macro-generated code".into()); + } else if st == Status::Ice { + what = Some("ice".into()); + } else if e.what == "widen-fix" && st != Status::Ok && together_ok() { + // This fix needs another fix of the same lint; together they compile. + rec.expected.push(format!("{}: fixes depend on each other", e.lint)); + } else if st != Status::Ok || !new.is_empty() { + what = Some(format!("{}-breaks", e.what)); + } else if let Some((msg, count)) = &e.fixes + && ed.iter().filter(|x| x.code() == e.lint && x.message == *msg).count() >= *count + { + what = Some("widen-not-fixed".into()); + } else if runnable { + let b = base_run.get_or_insert_with(|| { + let a = ctx.run("run-base", &test.text); + let b2 = ctx.run("run-base2", &test.text); + if a == b2 { a } else { None } + }); + if let Some(b) = b + && let Some(o) = ctx.run(&format!("run-{name}"), &e.text) + && o != *b + { + what = Some(format!("{}-changes-output", e.what)); + } + } + if let Some(what) = what { + let file = format!("{name}.rs"); + found.push(serde_json::json!({ + "what": what, "lint": e.lint, "file": file, + "new_errors": new.iter().take(5).collect::>(), + "first_error": ed.iter().find(|d| d.level == "error").map(|d| d.message.clone()), + })); + kept.push((file, e.text.into_bytes())); + } + } + rec.found = summarize(&found); + if !found.is_empty() { + driver::write_finding(&args.sweep.work, test, &kept, &serde_json::json!({ "found": found })); + } + rec +} + +/// Every error of the edited file is a name that no longer resolves, inside the ranges `within` +/// picks: items dead code may use without being reported, or macros. +fn unresolved_within(text: &str, diags: &[Diagnostic], file: &str, within: impl Fn(&Index) -> Vec) -> bool { + const UNRESOLVED: &[&str] = &["E0405", "E0412", "E0422", "E0423", "E0425", "E0432", "E0433", "E0531"]; + let Some(mut ix) = index(text) else { return false }; + proc_macro2::extra::invalidate_current_thread_spans(); + let impls: Vec = ix.impls.iter().filter(|(_, t, _)| ix.exempt_types.contains(t)).map(|(r, _, _)| r.clone()).collect(); + ix.exempt.extend(impls); + let ranges = within(&ix); + let errs: Vec<&Diagnostic> = diags.iter().filter(|d| d.level == "error" && !SUMMARY.is_match(&d.message)).collect(); + !errs.is_empty() + && errs.iter().all(|d| { + UNRESOLVED.contains(&d.code()) && spans(d, file).iter().all(|sp| ranges.iter().any(|r| r.start <= sp.start && sp.end <= r.end)) + }) +} + +fn summarize(found: &[serde_json::Value]) -> Vec { + found.iter().map(|f| format!("{}: {}", f["what"].as_str().unwrap_or(""), f["lint"].as_str().unwrap_or(""))).collect() +} + +pub fn run(args: Args) -> anyhow::Result { + 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, t))) +} diff --git a/docs/checks.md b/docs/checks.md index 1e149f5..98662af 100644 --- a/docs/checks.md +++ b/docs/checks.md @@ -370,6 +370,27 @@ 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) + +| check | script | swept | result | +|---|---|---|---| +| lint oracles (16) | `mirth-lab lint-check` | 18,624 tests; the 8,055 that compile without errors (lints capped to warnings where a test denies them), with 16 allow-by-default lints turned on; 4,244 have a lint warning, 211 lints in all; 30,100 compilations | findings 33–36 (`let_underscore_drop`; lifetime-lint fixes; `dead_code` on needed traits and opaque-type definitions; `trivial_numeric_casts` on literals); known #110332 and #163369 reproduced; `unreachable_pub` and `missing_copy_implementations` edge cases noted in [`hunt/lint-check.md`](hunt/lint-check.md) | + +`lint-check` acts on each warning four ways, and reports when anything else changes: +- `#![allow(lint)]`; +- deleting what a premise lint flags (all `dead_code` items at once with their impls, each + `unreachable_patterns` arm, `unreachable_code` statements up to the block's tail); +- rewriting to what the premise says is equivalent (`trivial_casts` through a coercion site, + `trivial_numeric_casts` without the cast, `ambiguous_wide_pointer_comparisons` through a + const assertion that the operand is two words, `impl Copy` for + `missing_copy_implementations`); +- applying allow-by-default lints' machine-applicable fixes, which suggest-diff never sees, + alone and then all of a lint's together. + +Failures the lint's design or the edit explains are listed per test under `expected` in +`results.jsonl` and not counted: items only exempt dead code uses, re-exports, macro-generated +users, unreachable code that takes part in inference. + ## Running the checks The checks are subcommands of `mirth-lab` (`crates/mirth-lab`; `mirth-lab --help` lists them): @@ -380,6 +401,7 @@ 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 abi-diff --rustc $R --rust ~/mirth-work/rust --work --seed 3 +target/release/mirth-lab lint-check --rustc $R --tests $T --work 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..64fca3b 100644 --- a/docs/hunt.md +++ b/docs/hunt.md @@ -56,6 +56,10 @@ 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 | `let_underscore_drop` (allow-by-default): fires on `let _ = x;` with `x` a place, which neither moves nor drops it, and its "drop" fix moves the drop (output changes); its two machine-applicable fixes break builds: binding keeps borrowed temporaries alive (E0716), `drop(…)` loses the `let`'s type annotation (E0283) and expression attributes (`#[coroutine]`), and inside a macro rewrites the macro body (`drop()`, `drop($expr;`) | **looks new**, low (allow-by-default; `cargo fix` skips alternative suggestions, #104910); 1.98.0 and nightly; 26 UI tests; found by the lint-oracle check; [facts](hunt/lint-check.md) | +| 34 | lifetime-lint fixes that change meaning: `single_use_lifetimes` deletes a `#[may_dangle]` lifetime but not its attribute, which moves the unsafe promise to the next parameter and still compiles; it turns a derive field's `for<'a> fn(T::A<'a>)` into `'_` (E0637); `unused_lifetimes` removes the `for<'a>` that kept `where for<'a> Inherent: Clone` from being checked (E0277) | **looks new**, low (allow-by-default lints; `may_dangle` is unstable); 1.98.0 and nightly; found by the lint-oracle check; [facts](hunt/lint-check.md) | +| 35 | `dead_code` reports needed items as never used: a trait used only in the where-clause or a projection in the self type of an impl whose methods are called (stable since at least 1.80.0); the `#[define_opaque]` function that is an opaque type's only defining use (removing it: "unconstrained opaque type") | **looks new**; trait cases on stable (warn-by-default), opaque case nightly-only; 3 + 24 UI tests (one blesses the warning); found by the lint-oracle check; [facts](hunt/lint-check.md) | +| 36 | `trivial_numeric_casts` calls `5 as i16` an `i16`-to-`i16` cast, but the cast is what makes the literal `i16`: without it the program uses `i32` (prints 4, not 2; a `transmute` size mismatch in a UI test) | **looks new**, low (allow-by-default); 1.98.0 and nightly; found by the lint-oracle check; [repro](hunt/tests/lint-check/trivial-numeric-cast-literal.rs) | 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/lint-check.md b/docs/hunt/lint-check.md new file mode 100644 index 0000000..0b903b2 --- /dev/null +++ b/docs/hunt/lint-check.md @@ -0,0 +1,121 @@ +# Lint oracles: lints whose premise does not hold, and fixes that change the program + +Facts for findings 33–36. Found by the lint-oracle check ([`checks.md`](../checks.md), check 16: +`mirth-lab lint-check`). It runs over the standalone UI tests with a column of +allow-by-default lints turned on. For each lint that warns, it acts on the warning: it allows +the lint, removes what the lint calls unused or unreachable, rewrites code to what the lint's +premise says is equivalent, or applies the lint's machine-applicable suggestion. It then checks +that nothing else changed. Reductions are in [`tests/lint-check/`](tests/lint-check). "1.98.0" +means stable 1.98.0; "nightly" means the pinned nightly-2026-10-06. + +## 33. `let_underscore_drop`: the premise and both fixes + +`let_underscore_drop` (allow-by-default) warns on `let _ = ;` when the value has a +destructor. Its message: "non-binding let on a type that has a destructor". It offers two +machine-applicable fixes: "consider binding to an unused variable to avoid immediately dropping +the value" (`let _unused = …`) and "consider immediately dropping the value" +(`drop(…)`). + +| reduction | what happens | 1.98.0 | nightly | +|---|---|---|---| +| [`let-underscore-drop-place.rs`](tests/lint-check/let-underscore-drop-place.rs) | `let _ = x;` with `x` a local: the lint fires, but a place expression is neither moved nor dropped by `let _`. The `drop(x)` fix moves the drop from the end of `main` to that line: the program prints `drop x` before `end of main` instead of after | yes | yes | +| [`let-underscore-drop-temporary.rs`](tests/lint-check/let-underscore-drop-temporary.rs) | `let _ = A.borrow();` with `A` a `const RefCell`: the `_unused` fix keeps the `Ref` past the temporary it borrows: E0716 | yes | yes | +| [`let-underscore-drop-inference.rs`](tests/lint-check/let-underscore-drop-inference.rs) | `let _: Vec = Default::default();`: the `drop(…)` fix removes the type annotation with the `let`: E0283 | yes | yes | +| [`let-underscore-drop-macro.rs`](tests/lint-check/let-underscore-drop-macro.rs) | `let _ = wrap!(String::new());` where the macro expands to `identity($x)`: the `drop(…)` fix replaces `identity($x)` in the macro definition with `drop()` and leaves the `let` alone: E0061 | yes | yes | +| [`let-underscore-drop-coroutine.rs`](tests/lint-check/let-underscore-drop-coroutine.rs) | `let _ = #[coroutine] \|\| yield 42;`: the `drop(…)` fix drops the expression's attribute: "`yield` can only be used in `#[coroutine]` closures" | — (unstable) | yes | + +In the UI tests, 26 tests have a fix that does not compile or changes the output. Most fail to +infer a type once `drop(…)` replaces an annotated `let` (E0282, E0283, E0790). Others: borrowed +temporaries kept alive (E0716), `match` arms whose types the binding unified (E0308), three +coroutine tests that lose `#[coroutine]`, macros +(`lifetimes/rvalue-lifetime-drop-timing.rs`: the fix turns the macro body's +`let $pat = $expr;` into `drop($expr;`, a syntax error), and +`async-await/async-fn-send-uses-nonsend.rs`, where binding keeps a non-`Send` value alive +across an `await` ("future cannot be sent between threads safely"). In 5 run-pass tests a fix +compiles and changes the output: the drop-order tests (`destructuring-assignment/drop-order.rs`, +`drop/drop_order.rs`, `drop/issue-2735-2.rs`, `lifetimes/rvalue-lifetime-drop-timing.rs`). + +`cargo fix` applies neither fix: rustfix skips a diagnostic with alternative suggestions +(#104910, open). An editor's quick fix, or a tool that applies one suggestion, uses them. +Related, about the language and not the lint: #97305 (`let _ = var` does not move `var`, closed +as intended). + +## 34. Lifetime lints: fixes that change meaning + +| reduction | lint | what happens | 1.98.0 | nightly | +|---|---|---|---|---| +| [`single-use-lifetimes-may-dangle.rs`](tests/lint-check/single-use-lifetimes-may-dangle.rs) | `single_use_lifetimes` | in `unsafe impl<#[may_dangle] 'a, T> Drop for Pr<'a, T>`, the fix deletes `'a, ` but not its attribute. The result `unsafe impl<#[may_dangle] T> Drop for Pr<'_, T>` compiles: the unsafe `may_dangle` promise moved from `'a` to `T` | — (unstable attribute) | yes | +| [`single-use-lifetimes-derive-hrtb.rs`](tests/lint-check/single-use-lifetimes-derive-hrtb.rs) | `single_use_lifetimes` | `x: for<'a> fn(T::SomeType<'a>)` in a `#[derive(Clone)]` struct: the fix gives `fn(T::SomeType<'_>)`: E0637 "`'_` cannot be used here" | yes | yes | +| [`unused-lifetimes-global-bound.rs`](tests/lint-check/unused-lifetimes-global-bound.rs) | `unused_lifetimes` | `where for<'a> Inherent: Clone`: the binder keeps the bound from being global, so it is not checked at the definition. The fix removes `for<'a> ` and the bound is checked: E0277 | yes | yes | + +In the UI tests, `drop/dropck-eyepatch-reorder.rs` has the `may_dangle` case where the +attribute lands on a parameter that rejects it (E0199). Both lints are allow-by-default. +Earlier fixes in the same area, all closed: #117965 and #120148 (`single_use_lifetimes` fixes +that do not compile), #141758 (`unused_lifetimes` and unsafe binders). + +## 35. `dead_code`: items reported unused that the program needs + +`dead_code` is warn-by-default. "X is never used" invites deleting X; in these cases deleting it +(with everything else the lint reports in the same crate) breaks the program. + +| reduction | what happens | 1.80.0 | 1.98.0 | nightly | +|---|---|---|---|---| +| [`dead-code-trait-in-bound.rs`](tests/lint-check/dead-code-trait-in-bound.rs) | "trait `Bar` is never used": `Bar` is in the where-clause of `impl Foo where [u8; N]: Bar<[(); N]>`, and `main` calls that impl's `foo` | yes | yes | yes | +| [`dead-code-trait-in-projection.rs`](tests/lint-check/dead-code-trait-in-projection.rs) | "trait `Mirror` is never used": `::Me` is in the self type of `impl Foo::Me>`, whose `m` `main` calls | yes | yes | yes | +| [`dead-code-defining-use.rs`](tests/lint-check/dead-code-defining-use.rs) | "function `assign` is never used" for the `#[define_opaque(Qux)]` function that is the only defining use of a used opaque type; removing it gives "unconstrained opaque type" | — | — (unstable) | yes | + +The trait cases are three UI tests: `const-generics/issues/issue-69654-run-pass.rs` (which +blesses the warning: `//~ WARN trait `Bar` is never used`), +`nll/user-annotations/normalize-self-ty.rs` and `mir/issue-101844.rs`. The opaque-type case is +24 UI tests (`type-alias-impl-trait/*`, `lint/improper-ctypes/lint-73249-3.rs` and `-5.rs`, …). +Five more `dead_code` tests fail for reasons the check cannot settle (an item named through a +module path or a `decl_macro`, an `eii` attribute, an associated-const binding of the +incomplete `gca` feature); they are listed in the sweep's results, not counted here. Related +but different: #47569 (a struct used only through an associated constant, open), #110332 +(below). + +## 36. `trivial_numeric_casts` on an unsuffixed literal + +[`trivial-numeric-cast-literal.rs`](tests/lint-check/trivial-numeric-cast-literal.rs): +`let x = 5 as i16;` warns "trivial numeric cast: `i16` as `i16`". The literal is `i16` only +because of the cast: without it, `x` is `i32` and the program prints 4 instead of 2. In +`tests/ui/packed/packed-struct-generic-layout.rs` the cast picks a generic struct's field type +(`S { …, c: 0b10000001_10000001 as i16 }`); without it, a `transmute` between the struct and +an array no longer has matching sizes (E0512). Allow-by-default lint; 1.98.0 and nightly. +Related lint issues, none about literals: #23739 (type aliases, closed), #161339 (FnDef to fn +pointer, open). + +## Minor, not numbered + +- `unreachable_pub` (allow-by-default): its `pub(crate)`/`pub(super)` fix does not compile when + a `decl_macro` used elsewhere names the item (`hygiene/lexical.rs`), and in + `imports/overwrite-different-ambig-2.rs` the narrower visibility turns the + `ambiguous_glob_imports` warning into E0659. In + `test-attrs/custom-test-frameworks/issue-107454.rs` it fires on a non-`pub` `#[test_case]` + function and its fix (`pub(crate)fn`, no space) leaves the warning. 29 tests where one fix + needs another compile when all of a lint's fixes are applied together, which is what + `cargo fix` does; those are not counted. +- `missing_copy_implementations` (allow-by-default) on a type with a manual `ToOwned` impl: the + `Clone` that `Copy` requires conflicts with the blanket `impl ToOwned for T` + (`autoref-autoderef/auto-deref-on-cow-regression-91489.rs`). + +## Known, not counted + +- `dead_code` reports inherent associated types as never used (`associated-inherent-types/*`): + #110332, open. +- `unused_qualifications` removing a qualifier that the unqualified path needs to be + unambiguous (`imports/ambiguous-trait-with-mixed-import-paths.rs`, E0659): #163369, open. + +## What the check leaves out, and why + +- An item `dead_code` reports may be used by code the lint exempts: an item with + `#[allow(dead_code)]`, an impl of such a type, or an item named with `_`. Deleting it then + breaks those users. That is the lint's design (it reports what only dead code uses), so the + check deletes and accepts errors only inside such items. +- Unreachable code still takes part in type inference. Deleting an unreachable tail + expression, or a statement that fixed a type, can change what the block infers. The check + keeps tails and accepts inference errors (E0282–E0284). +- `missing_copy_implementations` in `staged_api` crates: the added `impl Copy` needs a + stability attribute. +- `#163604` (deprecation and the crate's `rust-version`): `-Zhint-msrv` filters only lints + declared with `@msrv`, and no lint at the pin declares one, so there is nothing to check yet. diff --git a/docs/hunt/tests/lint-check/dead-code-defining-use.rs b/docs/hunt/tests/lint-check/dead-code-defining-use.rs new file mode 100644 index 0000000..5a62233 --- /dev/null +++ b/docs/hunt/tests/lint-check/dead-code-defining-use.rs @@ -0,0 +1,16 @@ +#![feature(type_alias_impl_trait)] + +pub trait Baz {} +impl Baz for u32 {} + +pub type Qux = impl Baz; + +// warning: function `assign` is never used; removing it gives "unconstrained opaque type" +#[define_opaque(Qux)] +fn assign() -> Qux { + 3 +} + +pub fn take(_: Option) {} + +fn main() {} diff --git a/docs/hunt/tests/lint-check/dead-code-trait-in-bound.rs b/docs/hunt/tests/lint-check/dead-code-trait-in-bound.rs new file mode 100644 index 0000000..cfdfcf9 --- /dev/null +++ b/docs/hunt/tests/lint-check/dead-code-trait-in-bound.rs @@ -0,0 +1,17 @@ +// warning: trait `Bar` is never used. It is used: the impl below applies only where +// `[u8; N]: Bar<..>` holds, and `main` calls its `foo`. Removing `Bar` (and its impl) breaks +// the call. +trait Bar {} +impl Bar for [u8; 7] {} + +struct Foo; +impl Foo +where + [u8; N]: Bar<[(); N]>, +{ + fn foo() {} +} + +fn main() { + Foo::<7>::foo(); +} diff --git a/docs/hunt/tests/lint-check/dead-code-trait-in-projection.rs b/docs/hunt/tests/lint-check/dead-code-trait-in-projection.rs new file mode 100644 index 0000000..9a0074e --- /dev/null +++ b/docs/hunt/tests/lint-check/dead-code-trait-in-projection.rs @@ -0,0 +1,17 @@ +// warning: trait `Mirror` is never used. It is used: `::Me` is in the self type +// of the impl that `main` calls into. +trait Mirror { + type Me; +} +impl Mirror for T { + type Me = T; +} + +struct Foo(A, B); +impl Foo::Me> { + fn m(_: A) {} +} + +fn main() { + >::m(22); +} diff --git a/docs/hunt/tests/lint-check/let-underscore-drop-coroutine.rs b/docs/hunt/tests/lint-check/let-underscore-drop-coroutine.rs new file mode 100644 index 0000000..5787084 --- /dev/null +++ b/docs/hunt/tests/lint-check/let-underscore-drop-coroutine.rs @@ -0,0 +1,6 @@ +#![feature(coroutines, stmt_expr_attributes)] +#![warn(let_underscore_drop)] + +fn main() { + let _ = #[coroutine] || yield 42; +} diff --git a/docs/hunt/tests/lint-check/let-underscore-drop-inference.rs b/docs/hunt/tests/lint-check/let-underscore-drop-inference.rs new file mode 100644 index 0000000..03a8552 --- /dev/null +++ b/docs/hunt/tests/lint-check/let-underscore-drop-inference.rs @@ -0,0 +1,7 @@ +#![warn(let_underscore_drop)] + +fn main() { + let _ = Vec::::new().into_iter().collect::>(); + let _: Vec = Default::default(); + let _ = String::from("a").chars().rev().collect::(); +} diff --git a/docs/hunt/tests/lint-check/let-underscore-drop-macro.rs b/docs/hunt/tests/lint-check/let-underscore-drop-macro.rs new file mode 100644 index 0000000..0c3aa46 --- /dev/null +++ b/docs/hunt/tests/lint-check/let-underscore-drop-macro.rs @@ -0,0 +1,15 @@ +#![warn(let_underscore_drop)] + +macro_rules! wrap { + ($x:expr) => { + identity($x) + }; +} + +fn identity(x: T) -> T { + x +} + +fn main() { + let _ = wrap!(String::new()); +} diff --git a/docs/hunt/tests/lint-check/let-underscore-drop-place.rs b/docs/hunt/tests/lint-check/let-underscore-drop-place.rs new file mode 100644 index 0000000..ee6dbfa --- /dev/null +++ b/docs/hunt/tests/lint-check/let-underscore-drop-place.rs @@ -0,0 +1,14 @@ +#![warn(let_underscore_drop)] + +struct D(&'static str); +impl Drop for D { + fn drop(&mut self) { + println!("drop {}", self.0); + } +} + +fn main() { + let x = D("x"); + let _ = x; // a place: `x` is neither moved nor dropped here + println!("end of main"); +} diff --git a/docs/hunt/tests/lint-check/let-underscore-drop-temporary.rs b/docs/hunt/tests/lint-check/let-underscore-drop-temporary.rs new file mode 100644 index 0000000..e0d7792 --- /dev/null +++ b/docs/hunt/tests/lint-check/let-underscore-drop-temporary.rs @@ -0,0 +1,8 @@ +#![warn(let_underscore_drop)] +use std::cell::RefCell; + +const A: RefCell = RefCell::new(0); + +fn main() { + let _ = A.borrow(); +} diff --git a/docs/hunt/tests/lint-check/single-use-lifetimes-derive-hrtb.rs b/docs/hunt/tests/lint-check/single-use-lifetimes-derive-hrtb.rs new file mode 100644 index 0000000..0cb0fbe --- /dev/null +++ b/docs/hunt/tests/lint-check/single-use-lifetimes-derive-hrtb.rs @@ -0,0 +1,12 @@ +#![warn(single_use_lifetimes)] + +trait SomeTrait { + type SomeType<'a>; +} + +#[derive(Clone)] +struct Foo { + x: for<'a> fn(T::SomeType<'a>), +} + +fn main() {} diff --git a/docs/hunt/tests/lint-check/single-use-lifetimes-may-dangle.rs b/docs/hunt/tests/lint-check/single-use-lifetimes-may-dangle.rs new file mode 100644 index 0000000..55039ae --- /dev/null +++ b/docs/hunt/tests/lint-check/single-use-lifetimes-may-dangle.rs @@ -0,0 +1,14 @@ +#![feature(dropck_eyepatch)] +#![warn(single_use_lifetimes)] + +struct Pr<'a, T>(&'a T); + +// `'a` is used once, so the lint suggests `'_`. The machine-applicable fix deletes `'a, ` but not +// its attribute, giving `unsafe impl<#[may_dangle] T> Drop for Pr<'_, T>`, which compiles: the +// unsafe promise moved from `'a` to `T`. (In tests/ui/drop/dropck-eyepatch-reorder.rs the +// attribute lands where it is rejected instead: E0199.) +unsafe impl<#[may_dangle] 'a, T> Drop for Pr<'a, T> { + fn drop(&mut self) {} +} + +fn main() {} diff --git a/docs/hunt/tests/lint-check/trivial-numeric-cast-literal.rs b/docs/hunt/tests/lint-check/trivial-numeric-cast-literal.rs new file mode 100644 index 0000000..2277b82 --- /dev/null +++ b/docs/hunt/tests/lint-check/trivial-numeric-cast-literal.rs @@ -0,0 +1,7 @@ +#![warn(trivial_numeric_casts)] + +fn main() { + // warning: trivial numeric cast: `i16` as `i16`. Without the cast, `x` is an `i32`. + let x = 5 as i16; + println!("{}", std::mem::size_of_val(&x)); +} diff --git a/docs/hunt/tests/lint-check/unused-lifetimes-global-bound.rs b/docs/hunt/tests/lint-check/unused-lifetimes-global-bound.rs new file mode 100644 index 0000000..ed5dc66 --- /dev/null +++ b/docs/hunt/tests/lint-check/unused-lifetimes-global-bound.rs @@ -0,0 +1,13 @@ +#![warn(unused_lifetimes)] + +struct Inherent; + +// The binder keeps the bound from being global, so it is not checked here. Removing the +// "unused" `'a` as suggested gives E0277: `Inherent: Clone` is not satisfied. +fn do_it() +where + for<'a> Inherent: Clone, +{ +} + +fn main() {}