From 7ec58216ff1fd808451352d5b366d518d8e53858 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 17:43:48 -0700 Subject: [PATCH 01/34] add misbound_arg --- README.md | 1 + src/lib.rs | 4 +- src/misbound_arg.rs | 194 +++++++++++++++++++++++++++++++++++++++++ ui/misbound_arg.rs | 122 ++++++++++++++++++++++++++ ui/misbound_arg.stderr | 35 ++++++++ 5 files changed, 355 insertions(+), 1 deletion(-) create mode 100644 src/misbound_arg.rs create mode 100644 ui/misbound_arg.rs create mode 100644 ui/misbound_arg.stderr diff --git a/README.md b/README.md index 0ce7065..8eaf09b 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `stale_across_reentry` | a length, flag, or pointer read off a field of `self`, then a call that can re-enter (closure, fn pointer, `dyn`, `.await`, configured), then the field used through it | | `defaulted_failure` | `f(x).unwrap_or(0)` or `let Ok(v) = f(x) else { return Ok(()) }` where `f`'s own body rejects some of `x`: the rejection becomes a value and processing carries on | | `unchecked_input_len` | opt-in via `unchecked-input-len-enabled`: a received integer bounded on one path and turned into memory (`split_at`, `set_len`, `ptr.add`) on a path no check dominates | +| `misbound_arg` | `resize(height, width)` against `fn resize(width: u32, height: u32)`: an argument named as another parameter of the same type, so only its position says which it is | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/lib.rs b/src/lib.rs index ca83e55..8c61609 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -35,6 +35,7 @@ mod hir_shapes; mod insert_then_unwrap; mod lock_order; mod mir_flow; +mod misbound_arg; mod narrowed_return; mod nonidentity_key; mod overwide_parameter; @@ -153,7 +154,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto bypassed_validator::BypassedValidator, defaulted_failure::DefaultedFailure, discarded_error::DiscardedError, exclusive_options::ExclusiveOptions, flag_cluster::FlagCluster, forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, - insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, + insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, misbound_arg::MisboundArg, narrowed_return::NarrowedReturn, nonidentity_key::NonidentityKey, overwide_parameter::OverwideParameter, parallel_bools::ParallelBools, stale_across_reentry::StaleAcrossReentry, stale_panic_message::StalePanicMessage, @@ -198,6 +199,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, move || StaleAcrossReentry { config }); add(s, true, move || DefaultedFailure::new(config)); add(s, config.unchecked_input_len_enabled, || UncheckedInputLen); + add(s, true, || MisboundArg); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/src/misbound_arg.rs b/src/misbound_arg.rs new file mode 100644 index 0000000..f99c4a6 --- /dev/null +++ b/src/misbound_arg.rs @@ -0,0 +1,194 @@ +use rustc_hir::def::{DefKind, Res}; +use rustc_hir::{Expr, ExprKind, QPath, UnOp}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::Ty; +use rustc_span::Symbol; + +use crate::baseline::emit; +use crate::hir_shapes::{Callee, callee_of}; + +rustc_session::declare_lint! { + /// Flags a call argument whose own name is the name of a *different* + /// parameter of the callee than the one it is bound to, when both + /// parameters have the same type: `resize(height, width)` against + /// `fn resize(width: u32, height: u32)`, or `spawn(opts.inherit_stderr, + /// opts.inherit_stdout)`. The two values are told apart by position and + /// by nothing else, so the transposition type-checks; distinct types + /// per parameter (a newtype per quantity, an enum per flag) reject it. + /// + /// The argument's name is a local or parameter it is spelled as, or the + /// last field of a field access. Silent when the names agree after + /// dropping a leading `is_`/`has_`/`_` and a trailing `_`, when the + /// parameter the name points at already receives an argument of that + /// name, when the bound parameter's name contains the argument's as a + /// word (`from_index` receiving `index`), for one-character names, + /// method receivers, and calls through closures or fn pointers. + pub MISBOUND_ARG, + Warn, + "argument named as a different same-typed parameter of the callee" +} + +rustc_session::declare_lint_pass!(MisboundArg => [MISBOUND_ARG]); + +/// The name an argument is spelled with: a local `x`, or the last field of +/// `x.a.b`, through `&`, `*` and casts. Anything computed has no name to +/// cross. +fn arg_name(mut e: &Expr<'_>) -> Option { + loop { + match &e.kind { + ExprKind::Cast(inner, _) + | ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(UnOp::Deref, inner) + | ExprKind::DropTemps(inner) => e = inner, + ExprKind::Field(_, ident) => return Some(ident.name), + ExprKind::Path(QPath::Resolved(None, p)) + if p.segments.len() == 1 && matches!(p.res, Res::Local(_)) => + { + return Some(p.segments[0].ident.name); + } + _ => return None, + } + } +} + +/// `is_open_` and `open` name the same thing for this lint's purposes. +fn normalized(name: &str) -> &str { + let name = name.trim_start_matches('_').trim_end_matches('_'); + name.strip_prefix("is_") + .or_else(|| name.strip_prefix("has_")) + .unwrap_or(name) +} + +/// `from_file_index` qualifies `file_index` rather than naming another slot. +fn qualifies(param: &str, arg: &str) -> bool { + param.len() > arg.len() + && ((param.ends_with(arg) && param[..param.len() - arg.len()].ends_with('_')) + || (param.starts_with(arg) && param[arg.len()..].starts_with('_'))) +} + +struct Crossing<'tcx> { + /// Index into the call's argument list. + arg: usize, + name: Symbol, + bound_to: Symbol, + /// Signature index of the parameter the name points at. + names_param: usize, + ty: Ty<'tcx>, +} + +impl<'tcx> LateLintPass<'tcx> for MisboundArg { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + if expr.span.from_expansion() { + return; + } + let Some(callee) = callee_of(cx, expr) else { + return; + }; + let def = callee.def(); + if !matches!(cx.tcx.def_kind(def), DefKind::Fn | DefKind::AssocFn) { + return; + } + // A method call's receiver is bound to `self`; explicit args start + // one signature slot later. + let (args, offset) = match callee { + Callee::Path { args, .. } => (args, 0), + Callee::Method { args, .. } => (args, 1), + }; + let idents = cx.tcx.fn_arg_idents(def); + let sig = cx.tcx.erase_and_anonymize_regions( + cx.tcx.instantiate_bound_regions_with_erased( + cx.tcx + .fn_sig(def) + .instantiate_identity() + .skip_normalization(), + ), + ); + let inputs = sig.inputs(); + if idents.len() != inputs.len() || args.len() + offset > inputs.len() { + return; + } + let param_name = |i: usize| idents[i].map(|id| id.name); + // The name each signature slot actually receives at this call. + let received = |slot: usize| { + slot.checked_sub(offset) + .and_then(|i| args.get(i)) + .and_then(|a| arg_name(a)) + }; + let mut crossings: Vec> = Vec::new(); + for (i, arg) in args.iter().enumerate() { + if arg.span.from_expansion() { + continue; + } + let slot = i + offset; + let (Some(name), Some(bound_to)) = (arg_name(arg), param_name(slot)) else { + continue; + }; + let an = normalized(name.as_str()); + let bn = normalized(bound_to.as_str()); + if an.len() < 2 || bn.len() < 2 || an == bn || qualifies(bn, an) { + continue; + } + let Some(other) = (0..inputs.len()).find(|&q| { + q != slot + && inputs[q] == inputs[slot] + && param_name(q).is_some_and(|p| normalized(p.as_str()) == an) + }) else { + continue; + }; + // `f(name, name)`: the namesake parameter already gets its name, + // so nothing is transposed, one value fills two roles. + if received(other).is_some_and(|r| normalized(r.as_str()) == an) { + continue; + } + crossings.push(Crossing { + arg: i, + name, + bound_to, + names_param: other, + ty: inputs[slot], + }); + } + let fn_name = cx.tcx.item_name(def); + let mut reported = vec![false; crossings.len()]; + for (k, c) in crossings.iter().enumerate() { + if reported[k] { + continue; + } + reported[k] = true; + let partner = (k + 1..crossings.len()).find(|&j| { + let d = &crossings[j]; + d.arg + offset == c.names_param && c.arg + offset == d.names_param + }); + if let Some(j) = partner { + reported[j] = true; + let d = &crossings[j]; + emit( + cx, + MISBOUND_ARG, + expr.span, + format!( + "arguments `{}` and `{}` are bound to `{fn_name}`'s parameters `{}` and `{}`; \ + all are `{}`, so the transposition type-checks", + c.name, d.name, c.bound_to, d.bound_to, c.ty, + ), + "give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error", + ); + } else { + emit( + cx, + MISBOUND_ARG, + args[c.arg].span, + format!( + "argument `{}` is bound to `{fn_name}`'s parameter `{}`, but `{fn_name}` also takes \ + a parameter `{}` of the same type `{}`", + c.name, + c.bound_to, + param_name(c.names_param).unwrap_or(c.name), + c.ty, + ), + "give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error", + ); + } + } + } +} diff --git a/ui/misbound_arg.rs b/ui/misbound_arg.rs new file mode 100644 index 0000000..6dd6523 --- /dev/null +++ b/ui/misbound_arg.rs @@ -0,0 +1,122 @@ +// An argument named as one parameter must not be bound to another of the same type. + +fn resize(width: u32, height: u32) -> u32 { + width * 2 + height +} + +struct SpawnOptions { + inherit_stdout: bool, + inherit_stderr: bool, +} + +struct Daemon { + detached: bool, +} + +fn spawn(inherit_stdout: bool, inherit_stderr: bool) -> bool { + inherit_stdout && !inherit_stderr +} + +fn place(line: u32, column: u32, offset: u32) -> u32 { + line + column + offset +} + +fn open(path: &str, is_dir: bool, follow: bool) -> usize { + path.len() + usize::from(is_dir) + usize::from(follow) +} + +fn label(name: &str, alias: &str) -> usize { + name.len() + alias.len() +} + +fn trace(index: usize, from_index: usize) -> usize { + index - from_index +} + +fn scale(width: u32, height: f64) -> f64 { + f64::from(width) * height +} + +struct Canvas; + +impl Canvas { + fn blit(&self, src: usize, dst: usize) -> usize { + dst - src + } +} + +fn swapped_pair_is_flagged(width: u32, height: u32) -> u32 { + // Flagged: both names cross, reported once for the call. + resize(height, width) +} + +fn swapped_fields_are_flagged(opts: &SpawnOptions) -> bool { + // Flagged: the field names cross the parameter names. + spawn(opts.inherit_stderr, opts.inherit_stdout) +} + +fn one_misbound_is_flagged(column: u32, extra: u32) -> u32 { + // Flagged: `column` lands in `line` while a `column` parameter exists. + place(column, extra, 0) +} + +fn method_args_are_flagged(c: &Canvas, src: usize, dst: usize) -> usize { + // Flagged: receiver aside, the two indices are transposed. + c.blit(dst, src) +} + +fn correct_order_is_fine(width: u32, height: u32, opts: &SpawnOptions) -> u32 { + // Fine: every name sits in its own slot. + let _ = spawn(opts.inherit_stdout, opts.inherit_stderr); + resize(width, height) +} + +fn prefixed_names_are_fine(dir: bool, is_follow: bool) -> usize { + // Fine: `dir` is `is_dir` and `is_follow` is `follow` once prefixes go. + open("p", dir, is_follow) +} + +fn different_types_are_fine(height: u32, width: f64) -> f64 { + // Fine: `scale`'s own `height` is `f64`, so a `u32` named `height` in + // the `width` slot cannot be the transposed one. + scale(height, width) +} + +fn same_value_twice_is_fine(name: &str) -> usize { + // Fine: `name` also fills `name`; nothing is transposed. + label(name, name) +} + +fn qualified_param_is_fine(index: usize, next: usize) -> usize { + // Fine: `from_index` receiving `index` is the recursion's parent, not a swap. + trace(next, index) +} + +fn unnamed_args_are_fine(d: &Daemon) -> bool { + // Fine: a literal and an unrelated field carry no crossing name. + spawn(true, d.detached) +} + +fn closures_are_fine(width: u32, height: u32) -> u32 { + // Fine: a closure's parameters are not a signature anyone reads by name. + let f = |width: u32, height: u32| width + height; + f(height, width) +} + +fn main() { + let opts = SpawnOptions { + inherit_stdout: true, + inherit_stderr: false, + }; + let _ = swapped_pair_is_flagged(1, 2); + let _ = swapped_fields_are_flagged(&opts); + let _ = one_misbound_is_flagged(1, 2); + let _ = method_args_are_flagged(&Canvas, 1, 2); + let _ = correct_order_is_fine(1, 2, &opts); + let _ = prefixed_names_are_fine(true, false); + let _ = different_types_are_fine(2, 1.0); + let _ = same_value_twice_is_fine("n"); + let _ = qualified_param_is_fine(2, 3); + let _ = unnamed_args_are_fine(&Daemon { detached: false }); + let _ = closures_are_fine(1, 2); +} diff --git a/ui/misbound_arg.stderr b/ui/misbound_arg.stderr new file mode 100644 index 0000000..a1e2f3b --- /dev/null +++ b/ui/misbound_arg.stderr @@ -0,0 +1,35 @@ +warning: arguments `height` and `width` are bound to `resize`'s parameters `width` and `height`; all are `u32`, so the transposition type-checks + --> $DIR/misbound_arg.rs:50:5 + | +LL | resize(height, width) + | ^^^^^^^^^^^^^^^^^^^^^ + | + = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error + = note: `#[warn(misbound_arg)]` on by default + +warning: arguments `inherit_stderr` and `inherit_stdout` are bound to `spawn`'s parameters `inherit_stdout` and `inherit_stderr`; all are `bool`, so the transposition type-checks + --> $DIR/misbound_arg.rs:55:5 + | +LL | spawn(opts.inherit_stderr, opts.inherit_stdout) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error + +warning: argument `column` is bound to `place`'s parameter `line`, but `place` also takes a parameter `column` of the same type `u32` + --> $DIR/misbound_arg.rs:60:11 + | +LL | place(column, extra, 0) + | ^^^^^^ + | + = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error + +warning: arguments `dst` and `src` are bound to `blit`'s parameters `src` and `dst`; all are `usize`, so the transposition type-checks + --> $DIR/misbound_arg.rs:65:5 + | +LL | c.blit(dst, src) + | ^^^^^^^^^^^^^^^^ + | + = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error + +warning: 4 warnings emitted + From 9999b9001289351ed33a2ce0a53c887391849336 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 17:44:27 -0700 Subject: [PATCH 02/34] add bypassed_conversion --- README.md | 1 + src/adt_facts.rs | 23 +++ src/bypassed_conversion.rs | 322 ++++++++++++++++++++++++++++++++++ src/lib.rs | 9 +- ui/bypassed_conversion.rs | 220 +++++++++++++++++++++++ ui/bypassed_conversion.stderr | 94 ++++++++++ ui/bypassed_validator.rs | 1 + ui/bypassed_validator.stderr | 26 +-- 8 files changed, 680 insertions(+), 16 deletions(-) create mode 100644 src/bypassed_conversion.rs create mode 100644 ui/bypassed_conversion.rs create mode 100644 ui/bypassed_conversion.stderr diff --git a/README.md b/README.md index 8eaf09b..bd95161 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `defaulted_failure` | `f(x).unwrap_or(0)` or `let Ok(v) = f(x) else { return Ok(()) }` where `f`'s own body rejects some of `x`: the rejection becomes a value and processing carries on | | `unchecked_input_len` | opt-in via `unchecked-input-len-enabled`: a received integer bounded on one path and turned into memory (`split_at`, `set_len`, `ptr.add`) on a path no check dominates | | `misbound_arg` | `resize(height, width)` against `fn resize(width: u32, height: u32)`: an argument named as another parameter of the same type, so only its position says which it is | +| `bypassed_conversion` | `mem::transmute` or a pointer cast into a type outside its own module and impls, when a `From`/`TryFrom` impl or constructor already converts that same source into it | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/adt_facts.rs b/src/adt_facts.rs index 1f5d563..652dd4a 100644 --- a/src/adt_facts.rs +++ b/src/adt_facts.rs @@ -5,6 +5,8 @@ //! lint fire or not -- privacy, `is_struct`, a minimum field count -- stays in //! the lint, so this module only ever answers, never decides. +use rustc_hir::HirId; +use rustc_hir::def::DefKind; use rustc_hir::def_id::DefId; use rustc_lint::LateContext; use rustc_middle::ty::{self, AdtDef, FieldDef, Ty, TyCtxt, VariantDef}; @@ -107,3 +109,24 @@ pub(crate) fn result_err_ty<'tcx>(tcx: TyCtxt<'tcx>, ty: Ty<'tcx>) -> Option, at: HirId, adt: DefId) -> bool { + if let Some(local) = adt.as_local() + && cx.tcx.parent_module(at) == cx.tcx.parent_module_from_def_id(local) + { + return true; + } + let mut cur = cx.tcx.hir_enclosing_body_owner(at).to_def_id(); + while let Some(parent) = cx.tcx.opt_parent(cur) { + if matches!(cx.tcx.def_kind(parent), DefKind::Impl { .. }) + && impl_self_adt(cx, parent).is_some_and(|a| a.did() == adt) + { + return true; + } + cur = parent; + } + false +} diff --git a/src/bypassed_conversion.rs b/src/bypassed_conversion.rs new file mode 100644 index 0000000..02b1c08 --- /dev/null +++ b/src/bypassed_conversion.rs @@ -0,0 +1,322 @@ +use crate::adt_facts::in_own_code_of; +use crate::baseline::{emit, emit_with_note}; +use crate::hir_shapes::callee_of; +use rustc_hir::def_id::DefId; +use rustc_hir::{Expr, ExprKind}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::{self, Ty, TypeVisitableExt}; +use rustc_span::{Symbol, sym}; + +rustc_session::declare_lint! { + /// Flags a value of a struct, enum or union produced by reinterpreting + /// the bits of some other type -- `mem::transmute` / + /// `mem::transmute_copy`, or a pointer cast (`p as *const T`, + /// `p.cast::()`) between different pointee types -- when a conversion + /// from that same source type to that same target type already exists: + /// an `impl From for B`, an `impl TryFrom for B`, or a safe + /// receiver-less associated function of `B` taking one `A` and returning + /// `B`, `Option` or `Result`. That function is where the crate + /// decided which `A` values are a `B` and how; the reinterpreting site + /// takes any bit pattern, so whatever the conversion rejects or remaps + /// arrives as a `B` anyway, and for an enum an unlisted discriminant is + /// undefined behaviour on the spot. + /// + /// For a transmute of a value, every integer type counts as the same + /// source: `transmute::(n as u16)` had to pick the repr width, + /// and `E::from_raw(n: u32)` is still the conversion it skipped. For a + /// pointer cast the pointee must be exactly the conversion's input type, + /// so a byte buffer viewed as a header is not matched against + /// `Header::new(u32)`. + /// + /// Silent on: anything in the target type's own module or in any impl of + /// it, trait impls included, since that code is the conversion or sits + /// beside it; a transmute that only changes lifetimes or is otherwise + /// between the same type; a target that is not an ADT (fn pointers, + /// integers, type parameters); a pointer cast into a type with interior + /// mutability, which views the pointee in place (`usize` as + /// `AtomicUsize`) where a by-value `From` would make a new cell; a type + /// nothing converts into, which has no check to bypass; `unsafe fn` + /// constructors, which promise no check; and conversions whose input is + /// the target type itself or generic. + pub BYPASSED_CONVERSION, + Warn, + "bits reinterpreted as a type that has a conversion from the same source, which the site skips" +} + +/// One way the target type's crate (or a trait impl anywhere) turns `from` +/// into the target. +struct Conversion<'tcx> { + from: Ty<'tcx>, + def: DefId, + /// `Level::try_from`, `Code::from_raw`: how the message names it. + name: String, + /// Returns `Option`/`Result`: it can refuse a value, not just remap one. + fallible: bool, +} + +/// How the site got from one type to the other; decides whether integer +/// widths are interchangeable when matching a conversion's input. +#[derive(Clone, Copy, PartialEq)] +enum Shape { + Value, + Pointer, +} + +rustc_session::declare_lint_pass!(BypassedConversion => [BYPASSED_CONVERSION]); + +const TRANSMUTE: &str = "`mem::transmute`"; +const TRANSMUTE_COPY: &str = "`mem::transmute_copy`"; +const POINTER_CAST: &str = "a pointer cast"; + +/// `mem::transmute` / `mem::transmute_copy`, named the way the message shows +/// them. `transmute_copy` has no diagnostic item, hence the path test. +fn transmuter(cx: &LateContext<'_>, def: DefId) -> Option<&'static str> { + if cx.tcx.is_diagnostic_item(sym::transmute, def) { + return Some(TRANSMUTE); + } + (cx.tcx.crate_name(def.krate) == sym::core + && cx.tcx.item_name(def).as_str() == "transmute_copy" + && cx + .tcx + .opt_parent(def) + .is_some_and(|m| cx.tcx.opt_item_name(m) == Some(sym::mem))) + .then_some(TRANSMUTE_COPY) +} + +/// Strips one reference or raw pointer layer off both types at once, as +/// long as both have one: `&A -> &B` and `*const A -> *mut B` compare their +/// pointees, `usize -> *const B` compares nothing. +fn peel_pointer_pair<'tcx>(mut a: Ty<'tcx>, mut b: Ty<'tcx>) -> (Ty<'tcx>, Ty<'tcx>, Shape) { + let mut shape = Shape::Value; + loop { + let pointee = |t: Ty<'tcx>| match *t.kind() { + ty::Ref(_, inner, _) | ty::RawPtr(inner, _) => Some(inner), + _ => None, + }; + match (pointee(a), pointee(b)) { + (Some(pa), Some(pb)) => { + a = pa; + b = pb; + shape = Shape::Pointer; + } + _ => return (a, b, shape), + } + } +} + +/// Reports `expr`, which turns a `from` into a `to` by reinterpretation +/// (`how` names the means), when a conversion between the same pair exists +/// and the site is not the target type's own code. +fn check_reinterpretation<'tcx>( + cx: &LateContext<'tcx>, + expr: &'tcx Expr<'tcx>, + from: Ty<'tcx>, + to: Ty<'tcx>, + how: &'static str, +) { + if expr.span.in_external_macro(cx.tcx.sess.source_map()) { + return; + } + let tcx = cx.tcx; + let (from, to, shape) = peel_pointer_pair( + tcx.erase_and_anonymize_regions(from), + tcx.erase_and_anonymize_regions(to), + ); + if from == to { + return; + } + let ty::Adt(adt, _) = *to.kind() else { + return; + }; + // A pointer cast to a type with interior mutability views the pointee + // in place (a `usize` as an `AtomicUsize`); a by-value conversion into + // such a type makes a new cell and is no substitute for the view. + if from.ty_adt_def().is_some_and(|a| a.did() == adt.did()) + || (shape == Shape::Pointer && !to.is_freeze(tcx, cx.typing_env())) + || in_own_code_of(cx, expr.hir_id, adt.did()) + { + return; + } + let conversions = collect_conversions(cx, adt); + let same_integer = + |c: &&Conversion<'tcx>| shape == Shape::Value && c.from.is_integral() && from.is_integral(); + let Some(conv) = conversions + .iter() + .find(|c| c.from == from) + .or_else(|| conversions.iter().find(same_integer)) + else { + return; + }; + let consequence = if conv.fallible { + format!( + "`{}` converts `{}` to `{to}` and can refuse a value; this site accepts any bit pattern", + conv.name, conv.from + ) + } else { + format!( + "`{}` is how `{}` becomes `{to}`; this site goes around it", + conv.name, conv.from + ) + }; + let msg = format!("`{from}` is reinterpreted as `{to}` by {how} here, but {consequence}"); + let help = "convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on"; + if conv.def.is_local() { + emit_with_note( + cx, + BYPASSED_CONVERSION, + expr.span, + msg, + tcx.def_span(conv.def), + "the conversion this site skips", + help, + ); + } else { + emit(cx, BYPASSED_CONVERSION, expr.span, msg, help); + } +} + +/// Every `From`/`TryFrom` impl for the ADT and every safe receiver-less +/// inherent fn of it that takes one value and returns the ADT, bare or in +/// `Option`/`Result`. Inputs that are the ADT itself or mention a type +/// parameter convert nothing a reinterpreting site could have held. +fn collect_conversions<'tcx>( + cx: &LateContext<'tcx>, + adt: ty::AdtDef<'tcx>, +) -> Vec> { + let tcx = cx.tcx; + let target = adt.did(); + let self_ty = tcx + .type_of(target) + .instantiate_identity() + .skip_normalization(); + let is_target = |t: Ty<'tcx>| { + t.peel_refs() + .ty_adt_def() + .is_some_and(|a| a.did() == target) + }; + let usable_input = |t: Ty<'tcx>| !is_target(t) && !t.has_param(); + let name_of = |f: Symbol| format!("{}::{f}", tcx.item_name(target)); + let mut out = Vec::new(); + for (trait_sym, method, fallible) in + [(sym::From, "from", false), (sym::TryFrom, "try_from", true)] + { + let Some(trait_did) = tcx.get_diagnostic_item(trait_sym) else { + continue; + }; + for imp in tcx.non_blanket_impls_for_ty(trait_did, self_ty) { + let args = tcx + .impl_trait_ref(imp) + .instantiate_identity() + .skip_normalization() + .args; + if !is_target(args.type_at(0)) { + continue; + } + let from = tcx.erase_and_anonymize_regions(args.type_at(1)); + if !usable_input(from) { + continue; + } + let def = tcx + .associated_items(imp) + .in_definition_order() + .find(|i| i.is_fn()) + .map_or(imp, |i| i.def_id); + out.push(Conversion { + from, + def, + name: name_of(Symbol::intern(method)), + fallible, + }); + } + } + for &imp in tcx.inherent_impls(target) { + for item in tcx.associated_items(imp).in_definition_order() { + if !item.is_fn() || item.is_method() { + continue; + } + let sig = tcx + .fn_sig(item.def_id) + .instantiate_identity() + .skip_normalization() + .skip_binder(); + if sig.safety().is_unsafe() { + continue; + } + let [input] = sig.inputs() else { + continue; + }; + let from = tcx.erase_and_anonymize_regions(*input); + if !usable_input(from) { + continue; + } + let output = sig.output(); + let fallible = match *output.kind() { + ty::Adt(o, _) if o.did() == target => false, + ty::Adt(o, args) + if (tcx.is_diagnostic_item(sym::Option, o.did()) + || tcx.is_diagnostic_item(sym::Result, o.did())) + && matches!(*args.type_at(0).kind(), ty::Adt(inner, _) if inner.did() == target) => + { + true + } + _ => continue, + }; + out.push(Conversion { + from, + def: item.def_id, + name: name_of(item.name()), + fallible, + }); + } + } + out +} + +impl<'tcx> LateLintPass<'tcx> for BypassedConversion { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + let results = cx.typeck_results(); + match expr.kind { + ExprKind::Call(_, [arg]) => { + let Some(def) = callee_of(cx, expr).map(|c| c.def()) else { + return; + }; + let Some(how) = transmuter(cx, def) else { + return; + }; + // `transmute_copy(&src)` reads through the reference. + let from = match (how, results.expr_ty(arg).kind()) { + (TRANSMUTE_COPY, &ty::Ref(_, inner, _)) => inner, + _ => results.expr_ty(arg), + }; + check_reinterpretation(cx, expr, from, results.expr_ty(expr), how); + } + ExprKind::Cast(inner, _) if results.expr_ty(expr).is_raw_ptr() => { + check_reinterpretation( + cx, + expr, + results.expr_ty(inner), + results.expr_ty(expr), + POINTER_CAST, + ); + } + ExprKind::MethodCall(_, recv, [], _) => { + let Some(def) = results.type_dependent_def_id(expr.hir_id) else { + return; + }; + if !(cx.tcx.is_diagnostic_item(sym::ptr_cast, def) + || cx.tcx.is_diagnostic_item(sym::const_ptr_cast, def)) + { + return; + } + check_reinterpretation( + cx, + expr, + results.expr_ty(recv), + results.expr_ty(expr), + POINTER_CAST, + ); + } + _ => {} + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 8c61609..495400b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ use rustc_data_structures::sync; mod adt_facts; mod asymmetric_guard; mod baseline; +mod bypassed_conversion; mod bypassed_validator; mod claims; mod ctor_flow; @@ -151,9 +152,10 @@ pub struct MordantConfig { pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintStore) { use { asymmetric_guard::AsymmetricGuard, baseline::BaselineWriter, - bypassed_validator::BypassedValidator, defaulted_failure::DefaultedFailure, - discarded_error::DiscardedError, exclusive_options::ExclusiveOptions, - flag_cluster::FlagCluster, forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, + bypassed_conversion::BypassedConversion, bypassed_validator::BypassedValidator, + defaulted_failure::DefaultedFailure, discarded_error::DiscardedError, + exclusive_options::ExclusiveOptions, flag_cluster::FlagCluster, + forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, misbound_arg::MisboundArg, narrowed_return::NarrowedReturn, nonidentity_key::NonidentityKey, overwide_parameter::OverwideParameter, parallel_bools::ParallelBools, @@ -200,6 +202,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, move || DefaultedFailure::new(config)); add(s, config.unchecked_input_len_enabled, || UncheckedInputLen); add(s, true, || MisboundArg); + add(s, true, || BypassedConversion); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/ui/bypassed_conversion.rs b/ui/bypassed_conversion.rs new file mode 100644 index 0000000..087f68b --- /dev/null +++ b/ui/bypassed_conversion.rs @@ -0,0 +1,220 @@ +// A transmute or pointer cast producing a type that something already converts +// the same source into is flagged outside that type's module and impls; the +// type's own code, identity transmutes, and types nothing converts into are not. + +mod level { + #[derive(Clone, Copy, PartialEq, Debug)] + #[repr(u8)] + pub enum Level { + Low = 0, + Mid = 1, + High = 2, + } + + impl std::convert::TryFrom for Level { + type Error = u8; + fn try_from(n: u8) -> Result { + match n { + 0 => Ok(Level::Low), + 1 => Ok(Level::Mid), + 2 => Ok(Level::High), + _ => Err(n), + } + } + } + + // Fine: the type's own module decides the layout; this sits beside the check. + pub fn trusted(n: u8) -> Level { + unsafe { core::mem::transmute::(n) } + } +} + +mod code { + #[derive(Clone, Copy)] + #[repr(u16)] + pub enum Code { + Ok = 0, + Retry = 1, + } + + impl Code { + pub fn from_raw(n: u32) -> Option { + match n { + 0 => Some(Code::Ok), + 1 => Some(Code::Retry), + _ => None, + } + } + } +} + +mod fd { + #[repr(transparent)] + pub struct Fd(pub(crate) i32); + + impl Fd { + // An infallible conversion still decides how an i32 becomes an Fd. + pub fn new(raw: i32) -> Fd { + Fd(if raw < 0 { -1 } else { raw }) + } + + // Fine as a conversion source: `unsafe fn` promises no check. + pub unsafe fn adopt(raw: i64) -> Fd { + Fd(raw as i32) + } + } +} + +mod meters { + #[derive(Clone, Copy)] + #[repr(transparent)] + pub struct Meters(pub(crate) u32); + + impl From for Meters { + fn from(n: u32) -> Meters { + Meters(n.min(40_000_000)) + } + } +} + +mod raw { + // Nothing converts into this: a transmute is the only door there is. + #[derive(Clone, Copy)] + #[repr(u8)] + pub enum Raw { + A = 0, + B = 1, + } + + // Only an unsafe constructor: it promises nothing a transmute skips. + #[repr(transparent)] + pub struct Slot(pub(crate) u8); + + impl Slot { + pub unsafe fn from_raw(n: u8) -> Slot { + Slot(n) + } + } + + pub struct View<'a> { + pub bytes: &'a [u8], + } + + impl<'a> View<'a> { + pub fn parse(bytes: &'a [u8]) -> Option> { + (!bytes.is_empty()).then_some(View { bytes }) + } + } +} + +use std::convert::TryFrom; + +use code::Code; +use fd::Fd; +use level::Level; +use meters::Meters; +use raw::{Raw, Slot, View}; + +// Flagged: `Level::try_from` exists for exactly this pair. +fn level_from_wire(n: u8) -> Level { + unsafe { core::mem::transmute::(n) } +} + +// Flagged: the conversion takes u32; a value transmute from any integer skips it. +fn code_from_wire(n: u32) -> Code { + unsafe { core::mem::transmute::(n as u16) } +} + +// Flagged: transmute_copy reads through the reference and reinterprets. +fn code_copied(n: &u16) -> Code { + unsafe { core::mem::transmute_copy::(n) } +} + +// Flagged: an infallible constructor is still the conversion this skips. +fn fd_from_env(raw: i32) -> Fd { + unsafe { core::mem::transmute::(raw) } +} + +// Flagged: a pointer cast between the exact pair `From for Meters` covers. +fn meters_in_place(n: &u32) -> Meters { + unsafe { *(n as *const u32 as *const Meters) } +} + +// Flagged: `.cast()` is the same reinterpretation. +fn meters_read(p: *const u32) -> Meters { + unsafe { p.cast::().read() } +} + +// Flagged: transmuting references compares the pointees. +fn meters_ref(n: &u32) -> &Meters { + unsafe { core::mem::transmute::<&u32, &Meters>(n) } +} + +// Fine: a byte buffer viewed as the type; pointer casts need the exact source. +fn meters_from_bytes(bytes: &[u8]) -> Meters { + unsafe { bytes.as_ptr().cast::().read_unaligned() } +} + +// Fine: nothing converts a u8 into Raw, so there is no check to skip. +fn raw_from_wire(n: u8) -> Raw { + unsafe { core::mem::transmute::(n) } +} + +// Fine: Slot's only constructor is unsafe. +fn slot_from_wire(n: u8) -> Slot { + unsafe { core::mem::transmute::(n) } +} + +// Fine: only the lifetime changes; the value already went through `parse`. +fn extend<'a>(v: View<'a>) -> View<'static> { + unsafe { core::mem::transmute::, View<'static>>(v) } +} + +// Fine: a trait impl of the type is its own code wherever it is written. +impl Default for Level { + fn default() -> Self { + unsafe { core::mem::transmute::(0) } + } +} + +// Fine: an integer-to-pointer cast reinterprets no pointee. +fn from_addr(addr: usize) -> *const Meters { + addr as *const Meters +} + +// Fine: casting to the same pointee, mutability aside. +fn constness(p: *mut Meters) -> *const Meters { + p as *const Meters +} + +// Fine: a type with interior mutability is viewed in place; `From` would make +// a new cell, not a view of this one. +fn as_atomic(n: &usize) -> usize { + use std::sync::atomic::{AtomicUsize, Ordering}; + unsafe { (*(n as *const usize).cast::()).load(Ordering::Relaxed) } +} + +fn main() { + let _ = level_from_wire(1); + let _ = code_from_wire(1); + let _ = code_copied(&1); + let _ = fd_from_env(2); + let _ = meters_in_place(&3); + let _ = meters_read(&3); + let _ = meters_ref(&3); + let _ = meters_from_bytes(&[0, 0, 0, 0]); + let _ = raw_from_wire(0); + let _ = slot_from_wire(0); + let _ = extend(View::parse(b"x").unwrap()).bytes; + let _ = Level::default(); + let _ = from_addr(8); + let _ = constness(core::ptr::null_mut()); + let _ = as_atomic(&0); + let _ = level::trusted(0); + let _ = Code::from_raw(0); + let _ = Fd::new(0).0; + let _ = unsafe { Fd::adopt(0) }.0; + let _ = Meters::from(1).0; + let _ = Level::try_from(9); + let _ = unsafe { Slot::from_raw(0) }.0; +} diff --git a/ui/bypassed_conversion.stderr b/ui/bypassed_conversion.stderr new file mode 100644 index 0000000..63ddd76 --- /dev/null +++ b/ui/bypassed_conversion.stderr @@ -0,0 +1,94 @@ +warning: `u8` is reinterpreted as `level::Level` by `mem::transmute` here, but `Level::try_from` converts `u8` to `level::Level` and can refuse a value; this site accepts any bit pattern + --> $DIR/bypassed_conversion.rs:120:14 + | +LL | unsafe { core::mem::transmute::(n) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:16:9 + | +LL | fn try_from(n: u8) -> Result { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + = note: `#[warn(bypassed_conversion)]` on by default + +warning: `u16` is reinterpreted as `code::Code` by `mem::transmute` here, but `Code::from_raw` converts `u32` to `code::Code` and can refuse a value; this site accepts any bit pattern + --> $DIR/bypassed_conversion.rs:125:14 + | +LL | unsafe { core::mem::transmute::(n as u16) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:41:9 + | +LL | pub fn from_raw(n: u32) -> Option { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `u16` is reinterpreted as `code::Code` by `mem::transmute_copy` here, but `Code::from_raw` converts `u32` to `code::Code` and can refuse a value; this site accepts any bit pattern + --> $DIR/bypassed_conversion.rs:130:14 + | +LL | unsafe { core::mem::transmute_copy::(n) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:41:9 + | +LL | pub fn from_raw(n: u32) -> Option { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `i32` is reinterpreted as `fd::Fd` by `mem::transmute` here, but `Fd::new` is how `i32` becomes `fd::Fd`; this site goes around it + --> $DIR/bypassed_conversion.rs:135:14 + | +LL | unsafe { core::mem::transmute::(raw) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:57:9 + | +LL | pub fn new(raw: i32) -> Fd { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `u32` is reinterpreted as `meters::Meters` by a pointer cast here, but `Meters::from` is how `u32` becomes `meters::Meters`; this site goes around it + --> $DIR/bypassed_conversion.rs:140:15 + | +LL | unsafe { *(n as *const u32 as *const Meters) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:74:9 + | +LL | fn from(n: u32) -> Meters { + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `u32` is reinterpreted as `meters::Meters` by a pointer cast here, but `Meters::from` is how `u32` becomes `meters::Meters`; this site goes around it + --> $DIR/bypassed_conversion.rs:145:14 + | +LL | unsafe { p.cast::().read() } + | ^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:74:9 + | +LL | fn from(n: u32) -> Meters { + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `u32` is reinterpreted as `meters::Meters` by `mem::transmute` here, but `Meters::from` is how `u32` becomes `meters::Meters`; this site goes around it + --> $DIR/bypassed_conversion.rs:150:14 + | +LL | unsafe { core::mem::transmute::<&u32, &Meters>(n) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:74:9 + | +LL | fn from(n: u32) -> Meters { + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: 7 warnings emitted + diff --git a/ui/bypassed_validator.rs b/ui/bypassed_validator.rs index 6bbea02..cad81fe 100644 --- a/ui/bypassed_validator.rs +++ b/ui/bypassed_validator.rs @@ -331,6 +331,7 @@ mod outside { } // Values that never went through the constructor at all. + #[allow(unknown_lints, bypassed_conversion)] fn conjured() -> (inner::Level, gate::Gate, gate::Gate, Free) { unsafe { ( diff --git a/ui/bypassed_validator.stderr b/ui/bypassed_validator.stderr index a92785b..5eccf2d 100644 --- a/ui/bypassed_validator.stderr +++ b/ui/bypassed_validator.stderr @@ -78,7 +78,7 @@ LL | if port > 65535 { = help: construct through the validating function, or move this literal into the type's module warning: `inner::Level` is produced by `mem::zeroed` here, but `Level::new` checks `value` before constructing one - --> $DIR/bypassed_validator.rs:337:17 + --> $DIR/bypassed_validator.rs:338:17 | LL | std::mem::zeroed::(), | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -91,78 +91,78 @@ LL | if v <= 10 { Ok(Level { value: v }) } else { Err(()) } = help: construct through the validating function warning: `gate::Gate` is produced by `mem::transmute` here, but `Gate::new` checks `v` before constructing one - --> $DIR/bypassed_validator.rs:338:17 + --> $DIR/bypassed_validator.rs:339:17 | LL | std::mem::transmute::(0), | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | note: the check this value never went through - --> $DIR/bypassed_validator.rs:406:16 + --> $DIR/bypassed_validator.rs:407:16 | LL | if v < 2 { Some(Gate { v }) } else { None } | ^^^^^ = help: construct through the validating function warning: `gate::Gate` is produced by `MaybeUninit::assume_init` here, but `Gate::new` checks `v` before constructing one - --> $DIR/bypassed_validator.rs:339:17 + --> $DIR/bypassed_validator.rs:340:17 | LL | std::mem::MaybeUninit::::zeroed().assume_init(), | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | note: the check this value never went through - --> $DIR/bypassed_validator.rs:406:16 + --> $DIR/bypassed_validator.rs:407:16 | LL | if v < 2 { Some(Gate { v }) } else { None } | ^^^^^ = help: construct through the validating function warning: `widened::Tag::s` is written directly here, but `Tag::new` rejects some values of `s` before storing one - --> $DIR/bypassed_validator.rs:432:13 + --> $DIR/bypassed_validator.rs:433:13 | LL | u.s = ""; | ^^^^^^^^ | note: the check this write never runs - --> $DIR/bypassed_validator.rs:422:16 + --> $DIR/bypassed_validator.rs:423:16 | LL | if s.is_empty() { None } else { Some(Tag { s }) } | ^^^^^^^^^^^^ = help: change the value through the validating function, or make the field private and move this write into the type's module warning: `promoted::Slot` is produced by `mem::transmute` here, but `Slot::promote` checks `n` before constructing one - --> $DIR/bypassed_validator.rs:465:22 + --> $DIR/bypassed_validator.rs:466:22 | LL | unsafe { std::mem::transmute::, Slot>(s) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | note: the check this value never went through - --> $DIR/bypassed_validator.rs:451:16 + --> $DIR/bypassed_validator.rs:452:16 | LL | if n == 0 { None } else { Some(Slot { n, state: PhantomData }) } | ^^^^^^ = help: construct through the validating function warning: `two_validators::Pair` is constructed by literal here, but `Pair::parse` checks `b` before constructing one - --> $DIR/bypassed_validator.rs:492:13 + --> $DIR/bypassed_validator.rs:493:13 | LL | Pair { b: 200, ..base } | ^^^^^^^^^^^^^^^^^^^^^^^ | note: the check this literal never runs - --> $DIR/bypassed_validator.rs:482:16 + --> $DIR/bypassed_validator.rs:483:16 | LL | if b > 99 { None } else { Some(Pair { a: 0, b }) } | ^^^^^^ = help: construct through the validating function, or move this literal into the type's module warning: `tuple::Digit` is constructed by literal here, but `Digit::new` checks `0` before constructing one - --> $DIR/bypassed_validator.rs:514:14 + --> $DIR/bypassed_validator.rs:515:14 | LL | (Digit(10), Plain(10)) | ^^^^^^^^^ | note: the check this literal never runs - --> $DIR/bypassed_validator.rs:502:16 + --> $DIR/bypassed_validator.rs:503:16 | LL | if d > 9 { None } else { Some(Digit(d)) } | ^^^^^ From 13aaef5f7f7f75d9e94dab4ccb5fc20b5517a58f Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 17:47:56 -0700 Subject: [PATCH 03/34] add same_match_twice and reimplemented_helper --- README.md | 3 + src/adt_facts.rs | 20 +++ src/hir_clone.rs | 139 +++++++++++++++++++++ src/lib.rs | 11 ++ src/reimplemented_helper.rs | 138 ++++++++++++++++++++ src/same_match_twice.rs | 136 ++++++++++++++++++++ src/unread_error_variant.rs | 24 +--- ui/reimplemented_helper.rs | 107 ++++++++++++++++ ui/reimplemented_helper.stderr | 29 +++++ ui/same_match_twice.rs | 222 +++++++++++++++++++++++++++++++++ ui/same_match_twice.stderr | 73 +++++++++++ ui/stringly_error.stderr | 16 ++- ui/wildcard_local_enum.stderr | 25 +++- 13 files changed, 918 insertions(+), 25 deletions(-) create mode 100644 src/hir_clone.rs create mode 100644 src/reimplemented_helper.rs create mode 100644 src/same_match_twice.rs create mode 100644 ui/reimplemented_helper.rs create mode 100644 ui/reimplemented_helper.stderr create mode 100644 ui/same_match_twice.rs create mode 100644 ui/same_match_twice.stderr diff --git a/README.md b/README.md index bd95161..033c0d7 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,8 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `unchecked_input_len` | opt-in via `unchecked-input-len-enabled`: a received integer bounded on one path and turned into memory (`split_at`, `set_len`, `ptr.add`) on a path no check dominates | | `misbound_arg` | `resize(height, width)` against `fn resize(width: u32, height: u32)`: an argument named as another parameter of the same type, so only its position says which it is | | `bypassed_conversion` | `mem::transmute` or a pointer cast into a type outside its own module and impls, when a `From`/`TryFrom` impl or constructor already converts that same source into it | +| `same_match_twice` | the same `match` over one enum written out arm for arm in two places: a mapping the enum should state once as a method, kept in step by hand instead | +| `reimplemented_helper` | a function whose signature and body repeat another function in the crate under a different name: one helper written twice, so a fix to one copy misses the other | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. @@ -84,6 +86,7 @@ wildcard-local-enum-max-variants = 12 exclusive-options-min-fields = 2 flag-cluster-min-bools = 3 stored-projection-min-sites = 2 +reimplemented-helper-min-nodes = 12 # Opt-in: also count `Box` as a stringly error type. stringly-error-include-box-dyn = true diff --git a/src/adt_facts.rs b/src/adt_facts.rs index 652dd4a..d387259 100644 --- a/src/adt_facts.rs +++ b/src/adt_facts.rs @@ -130,3 +130,23 @@ pub(crate) fn in_own_code_of(cx: &LateContext<'_>, at: HirId, adt: DefId) -> boo } false } + +/// True when `hir_id` sits inside a TRAIT impl whose self type is `adt_did`. +/// `Display`, `Debug`, `From` and derive expansions must match every variant +/// to exist, so their patterns prove nothing. Inherent methods are not +/// excluded: an accessor like `fn tenths(&self)` is the crate genuinely +/// reading the structure. +pub(crate) fn inside_own_trait_impl(cx: &LateContext<'_>, hir_id: HirId, adt_did: DefId) -> bool { + let mut cur = hir_id.owner.def_id.to_def_id(); + loop { + if matches!(cx.tcx.def_kind(cur), DefKind::Impl { of_trait: true }) + && impl_self_adt(cx, cur).is_some_and(|adt| adt.did() == adt_did) + { + return true; + } + match cx.tcx.opt_parent(cur) { + Some(p) => cur = p, + None => return false, + } + } +} diff --git a/src/hir_clone.rs b/src/hir_clone.rs new file mode 100644 index 0000000..9b6c122 --- /dev/null +++ b/src/hir_clone.rs @@ -0,0 +1,139 @@ +//! Structural identity of HIR: a hash to bucket candidates and an equality +//! to confirm them, both blind to spans, `HirId`s and binding names, both +//! reading paths by what they resolve to. Thin over +//! `clippy_utils::hir_utils`; what lives here is the pairing of local +//! bindings across two bodies, which `SpanlessEq` leaves to its caller (a +//! `Res::Local` on the left equals one on the right only when the two are +//! pre-mapped), and never `deny_side_effects`, since that makes every method +//! call unequal to itself. +//! +//! Hash where typeck results exist (`check_fn`, `check_expr`); the equality +//! functions work from `check_crate_post`, where the context has no +//! enclosing body, by fetching each side's typeck results themselves. + +use clippy_utils::{SpanlessEq, SpanlessHash}; +use rustc_hir::def::Res; +use rustc_hir::def_id::LocalDefId; +use rustc_hir::intravisit::{Visitor, walk_expr, walk_pat}; +use rustc_hir::{BodyId, Expr, ExprKind, HirId, HirIdSet, Pat, PatKind, QPath}; +use rustc_lint::LateContext; +use rustc_span::SyntaxContext; + +/// Structural hash of a fn body: spans, `HirId`s and binding names do not +/// contribute; resolved paths, literals, field and method names, operators +/// and shape do. +pub(crate) fn body_hash(cx: &LateContext<'_>, body: BodyId) -> u64 { + let mut h = SpanlessHash::new(cx).paths_by_resolution(); + h.hash_body(body); + h.finish() +} + +/// The bindings the body's parameter patterns introduce, in source order. +fn param_bindings(cx: &LateContext<'_>, body: BodyId) -> Vec { + let mut ids = Vec::new(); + for param in cx.tcx.hir_body(body).params { + param + .pat + .each_binding_or_first(&mut |_, id, _, _| ids.push(id)); + } + ids +} + +/// Two fn bodies are the same computation up to renaming of parameters and +/// locals. Callers must have checked the two signatures are equal first +/// (`fn_sigs_equal`): method calls compare by name, so `.len()` on two +/// different receiver types is the same call to this function. Two bodies +/// containing a closure are never equal (`SpanlessEq` refuses closures). +pub(crate) fn bodies_equal(cx: &LateContext<'_>, l: BodyId, r: BodyId) -> bool { + let (lp, rp) = (param_bindings(cx, l), param_bindings(cx, r)); + if lp.len() != rp.len() { + return false; + } + let mut eq = SpanlessEq::new(cx).paths_by_resolution(); + let mut ie = eq.inter_expr(SyntaxContext::root()); + ie.locals.extend(lp.into_iter().zip(rp)); + ie.eq_body(l, r) +} + +/// Erased signatures equal: same arity, each input and the output the same +/// `Ty` once late-bound regions are erased. For methods input 0 is `Self`, +/// which is what keeps `Foo::is_empty` and `Bar::is_empty` apart. +pub(crate) fn fn_sigs_equal(cx: &LateContext<'_>, l: LocalDefId, r: LocalDefId) -> bool { + let sig = |d: LocalDefId| { + cx.tcx.instantiate_bound_regions_with_erased( + cx.tcx + .fn_sig(d.to_def_id()) + .instantiate_identity() + .skip_normalization(), + ) + }; + sig(l).inputs_and_output == sig(r).inputs_and_output +} + +/// Structural hash of one expression, for bucketing across bodies. Every +/// local hashes alike, so which binding an arm reads never separates two +/// buckets; `exprs_equal` decides that. +pub(crate) fn expr_hash(cx: &LateContext<'_>, e: &Expr<'_>) -> u64 { + let mut h = SpanlessHash::new(cx).paths_by_resolution(); + h.hash_expr(e); + h.finish() +} + +/// The locals `e` reads but does not bind, in order of first use. +fn free_locals(e: &Expr<'_>) -> Vec { + struct V { + bound: HirIdSet, + free: Vec, + } + impl<'tcx> Visitor<'tcx> for V { + fn visit_pat(&mut self, p: &'tcx Pat<'tcx>) { + if let PatKind::Binding(_, id, ..) = p.kind { + self.bound.insert(id); + } + walk_pat(self, p); + } + fn visit_expr(&mut self, e: &'tcx Expr<'tcx>) { + if let ExprKind::Path(QPath::Resolved(None, path)) = e.kind + && let Res::Local(id) = path.res + && !self.bound.contains(&id) + && !self.free.contains(&id) + { + self.free.push(id); + } + walk_expr(self, e); + } + } + let mut v = V { + bound: HirIdSet::default(), + free: Vec::new(), + }; + v.visit_expr(e); + v.free +} + +/// Two expressions from two bodies (each given with its body owner) are the +/// same computation up to renaming: the locals each reads from outside +/// itself are paired in order of first use and must have the same type in +/// their own body, and under that pairing the two are structurally equal. +pub(crate) fn exprs_equal( + cx: &LateContext<'_>, + (l_owner, l): (LocalDefId, &Expr<'_>), + (r_owner, r): (LocalDefId, &Expr<'_>), +) -> bool { + let (lf, rf) = (free_locals(l), free_locals(r)); + if lf.len() != rf.len() { + return false; + } + let (lt, rt) = (cx.tcx.typeck(l_owner), cx.tcx.typeck(r_owner)); + let erased = |ty| cx.tcx.erase_and_anonymize_regions(ty); + for (&a, &b) in lf.iter().zip(&rf) { + match (lt.node_type_opt(a), rt.node_type_opt(b)) { + (Some(ta), Some(tb)) if erased(ta) == erased(tb) => {} + _ => return false, + } + } + let mut eq = SpanlessEq::new(cx).paths_by_resolution(); + let mut ie = eq.inter_expr(SyntaxContext::root()); + ie.locals.extend(lf.into_iter().zip(rf)); + ie.eq_expr(l, r) +} diff --git a/src/lib.rs b/src/lib.rs index 495400b..673ffb9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -32,6 +32,7 @@ mod exclusive_options; mod flag_cluster; mod forbidden_reach; mod guard_flag; +mod hir_clone; mod hir_shapes; mod insert_then_unwrap; mod lock_order; @@ -41,6 +42,8 @@ mod narrowed_return; mod nonidentity_key; mod overwide_parameter; mod parallel_bools; +mod reimplemented_helper; +mod same_match_twice; mod stale_across_reentry; mod stale_panic_message; mod stale_safety_comment; @@ -104,6 +107,9 @@ pub struct MordantConfig { /// Construction sites at which `stored_projection` will read a /// correspondence between two fields. pub stored_projection_min_sites: usize = 2, + /// Expression nodes below which `reimplemented_helper` does not compare + /// a body, so one-line accessors and constructors never pair up. + pub reimplemented_helper_min_nodes: usize = 12, /// Ratchet file name, resolved upward from each crate's manifest dir. Runs /// suppress up to the recorded count per (lint, file) and surface only new /// findings. Regenerate with `MORDANT_BASELINE_WRITE=1`. @@ -203,6 +209,10 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, config.unchecked_input_len_enabled, || UncheckedInputLen); add(s, true, || MisboundArg); add(s, true, || BypassedConversion); + add(s, true, same_match_twice::SameMatchTwice::default); + add(s, true, move || { + reimplemented_helper::ReimplementedHelper::new(config) + }); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } @@ -274,6 +284,7 @@ fn config_default_thresholds_match_docs() { assert_eq!(c.wildcard_local_enum_max_variants, 12); assert_eq!(c.flag_cluster_min_bools, 3); assert_eq!(c.stored_projection_min_sites, 2); + assert_eq!(c.reimplemented_helper_min_nodes, 12); assert!(!c.flag_cluster_enabled); assert!(!c.stale_safety_comment_enabled); assert!(!c.unchecked_input_len_enabled); diff --git a/src/reimplemented_helper.rs b/src/reimplemented_helper.rs new file mode 100644 index 0000000..d4b0e1b --- /dev/null +++ b/src/reimplemented_helper.rs @@ -0,0 +1,138 @@ +use std::collections::HashMap; +use std::ops::ControlFlow; + +use clippy_utils::visitors::for_each_expr_without_closures; +use rustc_hir::def::DefKind; +use rustc_hir::def_id::LocalDefId; +use rustc_hir::intravisit::FnKind; +use rustc_hir::{Body, FnDecl}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_span::Span; + +use crate::MordantConfig; +use crate::baseline::emit_with_note; +use crate::hir_clone::{bodies_equal, body_hash, fn_sigs_equal}; + +rustc_session::declare_lint! { + /// Flags a function whose signature and body are the same as another + /// function's in the crate: the same parameter and return types, and the + /// same computation once parameters and locals are renamed. One helper + /// exists twice under two names, nothing ties the copies together, and a + /// fix made to one is silently missing from the other. + /// + /// Bodies smaller than `reimplemented-helper-min-nodes` expression nodes + /// (default 12) are not compared, so one-line accessors and constructors + /// never pair up. Signatures are compared as types, so two methods that + /// read the same field names off different `Self` types are different + /// functions. A body containing a closure is never matched (closures are + /// not compared structurally), and neither is a function a macro wrote. + /// Two methods of one trait impl (`grow` and `shrink` both forwarding to + /// one `remap`) stay quiet: the trait requires both to exist, so there is + /// no copy to delete. + pub REIMPLEMENTED_HELPER, + Warn, + "a function whose signature and body repeat another function in the crate" +} + +struct FnFact { + def: LocalDefId, + span: Span, +} + +pub struct ReimplementedHelper { + min_nodes: usize, + /// Body hash -> the functions with that hash, in visit order. + fns: HashMap>, +} + +rustc_session::impl_lint_pass!(ReimplementedHelper => [REIMPLEMENTED_HELPER]); + +impl ReimplementedHelper { + pub fn new(config: &MordantConfig) -> Self { + Self { + min_nodes: config.reimplemented_helper_min_nodes, + fns: HashMap::new(), + } + } +} + +fn expr_nodes(body: &Body<'_>) -> usize { + let mut n = 0usize; + for_each_expr_without_closures(body.value, |_| { + n += 1; + ControlFlow::<()>::Continue(()) + }); + n +} + +/// Both are items of one trait impl block, which the trait obliges to +/// define each of them. +fn same_trait_impl(cx: &LateContext<'_>, l: LocalDefId, r: LocalDefId) -> bool { + let parent = cx.tcx.local_parent(l); + parent == cx.tcx.local_parent(r) + && matches!(cx.tcx.def_kind(parent), DefKind::Impl { of_trait: true }) +} + +impl<'tcx> LateLintPass<'tcx> for ReimplementedHelper { + fn check_fn( + &mut self, + cx: &LateContext<'tcx>, + kind: FnKind<'tcx>, + _decl: &'tcx FnDecl<'tcx>, + body: &'tcx Body<'tcx>, + span: Span, + def_id: LocalDefId, + ) { + if matches!(kind, FnKind::Closure) + || span.from_expansion() + || body.value.span.from_expansion() + || expr_nodes(body) < self.min_nodes + { + return; + } + self.fns + .entry(body_hash(cx, body.id())) + .or_default() + .push(FnFact { def: def_id, span }); + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + // (copy, original) + let mut findings: Vec<(&FnFact, &FnFact)> = Vec::new(); + for bucket in self.fns.values_mut() { + if bucket.len() < 2 { + continue; + } + bucket.sort_by_key(|f| f.span.lo()); + let mut distinct: Vec<&FnFact> = Vec::new(); + for f in bucket.iter() { + let body = cx.tcx.hir_body_owned_by(f.def).id(); + let original = distinct.iter().find(|o| { + !same_trait_impl(cx, o.def, f.def) + && fn_sigs_equal(cx, o.def, f.def) + && bodies_equal(cx, cx.tcx.hir_body_owned_by(o.def).id(), body) + }); + match original { + Some(o) => findings.push((f, o)), + None => distinct.push(f), + } + } + } + findings.sort_by_key(|(f, _)| f.span.lo()); + for (copy, original) in findings { + emit_with_note( + cx, + REIMPLEMENTED_HELPER, + cx.tcx.def_span(copy.def), + format!( + "`{}` has the same signature and body as `{}`", + cx.tcx.def_path_str(copy.def), + cx.tcx.def_path_str(original.def), + ), + cx.tcx.def_span(original.def), + "the same body is here", + "one helper written twice drifts apart at the first fix; keep one and call it from the other's callers", + ); + } + } +} diff --git a/src/same_match_twice.rs b/src/same_match_twice.rs new file mode 100644 index 0000000..e07b236 --- /dev/null +++ b/src/same_match_twice.rs @@ -0,0 +1,136 @@ +use std::collections::HashMap; + +use rustc_hir::def_id::{DefId, LocalDefId}; +use rustc_hir::{Expr, ExprKind, HirId, MatchSource, PatKind}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_span::{Span, sym}; + +use crate::adt_facts::inside_own_trait_impl; +use crate::baseline::emit_with_note; +use crate::hir_clone::{expr_hash, exprs_equal}; + +rustc_session::declare_lint! { + /// Flags a `match` over an enum that is written out a second time, arm + /// for arm, somewhere else in the crate: same scrutinee type, same + /// patterns, same arm bodies up to the names of the locals they read. A + /// mapping from variants to values or actions that exists in two places + /// is a method the enum does not have; the two copies are kept in step by + /// hand, and a variant added later is handled in whichever copy the + /// author remembers. + /// + /// Only matches with at least two arms that name a pattern count (a + /// `_`/binding catch-all plus one arm is a test, not a table), and only + /// on enums outside the standard library, since a repeated `match` on + /// `Option` is an idiom rather than a table. Matches inside the enum's + /// own trait impls (`Display` beside `Debug`) stay quiet, as do matches + /// produced by macros. Two copies whose free locals differ in type, or + /// that read a different number of them, are different code and stay + /// quiet; so does a copy nested inside a larger copy already reported. + /// Two blind spots keep it quiet on some true copies: arms using `A | B` + /// patterns are never confirmed equal, and a macro call in an arm + /// (`format!(..)`) compares by its tokens, so a renamed local inside its + /// arguments makes the arms differ. + pub SAME_MATCH_TWICE, + Warn, + "the same match over one enum written out in two places" +} + +struct Site { + hash: u64, + owner: LocalDefId, + expr: HirId, + span: Span, +} + +#[derive(Default)] +pub struct SameMatchTwice { + /// Scrutinee enum -> every counted match on it, in visit order. + sites: HashMap>, +} + +rustc_session::impl_lint_pass!(SameMatchTwice => [SAME_MATCH_TWICE]); + +impl<'tcx> LateLintPass<'tcx> for SameMatchTwice { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + let ExprKind::Match(scrut, arms, MatchSource::Normal) = expr.kind else { + return; + }; + if expr.span.from_expansion() { + return; + } + let counted = arms + .iter() + .filter(|a| !matches!(a.pat.kind, PatKind::Wild | PatKind::Binding(.., None))) + .count(); + if counted < 2 { + return; + } + let Some(adt) = cx.typeck_results().expr_ty(scrut).peel_refs().ty_adt_def() else { + return; + }; + if !adt.is_enum() + || matches!( + cx.tcx.crate_name(adt.did().krate), + sym::core | sym::alloc | sym::std + ) + || inside_own_trait_impl(cx, expr.hir_id, adt.did()) + { + return; + } + self.sites.entry(adt.did()).or_default().push(Site { + hash: expr_hash(cx, expr), + owner: cx.tcx.hir_enclosing_body_owner(expr.hir_id), + expr: expr.hir_id, + span: expr.span, + }); + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + // (later copy, earlier copy, enum) + let mut findings: Vec<(Span, Span, DefId)> = Vec::new(); + for (adt, mut sites) in self.sites.drain() { + sites.sort_by_key(|s| s.span.lo()); + let mut buckets: HashMap> = HashMap::new(); + for (i, site) in sites.iter().enumerate() { + // A macro that expands its argument twice (`log!`) yields two + // matches from one piece of source; that is one copy. + if sites[..i].iter().any(|e| e.span.source_equal(site.span)) { + continue; + } + let bucket = buckets.entry(site.hash).or_default(); + let this = (site.owner, cx.tcx.hir_expect_expr(site.expr)); + let earlier = bucket + .iter() + .find(|e| exprs_equal(cx, (e.owner, cx.tcx.hir_expect_expr(e.expr)), this)); + match earlier { + Some(e) => findings.push((site.span, e.span, adt)), + // Only distinct shapes stand as candidates, so a third + // copy is reported against the first, once. + None => bucket.push(site), + } + } + } + findings.sort_by_key(|(span, ..)| span.lo()); + let mut reported: Vec = Vec::new(); + for (span, earlier, adt) in findings { + // A match inside a repeated match is repeated too; the outer + // report already covers it. + if reported.iter().any(|r| r.contains(span)) { + continue; + } + reported.push(span); + emit_with_note( + cx, + SAME_MATCH_TWICE, + span, + format!( + "this `match` on `{}` is written out arm for arm a second time", + cx.tcx.def_path_str(adt), + ), + earlier, + "the same match is here", + "the mapping lives in two places kept in step by hand; a method on the enum states it once", + ); + } + } +} diff --git a/src/unread_error_variant.rs b/src/unread_error_variant.rs index 523f0bb..74664c0 100644 --- a/src/unread_error_variant.rs +++ b/src/unread_error_variant.rs @@ -5,7 +5,7 @@ use rustc_hir::{BinOpKind, Expr, ExprKind, Pat}; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty; -use crate::adt_facts::impl_self_adt; +use crate::adt_facts::inside_own_trait_impl; use crate::baseline::emit; use crate::enum_facts::{arm_variant, private_enum_of}; @@ -41,28 +41,6 @@ pub struct UnreadErrorVariant { rustc_session::impl_lint_pass!(UnreadErrorVariant => [UNREAD_ERROR_VARIANT]); -/// True when `hir_id` sits inside a TRAIT impl whose self type is `enum_did`. -/// `Display`, `Debug`, `From` and derive expansions must match every variant -/// to exist, so their patterns prove nothing. Inherent methods are not -/// excluded: an accessor like `fn tenths(&self)` is the crate genuinely -/// reading the structure. -fn inside_own_trait_impl(cx: &LateContext<'_>, hir_id: rustc_hir::HirId, enum_did: DefId) -> bool { - let mut cur = hir_id.owner.def_id.to_def_id(); - loop { - if matches!( - cx.tcx.def_kind(cur), - rustc_hir::def::DefKind::Impl { of_trait: true } - ) && impl_self_adt(cx, cur).is_some_and(|adt| adt.did() == enum_did) - { - return true; - } - match cx.tcx.opt_parent(cur) { - Some(p) => cur = p, - None => return false, - } - } -} - /// The private-enum variant `expr` constructs, if it is a variant path /// (including a bare tuple constructor passed as a function), call or struct /// expression, as `(enum, variant)`. diff --git a/ui/reimplemented_helper.rs b/ui/reimplemented_helper.rs new file mode 100644 index 0000000..838ec1b --- /dev/null +++ b/ui/reimplemented_helper.rs @@ -0,0 +1,107 @@ +// A function whose signature and body repeat another function's is one +// helper written twice; anything that differs in type or shape is not. + +pub fn clamp_add(base: u32, delta: u32, limit: u32) -> u32 { + let sum = base.saturating_add(delta); + if sum > limit { limit } else { sum } +} + +// Flagged: `clamp_add` again with the parameters renamed. +pub fn bounded_sum(a: u32, b: u32, max: u32) -> u32 { + let total = a.saturating_add(b); + if total > max { max } else { total } +} + +// Fine: same shape, but the parameters are `u64`, so the signature differs. +pub fn clamp_add_wide(base: u64, delta: u64, limit: u64) -> u64 { + let sum = base.saturating_add(delta); + if sum > limit { limit } else { sum } +} + +// Fine: one operator differs. +pub fn clamp_add_inclusive(base: u32, delta: u32, limit: u32) -> u32 { + let sum = base.saturating_add(delta); + if sum >= limit { limit } else { sum } +} + +pub struct Row { + cells: Vec, + pad: u32, +} + +pub struct Column { + cells: Vec, + pad: u32, +} + +impl Row { + pub fn extent(&self) -> u32 { + self.cells.iter().sum::() + self.pad * 2 + 1 + } + + // Flagged: the same computation as `extent` on the same type. + pub fn footprint(&self) -> u32 { + self.cells.iter().sum::() + self.pad * 2 + 1 + } + + // Fine: too small to compare (under `reimplemented-helper-min-nodes`). + pub fn pad(&self) -> u32 { + self.pad + } + + // Fine: an accessor as small as `pad`. + pub fn margin(&self) -> u32 { + self.pad + } +} + +impl Column { + // Fine: spelled like `Row::extent`, but `self` is a `Column`, so the + // signatures differ and the field reads are of another type. + pub fn extent(&self) -> u32 { + self.cells.iter().sum::() + self.pad * 2 + 1 + } +} + +pub trait Resize { + fn grow(&self, from: u32, to: u32) -> u32; + fn shrink(&self, from: u32, to: u32) -> u32; +} + +impl Row { + fn remap(&self, from: u32, to: u32) -> u32 { + if to > from { + self.pad + (to - from) + } else { + self.pad.saturating_sub(from - to) + } + } +} + +// Fine: the trait obliges the impl to define both, and each already forwards +// to the one shared helper, so there is no copy to delete. +impl Resize for Row { + fn grow(&self, from: u32, to: u32) -> u32 { + self.remap(from.min(to), to.max(from)) + self.cells.len() as u32 + } + fn shrink(&self, from: u32, to: u32) -> u32 { + self.remap(from.min(to), to.max(from)) + self.cells.len() as u32 + } +} + +// Quiet: closures are never compared structurally, so two bodies built +// around one never pair up even when they are copies. +pub fn doubled_evens(xs: &[u32]) -> Vec { + xs.iter().filter(|x| **x % 2 == 0).map(|x| x * 2).collect() +} + +// Quiet: the copy of `doubled_evens`. +pub fn twice_the_evens(values: &[u32]) -> Vec { + values + .iter() + .filter(|v| **v % 2 == 0) + .map(|v| v * 2) + .collect() +} + +fn main() {} diff --git a/ui/reimplemented_helper.stderr b/ui/reimplemented_helper.stderr new file mode 100644 index 0000000..c2d1dd4 --- /dev/null +++ b/ui/reimplemented_helper.stderr @@ -0,0 +1,29 @@ +warning: `bounded_sum` has the same signature and body as `clamp_add` + --> $DIR/reimplemented_helper.rs:10:1 + | +LL | pub fn bounded_sum(a: u32, b: u32, max: u32) -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the same body is here + --> $DIR/reimplemented_helper.rs:4:1 + | +LL | pub fn clamp_add(base: u32, delta: u32, limit: u32) -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: one helper written twice drifts apart at the first fix; keep one and call it from the other's callers + = note: `#[warn(reimplemented_helper)]` on by default + +warning: `Row::footprint` has the same signature and body as `Row::extent` + --> $DIR/reimplemented_helper.rs:43:5 + | +LL | pub fn footprint(&self) -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the same body is here + --> $DIR/reimplemented_helper.rs:38:5 + | +LL | pub fn extent(&self) -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: one helper written twice drifts apart at the first fix; keep one and call it from the other's callers + +warning: 2 warnings emitted + diff --git a/ui/same_match_twice.rs b/ui/same_match_twice.rs new file mode 100644 index 0000000..53b35b2 --- /dev/null +++ b/ui/same_match_twice.rs @@ -0,0 +1,222 @@ +// The same `match` over one enum written out arm for arm in two functions is +// a method the enum lacks; matches that differ anywhere are different code. + +use std::fmt; + +pub enum Step { + Open, + Read, + Parse, +} + +pub struct Failure { + pub step: Step, + pub code: u32, +} + +pub fn describe(f: &Failure) -> (u32, &'static str) { + match f.step { + Step::Open => (f.code, "open"), + Step::Read => (f.code + 1, "read"), + Step::Parse => (f.code + 2, "parse"), + } +} + +// Flagged: the same three arms as `describe`, reading a local of the same +// type under another name. +pub fn describe_again(cause: &Failure, loud: bool) -> Option<(u32, &'static str)> { + if !loud { + return None; + } + Some(match cause.step { + Step::Open => (cause.code, "open"), + Step::Read => (cause.code + 1, "read"), + Step::Parse => (cause.code + 2, "parse"), + }) +} + +// Flagged once, against `describe`: a third copy does not also pair with the +// second. +pub fn describe_third(f: &Failure) -> (u32, &'static str) { + let d = match f.step { + Step::Open => (f.code, "open"), + Step::Read => (f.code + 1, "read"), + Step::Parse => (f.code + 2, "parse"), + }; + d +} + +// Fine: one arm body differs. +pub fn describe_short(f: &Failure) -> (u32, &'static str) { + match f.step { + Step::Open => (f.code, "open"), + Step::Read => (f.code + 1, "read"), + Step::Parse => (0, "parse"), + } +} + +pub fn report(f: &Failure) -> String { + match f.step { + Step::Open => format!("failed to open {}", f.code), + Step::Read => format!("failed to read {}", f.code), + Step::Parse => String::from("failed to parse"), + } +} + +// Fine, though a copy: arguments to a macro compare by their tokens, so the +// renamed local inside `format!` makes these arms different text. +pub fn report_renamed(cause: &Failure) -> String { + match cause.step { + Step::Open => format!("failed to open {}", cause.code), + Step::Read => format!("failed to read {}", cause.code), + Step::Parse => String::from("failed to parse"), + } +} + +#[derive(Clone, Copy)] +pub enum Level { + Low, + Mid, + High, +} + +pub struct Gauge { + pub level: Level, + pub scale: u32, +} + +pub struct Meter { + pub level: Level, + pub scale: u32, + pub live: bool, +} + +pub fn gauge_units(g: &Gauge) -> u64 { + match g.level { + Level::Low => 1, + Level::Mid => u64::from(g.scale), + Level::High => 100, + } +} + +// Fine: the arms read `m`, whose type is not `g`'s, so this is not the same +// code even though it is spelled alike. +pub fn meter_units(m: &Meter) -> u64 { + match m.level { + Level::Low => 1, + Level::Mid => u64::from(m.scale), + Level::High => 100, + } +} + +pub fn low_offset(l: Level) -> Option { + match l { + Level::Low => Some(3), + _ => None, + } +} + +// Fine: one counted arm and a catch-all is a test, not a table. +pub fn low_offset_again(l: Level) -> Option { + match l { + Level::Low => Some(3), + _ => None, + } +} + +// Fine: `Display` and `Debug` are the enum's own trait impls, which have to +// match every variant to exist. +impl fmt::Display for Level { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Level::Low => f.write_str("low"), + Level::Mid => f.write_str("mid"), + Level::High => f.write_str("high"), + } + } +} + +impl fmt::Debug for Level { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Level::")?; + match self { + Level::Low => f.write_str("low"), + Level::Mid => f.write_str("mid"), + Level::High => f.write_str("high"), + } + } +} + +pub fn some_or_zero(o: Option) -> u32 { + match o { + Some(v) => v + 1, + None => 0, + } +} + +// Fine: `Option` is a standard-library enum; a repeated match on it is an +// idiom, not a table the crate could turn into a method. +pub fn some_or_zero_again(o: Option) -> u32 { + match o { + Some(v) => v + 1, + None => 0, + } +} + +pub enum Shape { + Dot, + Line(u32), + Rect(u32, u32), +} + +pub fn area(s: &Shape) -> u32 { + match s { + Shape::Dot => 0, + Shape::Line(_) => 0, + Shape::Rect(w, h) => match (w, h) { + (0, h) => *h, + (w, h) => w * h, + }, + } +} + +// Flagged once: the outer match repeats `area`'s; the tuple match inside it +// is covered by that report and not named again. +pub fn area_again(shape: &Shape) -> u32 { + let cells = match shape { + Shape::Dot => 0, + Shape::Line(_) => 0, + Shape::Rect(w, h) => match (w, h) { + (0, h) => *h, + (w, h) => w * h, + }, + }; + cells.next_power_of_two() +} + +// Fine: reads two free locals where `area` reads one. +pub fn area_scaled(s: &Shape, k: u32) -> u32 { + match s { + Shape::Dot => 0, + Shape::Line(_) => k, + Shape::Rect(w, h) => w * h, + } +} + +// Fine: a macro that expands its argument twice makes two matches out of +// one piece of source, which is still one copy. +macro_rules! both_ways { + ($e:expr) => { + ($e, $e) + }; +} + +pub fn level_pair(l: Level) -> (u8, u8) { + both_ways!(match l { + Level::Low => 0, + Level::Mid => 5, + Level::High => 9, + }) +} + +fn main() {} diff --git a/ui/same_match_twice.stderr b/ui/same_match_twice.stderr new file mode 100644 index 0000000..e67c614 --- /dev/null +++ b/ui/same_match_twice.stderr @@ -0,0 +1,73 @@ +warning: this `match` on `Step` is written out arm for arm a second time + --> $DIR/same_match_twice.rs:31:10 + | +LL | Some(match cause.step { + | __________^ +LL | | Step::Open => (cause.code, "open"), +LL | | Step::Read => (cause.code + 1, "read"), +LL | | Step::Parse => (cause.code + 2, "parse"), +LL | | }) + | |_____^ + | +note: the same match is here + --> $DIR/same_match_twice.rs:18:5 + | +LL | / match f.step { +LL | | Step::Open => (f.code, "open"), +LL | | Step::Read => (f.code + 1, "read"), +LL | | Step::Parse => (f.code + 2, "parse"), +LL | | } + | |_____^ + = help: the mapping lives in two places kept in step by hand; a method on the enum states it once + = note: `#[warn(same_match_twice)]` on by default + +warning: this `match` on `Step` is written out arm for arm a second time + --> $DIR/same_match_twice.rs:41:13 + | +LL | let d = match f.step { + | _____________^ +LL | | Step::Open => (f.code, "open"), +LL | | Step::Read => (f.code + 1, "read"), +LL | | Step::Parse => (f.code + 2, "parse"), +LL | | }; + | |_____^ + | +note: the same match is here + --> $DIR/same_match_twice.rs:18:5 + | +LL | / match f.step { +LL | | Step::Open => (f.code, "open"), +LL | | Step::Read => (f.code + 1, "read"), +LL | | Step::Parse => (f.code + 2, "parse"), +LL | | } + | |_____^ + = help: the mapping lives in two places kept in step by hand; a method on the enum states it once + +warning: this `match` on `Shape` is written out arm for arm a second time + --> $DIR/same_match_twice.rs:186:17 + | +LL | let cells = match shape { + | _________________^ +LL | | Shape::Dot => 0, +LL | | Shape::Line(_) => 0, +LL | | Shape::Rect(w, h) => match (w, h) { +... | +LL | | }, +LL | | }; + | |_____^ + | +note: the same match is here + --> $DIR/same_match_twice.rs:173:5 + | +LL | / match s { +LL | | Shape::Dot => 0, +LL | | Shape::Line(_) => 0, +LL | | Shape::Rect(w, h) => match (w, h) { +... | +LL | | }, +LL | | } + | |_____^ + = help: the mapping lives in two places kept in step by hand; a method on the enum states it once + +warning: 3 warnings emitted + diff --git a/ui/stringly_error.stderr b/ui/stringly_error.stderr index f028fcb..162ab85 100644 --- a/ui/stringly_error.stderr +++ b/ui/stringly_error.stderr @@ -40,5 +40,19 @@ LL | s.parse().map(Numeric).map_err(|e| e.to_string()) = help: return the error type, or an error enum with a variant that wraps it = note: `#[warn(stringified_error)]` on by default -warning: 5 warnings emitted +warning: `private_string_err_is_fine` has the same signature and body as `public_string_err_is_flagged` + --> $DIR/stringly_error.rs:31:1 + | +LL | fn private_string_err_is_fine(x: u32) -> Result { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the same body is here + --> $DIR/stringly_error.rs:7:1 + | +LL | pub fn public_string_err_is_flagged(x: u32) -> Result { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: one helper written twice drifts apart at the first fix; keep one and call it from the other's callers + = note: `#[warn(reimplemented_helper)]` on by default + +warning: 6 warnings emitted diff --git a/ui/wildcard_local_enum.stderr b/ui/wildcard_local_enum.stderr index 1c173bb..f676547 100644 --- a/ui/wildcard_local_enum.stderr +++ b/ui/wildcard_local_enum.stderr @@ -69,5 +69,28 @@ help: list the remaining variants; the compiler then flags every new one added LL | other @ (Op::Sub | Op::Mul) => { | +++++++++++++++++++++ -warning: 6 warnings emitted +warning: this `match` on `Op` is written out arm for arm a second time + --> $DIR/wildcard_local_enum.rs:219:13 + | +LL | / match other { +LL | | Op::Add => 0, +LL | | Op::Sub => 2, +LL | | Op::Mul => 3, +LL | | } + | |_____________^ + | +note: the same match is here + --> $DIR/wildcard_local_enum.rs:177:18 + | +LL | other => match other { + | __________________^ +LL | | Op::Add => 0, +LL | | Op::Sub => 2, +LL | | Op::Mul => 3, +LL | | }, + | |_________^ + = help: the mapping lives in two places kept in step by hand; a method on the enum states it once + = note: `#[warn(same_match_twice)]` on by default + +warning: 7 warnings emitted From 0778c2cdaaf12afcb5bbb77643f9d19c8d0e645c Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:01:34 -0700 Subject: [PATCH 04/34] add dependent_field --- README.md | 1 + src/dependent_field.rs | 556 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 8 +- ui/dependent_field.rs | 291 ++++++++++++++++++++ ui/dependent_field.stderr | 19 ++ 5 files changed, 872 insertions(+), 3 deletions(-) create mode 100644 src/dependent_field.rs create mode 100644 ui/dependent_field.rs create mode 100644 ui/dependent_field.stderr diff --git a/README.md b/README.md index 033c0d7..800d5d5 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `bypassed_conversion` | `mem::transmute` or a pointer cast into a type outside its own module and impls, when a `From`/`TryFrom` impl or constructor already converts that same source into it | | `same_match_twice` | the same `match` over one enum written out arm for arm in two places: a mapping the enum should state once as a method, kept in step by hand instead | | `reimplemented_helper` | a function whose signature and body repeat another function in the crate under a different name: one helper written twice, so a fix to one copy misses the other | +| `dependent_field` | a field every reader tests a sibling for one value before touching, and every other construction fills with a placeholder: an enum payload stored flat beside its tag | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/dependent_field.rs b/src/dependent_field.rs new file mode 100644 index 0000000..60e5c4b --- /dev/null +++ b/src/dependent_field.rs @@ -0,0 +1,556 @@ +use std::collections::{HashMap, HashSet}; + +use crate::adt_facts::{has_fixed_repr, has_positional_fields, private_local_struct, struct_field}; +use crate::baseline::emit; +use crate::enum_facts::{arm_variant, ctor_literal_variant}; +use clippy_utils::eq_expr_value; +use clippy_utils::{in_automatically_derived, is_default_equivalent}; +use rustc_ast::LitKind; +use rustc_hir::def_id::DefId; +use rustc_hir::{ + Arm, BinOpKind, Block, BorrowKind, Expr, ExprKind, Mutability, Node, Pat, PatExpr, PatExprKind, + PatKind, StmtKind, StructTailExpr, UnOp, +}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty; +use rustc_span::{Span, Symbol, SyntaxContext, sym}; + +rustc_session::declare_lint! { + /// Flags a struct field that is read only where a sibling field is known + /// to hold one particular value — every read in the crate sits under an + /// `if`, `match`, `let .. else` or diverging guard that tests the sibling + /// against the same variant or literal — while a construction site that + /// gives the sibling any other value fills the field with a placeholder + /// (`None`, `0`, `""`, `false`, `Default::default()`, a null pointer). + /// The field is the payload of one case of the sibling, stored flat: the + /// struct lets every other case carry a value that means nothing and lets + /// any new reader use it without the test. An enum whose variant carries + /// the field cannot be built or read that way. + /// + /// Only fires on structs private to the crate with named fields and no + /// explicit `repr`, and only on proof: one read the lint cannot place + /// under such a test — an accessor, a destructuring pattern, a `Debug` + /// written by hand, a test on a copy of the sibling rather than the + /// sibling itself — keeps it quiet, as does a construction that gives + /// the field a real value beside another value of the sibling, and so + /// does a sibling that is never given the tested value by any literal or + /// assignment (the field is then dead, not dependent). Reads in derived + /// impls are not counted. + pub DEPENDENT_FIELD, + Warn, + "a field that only means something when a sibling field has one value" +} + +/// A value a field can be tested against and built with, compared exactly. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Value { + Variant(DefId), + Bool(bool), + Int(u128), +} + +impl Value { + /// The one other value the field can hold, when there is exactly one. + fn complement(self) -> Option { + match self { + Value::Bool(b) => Some(Value::Bool(!b)), + Value::Variant(_) | Value::Int(_) => None, + } + } +} + +/// `sibling == value`, known to hold where a read happens. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct Test { + sibling: Symbol, + value: Value, +} + +/// What one construction site wrote into one field. +#[derive(Clone, Copy)] +struct Init { + value: Option, + placeholder: bool, +} + +#[derive(Default)] +pub struct DependentField { + /// (struct, field) -> per read, the sibling tests that dominate it. An + /// empty set is a read nothing is known at. + reads: HashMap<(DefId, Symbol), Vec>>, + /// struct -> per literal construction site, what each named field got. + sites: HashMap>>, + /// Fields assigned after construction, whose values the sites do not + /// bound. + assigned: HashSet<(DefId, Symbol)>, +} + +rustc_session::impl_lint_pass!(DependentField => [DEPENDENT_FIELD]); + +/// The struct behind `ty` when every read and construction of it is this +/// crate's to see and its fields have names a message can use. +fn relevant<'tcx>(cx: &LateContext<'tcx>, ty: ty::Ty<'tcx>) -> Option> { + let adt = private_local_struct(cx, ty)?; + let v = adt.non_enum_variant(); + (!has_fixed_repr(adt) && !has_positional_fields(v) && v.fields.len() >= 2).then_some(adt) +} + +/// `e` under HIR temporaries and shared borrows: `&x.g` names what `x.g` +/// does for a comparison or a `match`. +fn strip_ref<'h>(mut e: &'h Expr<'h>) -> &'h Expr<'h> { + while let ExprKind::DropTemps(inner) + | ExprKind::AddrOf(BorrowKind::Ref, Mutability::Not, inner) = e.kind + { + e = inner; + } + e +} + +/// `e` with `!`s removed, and whether they flip it (their parity, which +/// `hir_shapes::peel_not` does not keep). +fn peel_parity<'h>(mut e: &'h Expr<'h>) -> (&'h Expr<'h>, bool) { + let mut flipped = false; + loop { + match e.kind { + ExprKind::Unary(UnOp::Not, inner) => { + flipped = !flipped; + e = inner; + } + ExprKind::DropTemps(inner) => e = inner, + _ => return (e, flipped), + } + } +} + +fn lit_value(lit: &LitKind) -> Option { + match *lit { + LitKind::Bool(b) => Some(Value::Bool(b)), + LitKind::Int(n, _) => Some(Value::Int(n.get())), + _ => None, + } +} + +/// The value `e` spells out: a variant literal, a bool or an unsigned +/// integer literal. Named constants are not values here: two names can be +/// one value. +fn expr_value(cx: &LateContext<'_>, e: &Expr<'_>) -> Option { + let e = strip_ref(e); + if let Some(v) = ctor_literal_variant(cx, e) { + return Some(Value::Variant(v)); + } + match e.kind { + ExprKind::Lit(lit) => lit_value(&lit.node), + _ => None, + } +} + +/// The one value a pattern admits at its head; or-patterns, ranges, +/// bindings without a sub-pattern and wildcards admit more than one. +fn pat_value(cx: &LateContext<'_>, pat: &Pat<'_>) -> Option { + match pat.kind { + PatKind::Binding(.., Some(sub)) + | PatKind::Ref(sub, ..) + | PatKind::Deref(sub) + | PatKind::Box(sub) => pat_value(cx, sub), + PatKind::Expr(PatExpr { + kind: + PatExprKind::Lit { + lit, + negated: false, + }, + .. + }) => lit_value(&lit.node), + _ => arm_variant(cx, pat).map(Value::Variant), + } +} + +/// Whether variant `v` has no fields, so `!= E::V` rules the variant out and +/// not just one payload of it. +fn negation_rules_out(cx: &LateContext<'_>, v: Value) -> bool { + match v { + Value::Bool(_) | Value::Int(_) => true, + Value::Variant(did) => cx + .tcx + .adt_def(cx.tcx.parent(did)) + .variant_with_id(did) + .fields + .is_empty(), + } +} + +/// A don't-care initialiser: whatever `Default` would give, or a null +/// pointer. +fn is_placeholder(cx: &LateContext<'_>, e: &Expr<'_>) -> bool { + if is_default_equivalent(cx, e) { + return true; + } + if let ExprKind::Call(f, []) = e.kind + && let ExprKind::Path(qpath) = &f.kind + && let Some(def) = cx.qpath_res(qpath, f.hir_id).opt_def_id() + { + return matches!( + cx.tcx.get_diagnostic_name(def), + Some(sym::ptr_null | sym::ptr_null_mut) + ); + } + false +} + +/// Which sibling of the read field `e` names: `e` must be `.g` over +/// the very place expression the read was `.f` over. +fn sibling_of(cx: &LateContext<'_>, base: &Expr<'_>, e: &Expr<'_>) -> Option { + let ExprKind::Field(other, ident) = strip_ref(e).kind else { + return None; + }; + eq_expr_value(cx, SyntaxContext::root(), base, other).then_some(ident.name) +} + +/// Every `sibling == value` fact that follows from `cond` evaluating to +/// `holds`, for siblings read off `base`. +fn cond_tests( + cx: &LateContext<'_>, + base: &Expr<'_>, + cond: &Expr<'_>, + holds: bool, + out: &mut HashSet, +) { + let (inner, flipped) = peel_parity(cond); + let holds = holds != flipped; + match inner.kind { + ExprKind::Binary(op, l, r) if op.node == BinOpKind::And && holds => { + cond_tests(cx, base, l, true, out); + cond_tests(cx, base, r, true, out); + } + ExprKind::Binary(op, l, r) if op.node == BinOpKind::Or && !holds => { + cond_tests(cx, base, l, false, out); + cond_tests(cx, base, r, false, out); + } + ExprKind::Binary(op, l, r) if matches!(op.node, BinOpKind::Eq | BinOpKind::Ne) => { + let equal = (op.node == BinOpKind::Eq) == holds; + let sides = sibling_of(cx, base, l) + .map(|s| (s, r)) + .or_else(|| sibling_of(cx, base, r).map(|s| (s, l))); + let Some((sibling, ve)) = sides else { + return; + }; + let Some(value) = expr_value(cx, ve) else { + return; + }; + known(cx, sibling, value, equal, out); + } + ExprKind::Let(l) if holds => { + if let Some(sibling) = sibling_of(cx, base, l.init) + && let Some(value) = pat_value(cx, l.pat) + { + known(cx, sibling, value, true, out); + } + } + // `matches!(base.g, P)`. + ExprKind::Match(scrut, [yes, no], _) + if is_bool_lit(yes.body, true) + && is_bool_lit(no.body, false) + && matches!(no.pat.kind, PatKind::Wild) + && (holds || yes.guard.is_none()) => + { + if let Some(sibling) = sibling_of(cx, base, scrut) + && let Some(value) = pat_value(cx, yes.pat) + { + known(cx, sibling, value, holds, out); + } + } + _ => { + if let Some(sibling) = sibling_of(cx, base, inner) + && cx.typeck_results().expr_ty(inner).is_bool() + { + known(cx, sibling, Value::Bool(holds), true, out); + } + } + } +} + +fn is_bool_lit(e: &Expr<'_>, b: bool) -> bool { + matches!(strip_ref(e).kind, ExprKind::Lit(lit) if lit.node == LitKind::Bool(b)) +} + +/// Record `sibling == value` (or, for `equal == false`, what `!=` pins down, +/// which is something only when the value has exactly one complement). +fn known( + cx: &LateContext<'_>, + sibling: Symbol, + value: Value, + equal: bool, + out: &mut HashSet, +) { + if equal { + out.insert(Test { sibling, value }); + } else if negation_rules_out(cx, value) + && let Some(value) = value.complement() + { + out.insert(Test { sibling, value }); + } +} + +fn is_never(cx: &LateContext<'_>, e: &Expr<'_>) -> bool { + cx.typeck_results().expr_ty(e).is_never() +} + +/// Facts established by the statements of `block` that run before `child`: +/// a guard whose failing branch diverges, a `let .. else`, a `match` whose +/// every other arm diverges. +fn preceding_guards( + cx: &LateContext<'_>, + base: &Expr<'_>, + block: &Block<'_>, + child: rustc_hir::HirId, + out: &mut HashSet, +) { + let pos = block + .stmts + .iter() + .position(|s| s.hir_id == child || matches!(s.kind, StmtKind::Let(l) if l.hir_id == child)) + .unwrap_or_else(|| { + if block.expr.is_some_and(|e| e.hir_id == child) { + block.stmts.len() + } else { + 0 + } + }); + for stmt in &block.stmts[..pos] { + match stmt.kind { + StmtKind::Let(l) => { + if l.els.is_some() + && let Some(init) = l.init + && let Some(sibling) = sibling_of(cx, base, init) + && let Some(value) = pat_value(cx, l.pat) + { + known(cx, sibling, value, true, out); + } + } + StmtKind::Expr(e) | StmtKind::Semi(e) => match e.kind { + ExprKind::If(cond, then, els) => { + let then_diverges = is_never(cx, then); + let else_diverges = els.is_some_and(|x| is_never(cx, x)); + if then_diverges && !else_diverges { + cond_tests(cx, base, cond, false, out); + } else if else_diverges && !then_diverges { + cond_tests(cx, base, cond, true, out); + } + } + ExprKind::Match(scrut, arms, _) => { + let mut live = arms.iter().filter(|a| !is_never(cx, a.body)); + if let (Some(only), None) = (live.next(), live.next()) + && only.guard.is_none() + && let Some(sibling) = sibling_of(cx, base, scrut) + && let Some(value) = pat_value(cx, only.pat) + { + known(cx, sibling, value, true, out); + } + } + _ => {} + }, + StmtKind::Item(_) => {} + } + } +} + +/// Every sibling test that dominates `read` (a `.f` expression): +/// enclosing `if` conditions and `match` arms on `.g`, and guards +/// that ran earlier in an enclosing block. +fn dominating_tests(cx: &LateContext<'_>, read: &Expr<'_>, base: &Expr<'_>) -> HashSet { + let mut out = HashSet::new(); + let mut child = read.hir_id; + let mut via_arm: Option<&Arm<'_>> = None; + for (id, node) in cx.tcx.hir_parent_iter(read.hir_id) { + match node { + Node::Expr(e) => match e.kind { + ExprKind::If(cond, then, els) => { + if then.hir_id == child { + cond_tests(cx, base, cond, true, &mut out); + } else if els.is_some_and(|x| x.hir_id == child) { + cond_tests(cx, base, cond, false, &mut out); + } + } + ExprKind::Match(scrut, ..) => { + if let Some(arm) = via_arm.take() + && let Some(sibling) = sibling_of(cx, base, scrut) + && let Some(value) = pat_value(cx, arm.pat) + { + known(cx, sibling, value, true, &mut out); + } + } + _ => {} + }, + Node::Arm(arm) => via_arm = Some(arm), + Node::Block(b) => preceding_guards(cx, base, b, child, &mut out), + Node::Item(_) | Node::ImplItem(_) | Node::TraitItem(_) | Node::ForeignItem(_) => break, + _ => {} + } + child = id; + } + out +} + +impl DependentField { + fn record_read(&mut self, adt: DefId, field: Symbol, tests: HashSet) { + self.reads.entry((adt, field)).or_default().push(tests); + } +} + +impl<'tcx> LateLintPass<'tcx> for DependentField { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + match expr.kind { + ExprKind::Struct(_, fields, tail) => { + let Some(adt) = relevant(cx, cx.typeck_results().expr_ty(expr)) else { + return; + }; + if !matches!(tail, StructTailExpr::None) { + return; + } + let site = fields + .iter() + .map(|f| { + let init = Init { + value: expr_value(cx, f.expr), + placeholder: is_placeholder(cx, f.expr), + }; + (f.ident.name, init) + }) + .collect(); + self.sites.entry(adt.did()).or_default().push(site); + } + ExprKind::Field(base, ident) => { + let Some(adt) = relevant(cx, cx.typeck_results().expr_ty_adjusted(base)) else { + return; + }; + if struct_field(adt, ident.name).is_none() + || in_automatically_derived(cx.tcx, expr.hir_id) + { + return; + } + // `base.f = ..` writes; everything else, `base.f += ..` and + // `&mut base.f` included, reads. + if let Node::Expr(parent) = cx.tcx.parent_hir_node(expr.hir_id) + && let ExprKind::Assign(lhs, ..) = parent.kind + && lhs.hir_id == expr.hir_id + { + self.assigned.insert((adt.did(), ident.name)); + return; + } + let tests = dominating_tests(cx, expr, base); + self.record_read(adt.did(), ident.name, tests); + } + _ => {} + } + } + + fn check_pat(&mut self, cx: &LateContext<'tcx>, pat: &'tcx Pat<'tcx>) { + let PatKind::Struct(_, fields, _) = pat.kind else { + return; + }; + let Some(typeck) = cx.maybe_typeck_results() else { + return; + }; + let Some(adt) = relevant(cx, typeck.pat_ty(pat)) else { + return; + }; + if in_automatically_derived(cx.tcx, pat.hir_id) { + return; + } + for f in fields { + self.record_read(adt.did(), f.ident.name, HashSet::new()); + } + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let mut findings: Vec<(Span, String)> = Vec::new(); + for ((did, field), reads) in &self.reads { + // The tests every read agrees on; one bare read empties it. + let mut common: Option> = None; + for tests in reads { + common = Some(match common { + None => tests.clone(), + Some(c) => c.intersection(tests).copied().collect(), + }); + } + let Some(common) = common else { continue }; + let adt = cx.tcx.adt_def(*did); + let order: Vec = adt + .non_enum_variant() + .fields + .iter() + .map(|f| f.name) + .collect(); + let mut candidates: Vec = + common.into_iter().filter(|t| t.sibling != *field).collect(); + candidates.sort_by_key(|t| order.iter().position(|n| *n == t.sibling)); + let sites = self.sites.get(did).map_or(&[][..], Vec::as_slice); + for test in candidates { + // The tested case must be one the crate makes: a literal site + // with that value, a site whose value is not spelled out, or + // any later assignment. Otherwise the field is never read at + // all, which is not this lint's claim. + let reached = self.assigned.contains(&(*did, test.sibling)) + || sites.iter().any(|s| { + s.get(&test.sibling) + .is_some_and(|i| i.value.is_none_or(|v| v == test.value)) + }); + if !reached { + continue; + } + // Sites that give the sibling some other spelled-out value. + let elsewhere = sites.iter().filter(|s| { + s.get(&test.sibling) + .and_then(|i| i.value) + .is_some_and(|v| v != test.value) + }); + let (mut placeholders, mut real) = (0usize, false); + for site in elsewhere { + match site.get(field) { + Some(init) if init.placeholder => placeholders += 1, + Some(_) => real = true, + None => {} + } + } + if placeholders == 0 || real { + continue; + } + let Some(fdef) = struct_field(adt, *field) else { + continue; + }; + let holds = match test.value { + Value::Variant(v) => format!( + "{} == {}::{}", + test.sibling, + cx.tcx.item_name(cx.tcx.parent(v)), + cx.tcx.item_name(v) + ), + Value::Bool(true) => test.sibling.to_string(), + Value::Bool(false) => format!("!{}", test.sibling), + Value::Int(n) => format!("{} == {n}", test.sibling), + }; + findings.push(( + cx.tcx.def_span(fdef.did), + format!( + "`{field}` is only read where `{holds}` has been tested ({} read{}), and every `{}` made with another `{}` fills it with a placeholder ({placeholders} site{})", + reads.len(), + if reads.len() == 1 { "" } else { "s" }, + cx.tcx.item_name(*did), + test.sibling, + if placeholders == 1 { "" } else { "s" }, + ), + )); + break; + } + } + findings.sort_by_key(|(span, _)| span.lo()); + for (span, msg) in findings { + emit( + cx, + DEPENDENT_FIELD, + span, + msg, + "the field is the payload of that one case, stored flat; an enum variant carrying it leaves the other cases nothing to fill in or misread", + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 673ffb9..0b73aba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,7 @@ mod bypassed_validator; mod claims; mod ctor_flow; mod defaulted_failure; +mod dependent_field; mod discarded_error; mod enum_facts; mod exclusive_options; @@ -159,9 +160,9 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto use { asymmetric_guard::AsymmetricGuard, baseline::BaselineWriter, bypassed_conversion::BypassedConversion, bypassed_validator::BypassedValidator, - defaulted_failure::DefaultedFailure, discarded_error::DiscardedError, - exclusive_options::ExclusiveOptions, flag_cluster::FlagCluster, - forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, + defaulted_failure::DefaultedFailure, dependent_field::DependentField, + discarded_error::DiscardedError, exclusive_options::ExclusiveOptions, + flag_cluster::FlagCluster, forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, misbound_arg::MisboundArg, narrowed_return::NarrowedReturn, nonidentity_key::NonidentityKey, overwide_parameter::OverwideParameter, parallel_bools::ParallelBools, @@ -213,6 +214,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, move || { reimplemented_helper::ReimplementedHelper::new(config) }); + add(s, true, DependentField::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/ui/dependent_field.rs b/ui/dependent_field.rs new file mode 100644 index 0000000..c583028 --- /dev/null +++ b/ui/dependent_field.rs @@ -0,0 +1,291 @@ +// A field read only under a test of a sibling, and filled with a placeholder otherwise. + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Tag { + Cmd, + Pipe, + Subproc, +} + +// Flagged: `raw` is read under `tag == Subproc` (an `if`, a `match` arm and +// a diverging guard) and is null wherever another tag is built. +#[derive(Clone, Copy, Debug)] +struct Child { + node: u32, + tag: Tag, + raw: *mut u8, +} + +fn cmd(node: u32) -> Child { + Child { + node, + tag: Tag::Cmd, + raw: core::ptr::null_mut(), + } +} + +fn subproc(raw: *mut u8) -> Child { + Child { + node: 0, + tag: Tag::Subproc, + raw, + } +} + +fn dispatch(c: &Child) -> usize { + if c.tag == Tag::Subproc && c.node == 0 { + return c.raw as usize; + } + match c.tag { + Tag::Subproc => c.raw as usize, + Tag::Cmd | Tag::Pipe => c.node as usize, + } +} + +fn captured(c: &Child) -> *mut u8 { + if c.tag != Tag::Subproc { + return core::ptr::null_mut(); + } + c.raw +} + +// Flagged: `reject` is only consulted when `request` is set, through a bare +// bool test, a `matches!` and a `let .. else`; the unset construction +// defaults it. +struct Verify { + request: bool, + reject: bool, + depth: u8, +} + +fn no_request() -> Verify { + Verify { + request: false, + reject: false, + depth: 0, + } +} + +fn with_request(reject: bool) -> Verify { + Verify { + request: true, + reject, + depth: 4, + } +} + +fn apply(v: &Verify) -> u8 { + let mut out = v.depth; + if v.request { + out += u8::from(v.reject); + } + if !matches!(v.request, true) { + return out; + } + let true = v.request else { return out }; + out + u8::from(v.reject) +} + +// Fine: `len` has an accessor that reads it whatever `kind` is. +#[derive(Clone, Copy, PartialEq)] +enum Kind { + Inline, + Heap, +} + +struct Buf { + kind: Kind, + len: usize, +} + +impl Buf { + fn len(&self) -> usize { + self.len + } + + fn heap_len(&self) -> usize { + if self.kind == Kind::Heap { self.len } else { 0 } + } +} + +fn bufs(n: usize) -> [Buf; 2] { + [ + Buf { + kind: Kind::Inline, + len: 0, + }, + Buf { + kind: Kind::Heap, + len: n, + }, + ] +} + +// Fine: every read is guarded, but the other construction gives `port` a +// real value, so it means something there too. +struct Addr { + unix: bool, + port: u16, +} + +fn addrs(p: u16) -> [Addr; 3] { + [ + Addr { + unix: true, + port: 0, + }, + Addr { + unix: true, + port: p + 1, + }, + Addr { + unix: false, + port: p, + }, + ] +} + +fn port(a: &Addr) -> u16 { + if !a.unix { a.port } else { 0 } +} + +fn port_again(a: &Addr) -> u16 { + if a.unix { + return 0; + } + a.port +} + +// Fine: the test is on a copy of the sibling, which proves nothing about the +// place the read goes through. +struct Slot { + live: bool, + value: u64, +} + +fn slots(v: u64) -> [Slot; 2] { + [ + Slot { + live: false, + value: 0, + }, + Slot { + live: true, + value: v, + }, + ] +} + +fn slot_value(s: &Slot) -> u64 { + let live = s.live; + if live { s.value } else { 0 } +} + +// Fine: a destructuring pattern reads `extra` unconditionally. +struct Opts { + verbose: bool, + extra: u32, +} + +fn opts(e: u32) -> [Opts; 2] { + [ + Opts { + verbose: false, + extra: 0, + }, + Opts { + verbose: true, + extra: e, + }, + ] +} + +fn extra(o: &Opts) -> u32 { + if o.verbose { + return o.extra; + } + let Opts { verbose: _, extra } = o; + *extra +} + +// Fine: the reads sit under tests of `tag` against different variants, so no +// single case owns `count`. +struct Tally { + tag: Tag, + count: u32, +} + +fn tallies(n: u32) -> [Tally; 2] { + [ + Tally { + tag: Tag::Cmd, + count: 0, + }, + Tally { + tag: Tag::Pipe, + count: n, + }, + ] +} + +fn tally(t: &Tally) -> u32 { + match t.tag { + Tag::Pipe => t.count, + Tag::Subproc => t.count + 1, + Tag::Cmd => 0, + } +} + +// Fine: no `Printer` is ever made or set non-minifying, so `out` is dead +// rather than the payload of a case the crate has. +struct Printer { + minify: bool, + out: String, +} + +fn printer() -> Printer { + Printer { + minify: true, + out: String::new(), + } +} + +fn newline(p: &mut Printer) { + if !p.minify { + p.out.push('\n'); + } +} + +// Fine: exported, so other crates read it where they like. +pub struct Public { + pub on: bool, + pub data: u32, +} + +pub fn public(d: u32) -> [Public; 2] { + [Public { on: false, data: 0 }, Public { on: true, data: d }] +} + +pub fn public_data(p: &Public) -> u32 { + if p.on { p.data } else { 0 } +} + +fn main() { + let c = cmd(1); + let s = subproc(core::ptr::null_mut()); + let _ = (dispatch(&c), dispatch(&s), captured(&c), format!("{c:?}")); + let _ = (apply(&no_request()), apply(&with_request(true))); + let [a, b] = bufs(9); + let _ = (a.len(), b.heap_len()); + let [x, y, z] = addrs(3); + let _ = (port(&x), port_again(&y), port(&z)); + let [p, q] = slots(5); + let _ = (slot_value(&p), slot_value(&q)); + let [m, n] = opts(2); + let _ = (extra(&m), extra(&n)); + let [t, w] = tallies(7); + let _ = (tally(&t), tally(&w)); + newline(&mut printer()); + let [u, v] = public(1); + let _ = (public_data(&u), public_data(&v)); +} diff --git a/ui/dependent_field.stderr b/ui/dependent_field.stderr new file mode 100644 index 0000000..23b5acb --- /dev/null +++ b/ui/dependent_field.stderr @@ -0,0 +1,19 @@ +warning: `raw` is only read where `tag == Tag::Subproc` has been tested (3 reads), and every `Child` made with another `tag` fills it with a placeholder (1 site) + --> $DIR/dependent_field.rs:16:5 + | +LL | raw: *mut u8, + | ^^^^^^^^^^^^ + | + = help: the field is the payload of that one case, stored flat; an enum variant carrying it leaves the other cases nothing to fill in or misread + = note: `#[warn(dependent_field)]` on by default + +warning: `reject` is only read where `request` has been tested (2 reads), and every `Verify` made with another `request` fills it with a placeholder (1 site) + --> $DIR/dependent_field.rs:57:5 + | +LL | reject: bool, + | ^^^^^^^^^^^^ + | + = help: the field is the payload of that one case, stored flat; an enum variant carrying it leaves the other cases nothing to fill in or misread + +warning: 2 warnings emitted + From a1476dc882fcc74efe33742637269413fcecedf2 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:07:39 -0700 Subject: [PATCH 05/34] add collapsed_error --- README.md | 1 + src/collapsed_error.rs | 432 ++++++++++++++++++++++++++++++++++++ src/defaulted_failure.rs | 16 +- src/hir_shapes.rs | 19 ++ src/lib.rs | 9 +- ui/collapsed_error.rs | 234 +++++++++++++++++++ ui/collapsed_error.stderr | 94 ++++++++ ui/defaulted_failure.stderr | 29 ++- 8 files changed, 817 insertions(+), 17 deletions(-) create mode 100644 src/collapsed_error.rs create mode 100644 ui/collapsed_error.rs create mode 100644 ui/collapsed_error.stderr diff --git a/README.md b/README.md index 800d5d5..b1358f6 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `same_match_twice` | the same `match` over one enum written out arm for arm in two places: a mapping the enum should state once as a method, kept in step by hand instead | | `reimplemented_helper` | a function whose signature and body repeat another function in the crate under a different name: one helper written twice, so a fix to one copy misses the other | | `dependent_field` | a field every reader tests a sibling for one value before touching, and every other construction fills with a placeholder: an enum payload stored flat beside its tag | +| `collapsed_error` | `f(x);` or `let _ = f(x)` on a crate fn whose `false`/`None` is the bare `Err` arm of a `Result` it held: the typed error became one bit, and this call drops the bit | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/collapsed_error.rs b/src/collapsed_error.rs new file mode 100644 index 0000000..fa980aa --- /dev/null +++ b/src/collapsed_error.rs @@ -0,0 +1,432 @@ +use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; + +use clippy_utils::visitors::for_each_expr_without_closures; +use clippy_utils::{get_expr_use_or_unification_node, is_def_id_trait_method, is_in_test}; +use rustc_abi::ExternAbi; +use rustc_hir::def::DefKind; +use rustc_hir::def_id::{DefId, LocalDefId}; +use rustc_hir::intravisit::FnKind; +use rustc_hir::{ + Block, Body, Expr, ExprKind, FnDecl, HirId, LangItem, LetStmt, MatchSource, Node, PatKind, + StmtKind, +}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::print::with_no_trimmed_paths; +use rustc_middle::ty::{self, Ty}; +use rustc_span::Span; + +use crate::adt_facts::{is_option_ty, result_err_ty}; +use crate::baseline::emit_hir_then; +use crate::enum_facts::{arm_variant, ctor_literal_variant}; +use crate::hir_shapes::{callee_of, peel_blocks_unsafe, peel_not, sole_expr}; + +rustc_session::declare_lint! { + /// Flags a call that drops the `bool` or `Option` a function of this + /// crate collapsed a typed error into. The callee returns `bool` (or + /// `Option`) and somewhere in its body the `Err` of a `Result` it held + /// becomes a bare `false` (or `None`) and nothing else -- `match r { + /// Err(_) => return false, .. }`, `if r.is_err() { return false }`, `let + /// Ok(v) = r else { return None }`, `r.ok()?`, or `r.is_ok()` / `r.ok()` + /// as the value returned -- so the error's kind is already gone from its + /// signature; the reported call then ignores even that bit (`f(x);`, + /// `let _ = f(x);`), and the failure can no longer be observed anywhere. + /// A `Result` return would have made this caller decide. + /// + /// Silent when the `Err` arm or `else` block does anything besides exit + /// (logs, stores or converts the error: it was looked at); when the error + /// type is `()`, `!` or a bare number (`binary_search`'s `Err(idx)` is an + /// answer, not a failure); on trait methods and non-Rust-ABI functions + /// (the signature is not the function's to choose); on collapses inside + /// closures; on callees in other crates; on calls in tests or produced + /// by macros; on every call whose value is read (`if`, `&&`, `let x =`, + /// `?`, a tail); and on `unwrap_or(false)` over a `Result`, + /// which is `defaulted_failure`'s vocabulary. `discarded_error` owns + /// `.ok();` on the `Result` itself: there the collapse and the drop are + /// one expression; here they are a signature apart. + pub COLLAPSED_ERROR, + Warn, + "a call drops the bool or Option a callee collapsed a typed error into" +} + +/// What the collapsing function hands back in place of the error. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Exit { + False, + None, +} + +impl Exit { + fn as_str(self) -> &'static str { + match self { + Exit::False => "false", + Exit::None => "None", + } + } +} + +/// One function's collapse: the first site by position, how many more +/// there are, and the error type lost at that first site. +struct Collapse { + exit: Exit, + at: Span, + err: String, + more: usize, +} + +/// A call to a local `bool`/`Option` function whose value nothing reads. +struct Dropped { + callee: LocalDefId, + hir_id: HirId, + span: Span, +} + +#[derive(Default)] +pub struct CollapsedError { + collapses: HashMap, + /// In visit order, so reports come out in source order per file. + drops: Vec, +} + +rustc_session::impl_lint_pass!(CollapsedError => [COLLAPSED_ERROR]); + +/// The error type of `e`'s `Result`, when it is one worth calling an error: +/// not `()`, `!`, a bare primitive or an uninhabited enum. +fn err_of<'tcx>(cx: &LateContext<'tcx>, e: &Expr<'tcx>) -> Option> { + let ty = cx.typeck_results().expr_ty(e).peel_refs(); + let err = result_err_ty(cx.tcx, ty)?.peel_refs(); + if err.is_unit() || err.is_never() || err.is_primitive() || err.is_str() { + return None; + } + if let ty::Adt(adt, _) = err.kind() + && adt.is_enum() + && adt.variants().is_empty() + { + return None; + } + Some(err) +} + +/// `v` is the bare exit value: the literal `false`, or the path `None`. +fn is_exit(cx: &LateContext<'_>, v: &Expr<'_>, exit: Exit) -> bool { + let v = peel_blocks_unsafe(v); + match exit { + Exit::False => { + matches!(v.kind, ExprKind::Lit(lit) if lit.node == rustc_ast::LitKind::Bool(false)) + } + Exit::None => ctor_literal_variant(cx, v) + .is_some_and(|d| Some(d) == cx.tcx.lang_items().option_none_variant()), + } +} + +/// `e` does nothing but leave the function with the exit value: `return +/// false`, `{ return false; }`, or -- when `e` is itself the function's +/// value (`value_position`) -- the bare `false`. Returns that leaving +/// expression. +fn pure_exit<'h>( + cx: &LateContext<'_>, + e: &'h Expr<'h>, + exit: Exit, + value_position: bool, +) -> Option<&'h Expr<'h>> { + let mut e = peel_blocks_unsafe(e); + if let ExprKind::Block(b, _) = e.kind { + e = peel_blocks_unsafe(sole_expr(b)?); + } + let exits = match e.kind { + ExprKind::Ret(Some(v)) => is_exit(cx, v, exit), + ExprKind::Ret(None) => false, + _ => value_position && is_exit(cx, e, exit), + }; + exits.then_some(e) +} + +/// The expressions whose value is the function's value: the body's tail +/// and every `return` operand, followed down through blocks, `if` and +/// `match`. `branches` are the `if`/`match` nodes passed through (their +/// arms are in value position); `leaves` are where the descent stopped. +#[derive(Default)] +struct Tails<'tcx> { + branches: HashSet, + leaves: Vec<&'tcx Expr<'tcx>>, +} + +impl<'tcx> Tails<'tcx> { + fn of(body: &'tcx Body<'tcx>) -> Self { + let mut tails = Self::default(); + tails.descend(body.value); + for_each_expr_without_closures(body.value, |e: &'tcx Expr<'tcx>| { + if let ExprKind::Ret(Some(v)) = e.kind { + tails.descend(v); + } + ControlFlow::<()>::Continue(()) + }); + tails + } + + fn descend(&mut self, e: &'tcx Expr<'tcx>) { + match e.kind { + ExprKind::DropTemps(inner) => self.descend(inner), + ExprKind::Block(b, _) => { + if let Some(tail) = b.expr { + self.descend(tail); + } + } + ExprKind::If(_, then, els) => { + self.branches.insert(e.hir_id); + self.descend(then); + if let Some(els) = els { + self.descend(els); + } + } + ExprKind::Match(_, arms, MatchSource::Normal) => { + self.branches.insert(e.hir_id); + for arm in arms { + self.descend(arm.body); + } + } + _ => self.leaves.push(e), + } + } +} + +/// Every site in `body` where a `Result`'s error becomes the bare `exit` +/// value and nothing else happens to it. +fn collapses<'tcx>( + cx: &LateContext<'tcx>, + body: &'tcx Body<'tcx>, + exit: Exit, +) -> Vec<(Span, Ty<'tcx>)> { + let li = cx.tcx.lang_items(); + let (err_variant, ok_variant) = (li.result_err_variant(), li.result_ok_variant()); + let tails = Tails::of(body); + let mut sites = Vec::new(); + + for leaf in &tails.leaves { + if let ExprKind::MethodCall(seg, recv, [], _) = leaf.kind + && let Some(err) = err_of(cx, recv) + && match exit { + Exit::False => seg.ident.as_str() == "is_ok", + Exit::None => seg.ident.as_str() == "ok", + } + { + sites.push((leaf.span, err)); + } + } + + for_each_expr_without_closures(body.value, |e: &'tcx Expr<'tcx>| { + let value_position = tails.branches.contains(&e.hir_id); + match e.kind { + ExprKind::Match(scrut, arms, MatchSource::Normal) => { + if let Some(err) = err_of(cx, scrut) { + for arm in arms { + if arm.guard.is_none() + && arm_variant(cx, arm.pat).is_some_and(|v| Some(v) == err_variant) + && pure_exit(cx, arm.body, exit, value_position).is_some() + { + sites.push((arm.span, err)); + } + } + } + } + ExprKind::Match(scrut, _, MatchSource::TryDesugar(_)) if exit == Exit::None => { + if let ExprKind::Call(branch, [operand]) = scrut.kind + && let ExprKind::Path(qpath) = branch.kind + && cx.tcx.qpath_is_lang_item(qpath, LangItem::TryTraitBranch) + && let ExprKind::MethodCall(seg, recv, [], _) = peel_blocks_unsafe(operand).kind + && seg.ident.as_str() == "ok" + && let Some(err) = err_of(cx, recv) + { + sites.push((operand.span, err)); + } + } + ExprKind::If(cond, then, els) => { + let (cond, negated) = peel_not(cond); + let taken = match cond.kind { + ExprKind::MethodCall(seg, recv, [], _) => err_of(cx, recv).and_then(|err| { + let on_err = match (seg.ident.as_str(), negated) { + ("is_err", false) | ("is_ok", true) => Some(then), + ("is_ok", false) | ("is_err", true) => els, + _ => None, + }; + on_err.map(|b| (b, err)) + }), + ExprKind::Let(l) => err_of(cx, l.init).and_then(|err| { + let head = arm_variant(cx, l.pat)?; + let on_err = if Some(head) == err_variant { + Some(then) + } else if Some(head) == ok_variant { + els + } else { + None + }; + on_err.map(|b| (b, err)) + }), + _ => None, + }; + if let Some((on_err, err)) = taken + && let Some(leave) = pure_exit(cx, on_err, exit, value_position) + { + sites.push((leave.span, err)); + } + } + ExprKind::Block(block, _) => { + let_else_collapses(cx, block, exit, ok_variant, &mut sites) + } + _ => {} + } + ControlFlow::<()>::Continue(()) + }); + sites +} + +/// `let Ok(v) = r else { return false };` statements of `block`. +fn let_else_collapses<'tcx>( + cx: &LateContext<'tcx>, + block: &'tcx Block<'tcx>, + exit: Exit, + ok_variant: Option, + sites: &mut Vec<(Span, Ty<'tcx>)>, +) { + for stmt in block.stmts { + if let StmtKind::Let(LetStmt { + pat, + init: Some(init), + els: Some(els), + .. + }) = stmt.kind + && let Some(err) = err_of(cx, init) + && arm_variant(cx, pat).is_some_and(|v| Some(v) == ok_variant) + && let Some(only) = sole_expr(els) + && let Some(leave) = pure_exit(cx, only, exit, false) + { + sites.push((leave.span, err)); + } + } +} + +/// Nothing reads the call's value: it is a statement of its own (through +/// any blocks or one-armed matches that merely pass it up), or the +/// initializer of `let _ =`. +fn is_dropped(cx: &LateContext<'_>, call: &Expr<'_>) -> bool { + match get_expr_use_or_unification_node(cx.tcx, call) { + None => true, + Some((Node::Stmt(stmt), _)) => matches!(stmt.kind, StmtKind::Semi(_) | StmtKind::Expr(_)), + Some((Node::LetStmt(l), _)) => matches!(l.pat.kind, PatKind::Wild), + Some(_) => false, + } +} + +impl<'tcx> LateLintPass<'tcx> for CollapsedError { + fn check_fn( + &mut self, + cx: &LateContext<'tcx>, + kind: FnKind<'tcx>, + _decl: &'tcx FnDecl<'tcx>, + body: &'tcx Body<'tcx>, + span: Span, + def_id: LocalDefId, + ) { + if matches!(kind, FnKind::Closure) + || span.from_expansion() + || kind.header().is_some_and(|h| h.abi != ExternAbi::Rust) + || is_def_id_trait_method(cx, def_id) + || cx.tcx.trait_of_assoc(def_id.to_def_id()).is_some() + { + return; + } + let ret = cx + .tcx + .fn_sig(def_id) + .instantiate_identity() + .skip_normalization() + .output() + .skip_binder(); + let exit = if ret.is_bool() { + Exit::False + } else if is_option_ty(cx, ret) { + Exit::None + } else { + return; + }; + let mut sites = collapses(cx, body, exit); + sites.sort_by_key(|(span, _)| span.lo()); + sites.dedup_by_key(|(span, _)| *span); + // Printed now because `Ty` cannot outlive the pass; untrimmed because + // most of these are never reported, and a trimmed print with no + // diagnostic after it is a delayed ICE. + if let Some(&(at, err)) = sites.first() { + self.collapses.insert( + def_id, + Collapse { + exit, + at, + err: with_no_trimmed_paths!(err.to_string()), + more: sites.len() - 1, + }, + ); + } + } + + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + if !matches!(expr.kind, ExprKind::Call(..) | ExprKind::MethodCall(..)) + || expr.span.from_expansion() + { + return; + } + let Some(callee) = callee_of(cx, expr) else { + return; + }; + let Some(local) = callee.def().as_local() else { + return; + }; + if !matches!(cx.tcx.def_kind(local), DefKind::Fn | DefKind::AssocFn) { + return; + } + let ty = cx.typeck_results().expr_ty(expr); + if !(ty.is_bool() || is_option_ty(cx, ty)) + || !is_dropped(cx, expr) + || is_in_test(cx.tcx, expr.hir_id) + { + return; + } + self.drops.push(Dropped { + callee: local, + hir_id: expr.hir_id, + span: expr.span, + }); + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + for dropped in &self.drops { + let Some(collapse) = self.collapses.get(&dropped.callee) else { + continue; + }; + let name = with_no_trimmed_paths!(cx.tcx.def_path_str(dropped.callee)); + let exit = collapse.exit.as_str(); + let err = &collapse.err; + emit_hir_then( + cx, + COLLAPSED_ERROR, + dropped.hir_id, + dropped.span, + format!( + "`{name}` reports `{err}` only as `{exit}`, and this call drops the `{exit}`: the failure can no longer be observed anywhere" + ), + |diag| { + let more = match collapse.more { + 0 => String::new(), + n => format!(" (and at {n} more like it)"), + }; + diag.span_note( + collapse.at, + format!("the error becomes `{exit}` here{more}, and nothing else is done with it"), + ); + diag.help( + "return the `Result` and let this caller decide; failing that, `#[must_use]`", + ); + }, + ); + } + } +} diff --git a/src/defaulted_failure.rs b/src/defaulted_failure.rs index edf6e0b..eb2c299 100644 --- a/src/defaulted_failure.rs +++ b/src/defaulted_failure.rs @@ -10,7 +10,7 @@ use rustc_span::{Span, sym}; use crate::adt_facts::{matches_config_path, result_err_ty}; use crate::baseline::{emit, emit_with_note}; use crate::enum_facts::{arm_variant, ctor_literal_variant}; -use crate::hir_shapes::{callee_of, peel_blocks_unsafe}; +use crate::hir_shapes::{callee_of, peel_blocks_unsafe, sole_expr}; rustc_session::declare_lint! { /// Flags a call whose failure is replaced by a fixed value and never @@ -263,18 +263,8 @@ fn fixed_fallback<'tcx>( /// The else block of a `let .. else` reports success or nothing at all: /// `return`, `return ()` or `return Ok(())`, and nothing else in the block. fn else_reports_success(cx: &LateContext<'_>, els: &Block<'_>) -> bool { - let only = match (els.stmts, els.expr) { - ([], Some(e)) => e, - ( - [ - Stmt { - kind: StmtKind::Semi(e) | StmtKind::Expr(e), - .. - }, - ], - None, - ) => e, - _ => return false, + let Some(only) = sole_expr(els) else { + return false; }; let ExprKind::Ret(value) = peel_blocks_unsafe(only).kind else { return false; diff --git a/src/hir_shapes.rs b/src/hir_shapes.rs index a0b03ee..ced7333 100644 --- a/src/hir_shapes.rs +++ b/src/hir_shapes.rs @@ -124,6 +124,25 @@ pub(crate) fn peel_blocks_unsafe<'h>(mut e: &'h Expr<'h>) -> &'h Expr<'h> { e } +/// The block's only expression: `{ e }` with no statements, or `{ e; }` / +/// `{ e }` as a single expression statement with no tail. Anything else is +/// None. +pub(crate) fn sole_expr<'h>(b: &'h Block<'h>) -> Option<&'h Expr<'h>> { + match (b.stmts, b.expr) { + ([], Some(e)) => Some(e), + ( + [ + Stmt { + kind: StmtKind::Semi(e) | StmtKind::Expr(e), + .. + }, + ], + None, + ) => Some(e), + _ => None, + } +} + /// The statement's expression, for `let` its initializer. pub(crate) fn stmt_expr<'tcx>(stmt: &'tcx Stmt<'tcx>) -> Option<&'tcx Expr<'tcx>> { match stmt.kind { diff --git a/src/lib.rs b/src/lib.rs index 0b73aba..fedfef9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ mod baseline; mod bypassed_conversion; mod bypassed_validator; mod claims; +mod collapsed_error; mod ctor_flow; mod defaulted_failure; mod dependent_field; @@ -160,9 +161,10 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto use { asymmetric_guard::AsymmetricGuard, baseline::BaselineWriter, bypassed_conversion::BypassedConversion, bypassed_validator::BypassedValidator, - defaulted_failure::DefaultedFailure, dependent_field::DependentField, - discarded_error::DiscardedError, exclusive_options::ExclusiveOptions, - flag_cluster::FlagCluster, forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, + collapsed_error::CollapsedError, defaulted_failure::DefaultedFailure, + dependent_field::DependentField, discarded_error::DiscardedError, + exclusive_options::ExclusiveOptions, flag_cluster::FlagCluster, + forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, misbound_arg::MisboundArg, narrowed_return::NarrowedReturn, nonidentity_key::NonidentityKey, overwide_parameter::OverwideParameter, parallel_bools::ParallelBools, @@ -215,6 +217,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto reimplemented_helper::ReimplementedHelper::new(config) }); add(s, true, DependentField::default); + add(s, true, CollapsedError::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/ui/collapsed_error.rs b/ui/collapsed_error.rs new file mode 100644 index 0000000..3b81adc --- /dev/null +++ b/ui/collapsed_error.rs @@ -0,0 +1,234 @@ +// A crate fn folds a `Result`'s typed error into a bare `false`/`None`, and a call drops even that. + +struct SysError(i32); + +fn sys_write(buf: &mut Vec, bytes: &[u8]) -> Result { + if buf.len() + bytes.len() > 64 { + return Err(SysError(28)); + } + buf.extend_from_slice(bytes); + Ok(bytes.len()) +} + +// Collapses in a tail `match`: `Err(_) => false`. +fn write_pidfile(buf: &mut Vec) -> bool { + match sys_write(buf, b"1") { + Ok(_) => true, + Err(_) => false, + } +} + +// Flagged: statement position drops the `false`. +fn pidfile_dropped(buf: &mut Vec) { + write_pidfile(buf); +} + +// Flagged: `let _ =` drops it just the same. +fn pidfile_discarded(buf: &mut Vec) { + let _ = write_pidfile(buf); +} + +// Fine: the `false` is read. +fn pidfile_checked(buf: &mut Vec) -> u8 { + if write_pidfile(buf) { 1 } else { 0 } +} + +// Collapses as the returned value: `.is_ok()`. +fn set_mode(buf: &mut Vec) -> bool { + sys_write(buf, b"m").is_ok() +} + +// Flagged. +fn mode_discarded(buf: &mut Vec) { + let _ = set_mode(buf); +} + +// Collapses in an early return: `if r.is_err() { return false }`. +fn sync_dir(buf: &mut Vec) -> bool { + let r = sys_write(buf, b"s"); + if r.is_err() { + return false; + } + buf.push(0); + true +} + +// Flagged. +fn sync_dropped(buf: &mut Vec) { + sync_dir(buf); +} + +// Collapses in a `let .. else`. +fn reserve(buf: &mut Vec) -> bool { + let Ok(n) = sys_write(buf, b"r") else { + return false; + }; + n > 0 +} + +// Flagged. +fn reserve_discarded(buf: &mut Vec) { + let _ = reserve(buf); +} + +// Collapses into `None` through `.ok()?`. +fn open_slot(buf: &mut Vec) -> Option { + let n = sys_write(buf, b"o").ok()?; + Some(n + 1) +} + +// Flagged: the `None` is dropped. +fn slot_dropped(buf: &mut Vec) { + open_slot(buf); +} + +// Fine: the `None` is read. +fn slot_checked(buf: &mut Vec) -> usize { + if let Some(n) = open_slot(buf) { n } else { 0 } +} + +// Fine: `?` on the `Option` passes the `None` on; and `slot_chained` itself +// collapses no `Result`, so dropping ITS `None` is not this lint's. +fn slot_chained(buf: &mut Vec) -> Option { + let n = open_slot(buf)?; + Some(n * 2) +} + +fn chained_dropped(buf: &mut Vec) { + slot_chained(buf); +} + +// Collapses into `None` in a tail `if let .. else`. +fn probe_slot(buf: &mut Vec) -> Option { + if let Ok(n) = sys_write(buf, b"p") { + Some(n) + } else { + None + } +} + +// Flagged. +fn probe_dropped(buf: &mut Vec) { + probe_slot(buf); +} + +// Fine: the `Err` arm looks at the error before answering `false`. +fn write_logged(buf: &mut Vec, log: &mut Vec) -> bool { + match sys_write(buf, b"l") { + Ok(_) => true, + Err(e) => { + log.push(e.0); + false + } + } +} + +fn logged_dropped(buf: &mut Vec, log: &mut Vec) { + write_logged(buf, log); +} + +// Fine: every call reads the `bool`. +fn try_mark(buf: &mut Vec) -> bool { + sys_write(buf, b"c").is_ok() +} + +fn mark_checked(buf: &mut Vec) -> u8 { + if try_mark(buf) { 1 } else { 0 } +} + +// Fine: `binary_search`'s `Err(idx)` is an answer, not a failure. +fn is_sorted_in(haystack: &[u32], needle: u32) -> bool { + haystack.binary_search(&needle).is_ok() +} + +fn sorted_discarded(haystack: &[u32]) { + let _ = is_sorted_in(haystack, 3); +} + +// Fine: a `()` error carries no kind to lose. +fn ping(up: bool) -> Result<(), ()> { + if up { Ok(()) } else { Err(()) } +} + +fn pinged(up: bool) -> bool { + ping(up).is_ok() +} + +fn ping_discarded() { + let _ = pinged(true); +} + +// Fine: a trait method's signature is the trait's, not the impl's. +trait Sink { + fn put(&mut self, byte: u8) -> bool; +} + +impl Sink for Vec { + fn put(&mut self, byte: u8) -> bool { + sys_write(self, &[byte]).is_ok() + } +} + +fn put_dropped(buf: &mut Vec) { + buf.put(1); +} + +// Fine: an exported C signature cannot return a `Result`. +fn checked_len(n: usize) -> Result { + if n > 64 { Err(SysError(22)) } else { Ok(n) } +} + +extern "C" fn exported_len_ok(n: usize) -> bool { + checked_len(n).is_ok() +} + +fn exported_discarded() { + let _ = exported_len_ok(3); +} + +// Fine: `unwrap_or(false)` on a `Result` folds "could not tell" +// into "no"; that shape is `defaulted_failure`'s to judge. +fn probe(buf: &mut Vec) -> Result { + Ok(sys_write(buf, b"q")? > 0) +} + +fn folded(buf: &mut Vec) -> bool { + probe(buf).unwrap_or(false) +} + +fn folded_discarded(buf: &mut Vec) { + let _ = folded(buf); +} + +// Fine: the collapse happens inside a closure, whose signature is its own. +fn with_closure(buf: &mut Vec) -> bool { + let attempt = |b: &mut Vec| sys_write(b, b"x").is_ok(); + attempt(buf) +} + +fn closure_dropped(buf: &mut Vec) { + with_closure(buf); +} + +fn main() { + let mut buf = Vec::new(); + let mut log = Vec::new(); + pidfile_dropped(&mut buf); + pidfile_discarded(&mut buf); + let _ = pidfile_checked(&mut buf); + mode_discarded(&mut buf); + sync_dropped(&mut buf); + reserve_discarded(&mut buf); + slot_dropped(&mut buf); + let _ = slot_checked(&mut buf); + chained_dropped(&mut buf); + probe_dropped(&mut buf); + logged_dropped(&mut buf, &mut log); + let _ = mark_checked(&mut buf); + sorted_discarded(&[1, 2, 3]); + ping_discarded(); + put_dropped(&mut buf); + exported_discarded(); + folded_discarded(&mut buf); + closure_dropped(&mut buf); +} diff --git a/ui/collapsed_error.stderr b/ui/collapsed_error.stderr new file mode 100644 index 0000000..33b81fb --- /dev/null +++ b/ui/collapsed_error.stderr @@ -0,0 +1,94 @@ +warning: `write_pidfile` reports `SysError` only as `false`, and this call drops the `false`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:23:5 + | +LL | write_pidfile(buf); + | ^^^^^^^^^^^^^^^^^^ + | +note: the error becomes `false` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:17:9 + | +LL | Err(_) => false, + | ^^^^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + = note: `#[warn(collapsed_error)]` on by default + +warning: `write_pidfile` reports `SysError` only as `false`, and this call drops the `false`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:28:13 + | +LL | let _ = write_pidfile(buf); + | ^^^^^^^^^^^^^^^^^^ + | +note: the error becomes `false` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:17:9 + | +LL | Err(_) => false, + | ^^^^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: `set_mode` reports `SysError` only as `false`, and this call drops the `false`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:43:13 + | +LL | let _ = set_mode(buf); + | ^^^^^^^^^^^^^ + | +note: the error becomes `false` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:38:5 + | +LL | sys_write(buf, b"m").is_ok() + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: `sync_dir` reports `SysError` only as `false`, and this call drops the `false`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:58:5 + | +LL | sync_dir(buf); + | ^^^^^^^^^^^^^ + | +note: the error becomes `false` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:50:9 + | +LL | return false; + | ^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: `reserve` reports `SysError` only as `false`, and this call drops the `false`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:71:13 + | +LL | let _ = reserve(buf); + | ^^^^^^^^^^^^ + | +note: the error becomes `false` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:64:9 + | +LL | return false; + | ^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: `open_slot` reports `SysError` only as `None`, and this call drops the `None`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:82:5 + | +LL | open_slot(buf); + | ^^^^^^^^^^^^^^ + | +note: the error becomes `None` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:76:13 + | +LL | let n = sys_write(buf, b"o").ok()?; + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: `probe_slot` reports `SysError` only as `None`, and this call drops the `None`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:112:5 + | +LL | probe_slot(buf); + | ^^^^^^^^^^^^^^^ + | +note: the error becomes `None` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:106:9 + | +LL | None + | ^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: 7 warnings emitted + diff --git a/ui/defaulted_failure.stderr b/ui/defaulted_failure.stderr index 00aa335..e1789b2 100644 --- a/ui/defaulted_failure.stderr +++ b/ui/defaulted_failure.stderr @@ -201,5 +201,32 @@ LL | u32::from_str_radix(s, 16).unwrap_or(0) | = help: propagate the failure, or handle the failing arm where its cause is still visible -warning: 16 warnings emitted +warning: `let_else_reporting_failure_is_fine` reports `ParseError` only as `false`, and this call drops the `false`: the failure can no longer be observed anywhere + --> $DIR/defaulted_failure.rs:393:13 + | +LL | let _ = let_else_reporting_failure_is_fine("a", &mut ports); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the error becomes `false` here, and nothing else is done with it + --> $DIR/defaulted_failure.rs:328:9 + | +LL | return false; + | ^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + = note: `#[warn(collapsed_error)]` on by default + +warning: `let_else_returning_none_is_fine` reports `ParseError` only as `None`, and this call drops the `None`: the failure can no longer be observed anywhere + --> $DIR/defaulted_failure.rs:394:13 + | +LL | let _ = let_else_returning_none_is_fine("a"); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the error becomes `None` here, and nothing else is done with it + --> $DIR/defaulted_failure.rs:336:9 + | +LL | return None; + | ^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: 18 warnings emitted From 4108cac865a02db0769fc90a5f1e08d3d067eee3 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:12:50 -0700 Subject: [PATCH 06/34] add uneven_narrowing --- README.md | 1 + src/lib.rs | 8 +- src/uneven_narrowing.rs | 342 +++++++++++++++++++++++++++++++++++++ ui/uneven_narrowing.rs | 161 +++++++++++++++++ ui/uneven_narrowing.stderr | 55 ++++++ 5 files changed, 564 insertions(+), 3 deletions(-) create mode 100644 src/uneven_narrowing.rs create mode 100644 ui/uneven_narrowing.rs create mode 100644 ui/uneven_narrowing.stderr diff --git a/README.md b/README.md index b1358f6..8b6fd89 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `reimplemented_helper` | a function whose signature and body repeat another function in the crate under a different name: one helper written twice, so a fix to one copy misses the other | | `dependent_field` | a field every reader tests a sibling for one value before touching, and every other construction fills with a placeholder: an enum payload stored flat beside its tag | | `collapsed_error` | `f(x);` or `let _ = f(x)` on a crate fn whose `false`/`None` is the bare `Err` arm of a `Result` it held: the typed error became one bit, and this call drops the bit | +| `uneven_narrowing` | an integer field or local converted with `try_from` at one site and a bare `as` at another: the check says the value may not fit, and `as` wraps silently when it doesn't | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/lib.rs b/src/lib.rs index fedfef9..e9f1779 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -53,6 +53,7 @@ mod stored_projection; mod stringified_error; mod stringly_error; mod unchecked_input_len; +mod uneven_narrowing; mod unit_mismatch; mod unread_error_variant; mod unread_none; @@ -171,9 +172,9 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto stale_across_reentry::StaleAcrossReentry, stale_panic_message::StalePanicMessage, stale_safety_comment::StaleSafetyComment, stored_projection::StoredProjection, stringified_error::StringifiedError, stringly_error::StringlyError, - unchecked_input_len::UncheckedInputLen, unit_mismatch::UnitMismatch, - unread_error_variant::UnreadErrorVariant, unread_none::UnreadNone, - wildcard_local_enum::WildcardLocalEnum, + unchecked_input_len::UncheckedInputLen, uneven_narrowing::UnevenNarrowing, + unit_mismatch::UnitMismatch, unread_error_variant::UnreadErrorVariant, + unread_none::UnreadNone, wildcard_local_enum::WildcardLocalEnum, }; dylint_linting::init_config(sess); let config: MordantConfig = dylint_linting::config_or_default(env!("CARGO_PKG_NAME")); @@ -218,6 +219,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto }); add(s, true, DependentField::default); add(s, true, CollapsedError::default); + add(s, true, UnevenNarrowing::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/src/uneven_narrowing.rs b/src/uneven_narrowing.rs new file mode 100644 index 0000000..c6b84e1 --- /dev/null +++ b/src/uneven_narrowing.rs @@ -0,0 +1,342 @@ +use std::collections::{HashMap, HashSet}; + +use clippy_utils::consts::ConstEvalCtxt; +use clippy_utils::res::MaybeResPath; +use clippy_utils::source::snippet; +use rustc_hir::def::{DefKind, Res}; +use rustc_hir::def_id::DefId; +use rustc_hir::{BinOpKind, Expr, ExprKind, HirId, PatKind, UnOp}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::{self, Ty}; +use rustc_span::{Span, Symbol, sym}; + +use crate::baseline::emit_with_note; +use crate::hir_shapes::{Callee, callee_of}; + +rustc_session::declare_lint! { + /// Flags an integer place — a struct field, or a local or parameter + /// within one function — that the crate narrows two ways: at one site it + /// is converted into a smaller or differently signed integer through + /// `u32::try_from(x)` or `x.try_into()`, and at another the same place + /// goes through a bare `as`, which wraps silently. The check says the + /// value may not fit; the `as` site is where it will not. The place's + /// declared type is wider than what its readers need, so the range + /// invariant lives in whichever reader remembered it. + /// + /// A field is one place whatever it is read through, keyed by the struct + /// that declares it; a local is a place only inside its own body. Only + /// bare places count: `(x & 0xff) as u8`, `x.min(MAX) as u32` and casts + /// of call results are computations, not the place, and `x as u32 & + /// mask` keeps low bits on purpose. An `as` in a function that also + /// compares the place against a constant (`x <= u32::MAX as usize`, a + /// range pattern) is that function's checked form and stays quiet, but + /// it is not evidence against other sites: what a comparison guards is + /// not in its syntax. `usize` and `isize` have the target's pointer + /// width, so `u64 as usize` on a 64-bit target is not a narrowing. A + /// checked site only condemns `as` casts to a type at most as wide as + /// its own target: `u8::try_from(c)` inside an arm that already matched + /// a letter says nothing about `c as u32` elsewhere. Sites inside macro + /// expansions are not read. + pub UNEVEN_NARROWING, + Warn, + "an integer place range-checked at one narrowing and truncated with `as` at another" +} + +/// The place a conversion reads: a field of whichever struct declares it, or +/// a local binding. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Place { + Field(DefId, Symbol), + Local(HirId), +} + +struct Site { + span: Span, + /// The fn (closures folded into their parent) the site sits in, for the + /// compare-then-cast form. + body: DefId, + dst_bits: u64, + /// `try_from` / `try_into`: the evidence that the place may not fit. + checked: bool, + shown: String, + src: String, + dst: String, +} + +#[derive(Default)] +pub struct UnevenNarrowing { + sites: HashMap>, + /// (fn, place): the fn compares the place against a constant, so its + /// `as` casts of that place are excused (not reported, not evidence). + range_checked: HashSet<(DefId, Place)>, +} + +rustc_session::impl_lint_pass!(UnevenNarrowing => [UNEVEN_NARROWING]); + +#[derive(Clone, Copy, PartialEq, Eq)] +struct IntLayout { + bits: u64, + signed: bool, +} + +/// `usize`/`isize` take the target's pointer width: whether `u64 as usize` +/// narrows is a question about the target being linted, not about all of +/// them. +fn int_layout(cx: &LateContext<'_>, ty: Ty<'_>) -> Option { + let ptr_bits = || cx.tcx.data_layout.pointer_size().bits(); + match ty.kind() { + ty::Int(i) => Some(IntLayout { + bits: i.bit_width().unwrap_or_else(ptr_bits), + signed: true, + }), + ty::Uint(u) => Some(IntLayout { + bits: u.bit_width().unwrap_or_else(ptr_bits), + signed: false, + }), + _ => None, + } +} + +/// Some value of `src` has no representation in `dst`. +fn lossy(src: IntLayout, dst: IntLayout) -> bool { + let preserving = (src.signed == dst.signed && dst.bits >= src.bits) + || (!src.signed && dst.signed && dst.bits > src.bits); + !preserving +} + +/// The integer place `e` reads, through `&`, `*`, HIR temporaries and inner +/// integer-to-integer casts (which change representation, not which place is +/// read), with its source text to show for it. +fn place_of<'tcx>(cx: &LateContext<'tcx>, mut e: &'tcx Expr<'tcx>) -> Option<(Place, String)> { + let typeck = cx.typeck_results(); + loop { + match e.kind { + ExprKind::Cast(inner, _) + if int_layout(cx, typeck.expr_ty(inner)).is_some() + && int_layout(cx, typeck.expr_ty(e)).is_some() => + { + e = inner; + } + ExprKind::DropTemps(inner) + | ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(UnOp::Deref, inner) => e = inner, + _ => break, + } + } + int_layout(cx, typeck.expr_ty(e).peel_refs())?; + let shown = snippet(cx, e.span, "..").into_owned(); + match e.kind { + ExprKind::Field(base, ident) => { + let adt = typeck.expr_ty_adjusted(base).peel_refs().ty_adt_def()?; + Some((Place::Field(adt.did(), ident.name), shown)) + } + _ => { + let local = e.res_local_id()?; + Some((Place::Local(local), shown)) + } + } +} + +/// `T::try_from(x)` / `x.try_into()`: the operand and the `T` it is checked +/// into. +fn checked_conversion<'tcx>( + cx: &LateContext<'tcx>, + e: &'tcx Expr<'tcx>, +) -> Option<(&'tcx Expr<'tcx>, Ty<'tcx>)> { + // By name, not `clippy_utils::sym::try_from_fn`: that is one of clippy's + // extra symbols, interned at an index the dylint driver need not share. + let operand = match callee_of(cx, e)? { + Callee::Path { def, args: [arg] } + if cx + .tcx + .get_diagnostic_name(def) + .is_some_and(|n| n.as_str() == "try_from_fn") => + { + arg + } + Callee::Method { + def, + recv, + args: [], + } if cx.tcx.item_name(def) == sym::try_into + && cx + .tcx + .opt_parent(def) + .is_some_and(|t| cx.tcx.is_diagnostic_item(sym::TryInto, t)) => + { + recv + } + _ => return None, + }; + let ty::Adt(adt, args) = cx.typeck_results().expr_ty(e).kind() else { + return None; + }; + if !cx.tcx.is_diagnostic_item(sym::Result, adt.did()) { + return None; + } + Some((operand, args.types().next()?)) +} + +/// A comparand fixed at compile time: a literal, a named constant +/// (`u32::MAX`, `LIMIT`), a `const fn` of constants (`size_of::()`), or +/// arithmetic and casts over those. `ConstEvalCtxt` alone stops at `as`. +fn is_constant<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> bool { + match e.kind { + ExprKind::Lit(_) => true, + ExprKind::Cast(inner, _) | ExprKind::DropTemps(inner) | ExprKind::Unary(_, inner) => { + is_constant(cx, inner) + } + ExprKind::Binary(_, l, r) => is_constant(cx, l) && is_constant(cx, r), + ExprKind::Path(ref qpath) => matches!( + cx.qpath_res(qpath, e.hir_id), + Res::Def( + DefKind::Const { .. } | DefKind::AssocConst { .. } | DefKind::ConstParam, + _ + ) + ), + ExprKind::Call(..) => { + matches!(callee_of(cx, e), Some(Callee::Path { def, args }) + if cx.tcx.is_const_fn(def) && args.iter().all(|a| is_constant(cx, a))) + } + _ => ConstEvalCtxt::new(cx).eval(e).is_some(), + } +} + +fn enclosing_fn(cx: &LateContext<'_>, hir_id: HirId) -> DefId { + let owner = cx.tcx.hir_enclosing_body_owner(hir_id).to_def_id(); + cx.tcx.typeck_root_def_id(owner) +} + +impl UnevenNarrowing { + fn record<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + site: &'tcx Expr<'tcx>, + operand: &'tcx Expr<'tcx>, + dst_ty: Ty<'tcx>, + checked: bool, + ) { + let src_ty = cx.typeck_results().expr_ty(operand).peel_refs(); + let (Some(src), Some(dst)) = (int_layout(cx, src_ty), int_layout(cx, dst_ty)) else { + return; + }; + // A conversion that cannot fail is neither a truncation nor a check. + if !lossy(src, dst) { + return; + } + let Some((place, shown)) = place_of(cx, operand) else { + return; + }; + self.sites.entry(place).or_default().push(Site { + span: site.span, + body: enclosing_fn(cx, site.hir_id), + dst_bits: dst.bits, + checked, + shown, + src: src_ty.to_string(), + dst: dst_ty.to_string(), + }); + } + + fn mark_range_checked<'tcx>(&mut self, cx: &LateContext<'tcx>, at: HirId, e: &'tcx Expr<'tcx>) { + if let Some((place, _)) = place_of(cx, e) { + self.range_checked.insert((enclosing_fn(cx, at), place)); + } + } +} + +impl<'tcx> LateLintPass<'tcx> for UnevenNarrowing { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + match expr.kind { + ExprKind::Cast(operand, _) if !expr.span.from_expansion() => { + // `x as u32 & (BITS - 1)`: the low bits are what is wanted. + if let Some(parent) = clippy_utils::get_parent_expr(cx, expr) + && let ExprKind::Binary(op, ..) = parent.kind + && op.node == BinOpKind::BitAnd + { + return; + } + let dst_ty = cx.typeck_results().expr_ty(expr); + self.record(cx, expr, operand, dst_ty, false); + } + // `x < LIMIT`, `u32::MAX as usize >= x`: an explicit range test + // of the place against something constant. `i < self.len` bounds + // `i`, not `self.len`, which is why the other side must be one. + ExprKind::Binary(op, l, r) + if matches!( + op.node, + BinOpKind::Lt | BinOpKind::Le | BinOpKind::Gt | BinOpKind::Ge + ) => + { + if is_constant(cx, r) { + self.mark_range_checked(cx, expr.hir_id, l); + } + if is_constant(cx, l) { + self.mark_range_checked(cx, expr.hir_id, r); + } + } + // `match x { 0..=9 => .., _ => .. }` tests the range too. + ExprKind::Match(scrut, arms, _) + if arms.iter().any(|arm| { + let mut has_range = false; + arm.pat + .walk_always(|p| has_range |= matches!(p.kind, PatKind::Range(..))); + has_range + }) => + { + self.mark_range_checked(cx, expr.hir_id, scrut); + } + _ if !expr.span.from_expansion() => { + if let Some((operand, dst_ty)) = checked_conversion(cx, expr) { + self.record(cx, expr, operand, dst_ty, true); + } + } + _ => {} + } + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let mut findings: Vec<(Span, Span, String)> = Vec::new(); + for (place, sites) in &self.sites { + // The widest checked target: a check into `i64` says the value + // may exceed even that, so every narrower `as` is condemned; a + // check into `u8` says nothing about an `as u32`. + let Some(check) = sites + .iter() + .filter(|s| s.checked) + .max_by_key(|s| (s.dst_bits, std::cmp::Reverse(s.span.lo()))) + else { + continue; + }; + for site in sites { + if site.checked + || site.dst_bits > check.dst_bits + || self.range_checked.contains(&(site.body, *place)) + { + continue; + } + findings.push(( + site.span, + check.span, + format!( + "narrowed two ways: `{}` is `{}` and becomes `{}` through `as` here, which wraps silently, while another site converts the same place into `{}` with a range check", + site.shown, site.src, site.dst, check.dst, + ), + )); + } + } + // `sites` is a HashMap; report in source order. + findings.sort_by_key(|(span, ..)| span.lo()); + for (span, check, msg) in findings { + emit_with_note( + cx, + UNEVEN_NARROWING, + span, + msg, + check, + "the same place, converted with a check here", + "the check says the value may not fit; declare the place with the narrow type, or a newtype whose constructor checks the range once, so no reader can truncate it (or convert with `try_from` here as well)", + ); + } + } +} diff --git a/ui/uneven_narrowing.rs b/ui/uneven_narrowing.rs new file mode 100644 index 0000000..d54fb20 --- /dev/null +++ b/ui/uneven_narrowing.rs @@ -0,0 +1,161 @@ +// A place range-checked at one narrowing must not be truncated with `as` at another. + +use std::convert::{TryFrom, TryInto}; + +struct Buf { + len: usize, + offset: u64, + count: u32, + hash: u64, + code: i32, + small: u16, + point: i32, + start: usize, +} + +// Flagged: `header_len` checks `len` into u32, this truncates it. +fn wire_len(b: &Buf) -> u32 { + b.len as u32 +} + +fn header_len(b: &Buf) -> u32 { + u32::try_from(b.len).expect("length fits the header") +} + +// Flagged: same-width sign flip; `seek_to` checks it, this reinterprets it. +fn tell(b: &Buf) -> i64 { + b.offset as i64 +} + +fn seek_to(b: &Buf) -> i64 { + i64::try_from(b.offset).expect("offset fits off_t") +} + +// Flagged: `try_into` is a check like `try_from`. +fn count_byte(b: &Buf) -> u8 { + b.count as u8 +} + +fn count_checked(b: &Buf) -> u8 { + let c: u8 = b.count.try_into().unwrap_or(u8::MAX); + c +} + +// Flagged: one local, checked and truncated in the same body. +fn local_both_ways(n: u64) -> (u16, u16) { + let checked = u16::try_from(n).unwrap_or(u16::MAX); + (n as u16, checked) +} + +// Fine: the comparison against a constant is the check for the cast after it. +fn compared_then_cast(b: &Buf) -> u32 { + if b.len <= u32::MAX as usize { + b.len as u32 + } else { + u32::MAX + } +} + +// Fine: a range pattern is a check too. +fn matched_then_cast(n: u64) -> u8 { + let wide = u8::try_from(n).is_err(); + match n { + 0..=255 if !wide => n as u8, + _ => 0, + } +} + +// Fine: widening loses nothing. +fn widen(b: &Buf) -> u64 { + b.small as u64 + b.len as u64 +} + +// Fine: nobody checks `hash`; truncating it everywhere is the convention. +fn bucket(b: &Buf) -> u32 { + b.hash as u32 +} + +fn bucket_again(b: &Buf) -> u16 { + b.hash as u16 +} + +// Fine: a computed operand is not the place. +fn low_byte(b: &Buf) -> u8 { + (b.len & 0xff) as u8 +} + +// Fine: the check into `u8` sits where `code` is already known small; it says +// nothing about whether `code` fits the wider `u32` elsewhere. +fn flag_index(b: &Buf) -> u8 { + if b.code >= 0x61 && b.code <= 0x7a { + u8::try_from(b.code).expect("a lowercase letter") - 0x61 + } else { + 0 + } +} + +fn shown(b: &Buf) -> char { + char::from_u32(b.code as u32).unwrap_or('?') +} + +// Fine: comparing `point` against a constant excuses the cast beside it, but +// is no evidence against the cast in `is_letter`: what it guards is not said. +fn in_bmp(b: &Buf) -> bool { + b.point <= 0xFFFF && char::from_u32(b.point as u32).is_some() +} + +fn is_letter(b: &Buf) -> bool { + char::from_u32(b.point as u32).map_or(false, char::is_alphabetic) +} + +// Fine: the mask right after the cast keeps the low bits on purpose. +fn bit_in_word(b: &Buf) -> u32 { + (b.start as u32) & (usize::BITS - 1) +} + +fn word_checked(b: &Buf) -> u32 { + u32::try_from(b.start).expect("small set") / usize::BITS +} + +// Fine: a local is a place only within its own body. +fn checks_its_own(n: u64) -> u32 { + u32::try_from(n).unwrap_or(u32::MAX) +} + +fn truncates_its_own(n: u64) -> u32 { + n as u32 +} + +fn main() { + let b = Buf { + len: 1, + offset: 2, + count: 3, + hash: 4, + code: 5, + small: 6, + point: 11, + start: 12, + }; + let _ = wire_len(&b); + let _ = header_len(&b); + let _ = tell(&b); + let _ = seek_to(&b); + let _ = count_byte(&b); + let _ = count_checked(&b); + let _ = local_both_ways(7); + let _ = compared_then_cast(&b); + let _ = matched_then_cast(8); + let _ = widen(&b); + let _ = bucket(&b); + let _ = bucket_again(&b); + let _ = low_byte(&b); + let _ = flag_index(&b); + let _ = shown(&b); + let _ = in_bmp(&b); + let _ = is_letter(&b); + let _ = bit_in_word(&b); + let _ = word_checked(&b); + let _ = checks_its_own(9); + let _ = truncates_its_own(10); +} diff --git a/ui/uneven_narrowing.stderr b/ui/uneven_narrowing.stderr new file mode 100644 index 0000000..f992de1 --- /dev/null +++ b/ui/uneven_narrowing.stderr @@ -0,0 +1,55 @@ +warning: narrowed two ways: `b.len` is `usize` and becomes `u32` through `as` here, which wraps silently, while another site converts the same place into `u32` with a range check + --> $DIR/uneven_narrowing.rs:18:5 + | +LL | b.len as u32 + | ^^^^^^^^^^^^ + | +note: the same place, converted with a check here + --> $DIR/uneven_narrowing.rs:22:5 + | +LL | u32::try_from(b.len).expect("length fits the header") + | ^^^^^^^^^^^^^^^^^^^^ + = help: the check says the value may not fit; declare the place with the narrow type, or a newtype whose constructor checks the range once, so no reader can truncate it (or convert with `try_from` here as well) + = note: `#[warn(uneven_narrowing)]` on by default + +warning: narrowed two ways: `b.offset` is `u64` and becomes `i64` through `as` here, which wraps silently, while another site converts the same place into `i64` with a range check + --> $DIR/uneven_narrowing.rs:27:5 + | +LL | b.offset as i64 + | ^^^^^^^^^^^^^^^ + | +note: the same place, converted with a check here + --> $DIR/uneven_narrowing.rs:31:5 + | +LL | i64::try_from(b.offset).expect("offset fits off_t") + | ^^^^^^^^^^^^^^^^^^^^^^^ + = help: the check says the value may not fit; declare the place with the narrow type, or a newtype whose constructor checks the range once, so no reader can truncate it (or convert with `try_from` here as well) + +warning: narrowed two ways: `b.count` is `u32` and becomes `u8` through `as` here, which wraps silently, while another site converts the same place into `u8` with a range check + --> $DIR/uneven_narrowing.rs:36:5 + | +LL | b.count as u8 + | ^^^^^^^^^^^^^ + | +note: the same place, converted with a check here + --> $DIR/uneven_narrowing.rs:40:17 + | +LL | let c: u8 = b.count.try_into().unwrap_or(u8::MAX); + | ^^^^^^^^^^^^^^^^^^ + = help: the check says the value may not fit; declare the place with the narrow type, or a newtype whose constructor checks the range once, so no reader can truncate it (or convert with `try_from` here as well) + +warning: narrowed two ways: `n` is `u64` and becomes `u16` through `as` here, which wraps silently, while another site converts the same place into `u16` with a range check + --> $DIR/uneven_narrowing.rs:47:6 + | +LL | (n as u16, checked) + | ^^^^^^^^ + | +note: the same place, converted with a check here + --> $DIR/uneven_narrowing.rs:46:19 + | +LL | let checked = u16::try_from(n).unwrap_or(u16::MAX); + | ^^^^^^^^^^^^^^^^ + = help: the check says the value may not fit; declare the place with the narrow type, or a newtype whose constructor checks the range once, so no reader can truncate it (or convert with `try_from` here as well) + +warning: 4 warnings emitted + From 70bee8e2f3e85968012b117a0558869edd6a47e1 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:14:31 -0700 Subject: [PATCH 07/34] add crossed_index --- README.md | 1 + src/crossed_index.rs | 322 ++++++++++++++++++++++++++++++++++++++++ src/hir_shapes.rs | 25 ++++ src/lib.rs | 10 +- src/unit_mismatch.rs | 27 +--- ui/crossed_index.rs | 116 +++++++++++++++ ui/crossed_index.stderr | 42 ++++++ 7 files changed, 519 insertions(+), 24 deletions(-) create mode 100644 src/crossed_index.rs create mode 100644 ui/crossed_index.rs create mode 100644 ui/crossed_index.stderr diff --git a/README.md b/README.md index 8b6fd89..37425d8 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `dependent_field` | a field every reader tests a sibling for one value before touching, and every other construction fills with a placeholder: an enum payload stored flat beside its tag | | `collapsed_error` | `f(x);` or `let _ = f(x)` on a crate fn whose `false`/`None` is the bare `Err` arm of a `Result` it held: the typed error became one bit, and this call drops the bit | | `uneven_narrowing` | an integer field or local converted with `try_from` at one site and a bare `as` at another: the check says the value may not fit, and `as` wraps silently when it doesn't | +| `crossed_index` | `parts[source_index]` in a function that indexes `parts` by `part_index` and `sources` by `source_index`: two index kinds cross, and both are plain integers | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/crossed_index.rs b/src/crossed_index.rs new file mode 100644 index 0000000..47bc9af --- /dev/null +++ b/src/crossed_index.rs @@ -0,0 +1,322 @@ +use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; + +use clippy_utils::visitors::for_each_expr_without_closures; +use rustc_hir::def::Res; +use rustc_hir::{Body, Expr, ExprKind, QPath, UnOp}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_span::{Span, Symbol}; + +use crate::baseline::emit_with_note; +use crate::hir_shapes::value_name; + +rustc_session::declare_lint! { + /// Flags an index whose name claims one kind (`source_index`, `pkg_id`: + /// the non-empty prefix before `_index`, `_idx`, `_id` or `_i`) landing + /// on a place the same function otherwise indexes by names of another + /// kind, when the function also shows the crossing name indexing a table + /// named after it: `sources[source_index]`, `parts[part_index]` twice, + /// then `parts[source_index]`. Both indices are plain integers, so + /// `parts` accepts a source index and returns whichever element sits at + /// that offset. `[]`, `.get`, `.get_mut` and `get_unchecked*` all count + /// as indexing; a place is the binding or item at the root plus the + /// fields, zero-argument accessors and earlier indices on the way + /// (`self.graph.parts`, `lockfile.packages.names()`, `parts[..]`), so a + /// two-level table is a different place at each level and two locals + /// both called `resolutions` are two places. + /// + /// The claim is read off names only, never types: silent when either + /// name carries no kind suffix (`i`, `n`, `at`), when the two prefixes + /// share or abbreviate a word (`dep_id`/`dependency_id`, + /// `pkg_id`/`package_id`, `other_chunk_index`/`chunk_index`), when the + /// place is itself named after the crossing kind, and when the crossing + /// name has no table of its own name in the function that the other name + /// leaves alone, which is how a role name for the same kind reads + /// (`nodes[parent_idx]` beside `nodes[node_idx]`, `symbols[existing_id]` + /// beside `symbols[symbol_id]`). + pub CROSSED_INDEX, + Warn, + "a place indexed by names of two different index kinds within one function" +} + +rustc_session::declare_lint_pass!(CrossedIndex => [CROSSED_INDEX]); + +/// The index kind a name claims: the non-empty prefix before `_index`, +/// `_idx`, `_id` or `_i`. +fn claimed_kind(name: &str) -> Option<&str> { + ["_index", "_idx", "_id", "_i"] + .iter() + .find_map(|suffix| name.strip_suffix(suffix)) + .filter(|prefix| !prefix.is_empty()) +} + +/// `short` spells `long`: equal, a prefix of it, or its letters in order +/// from the same initial (`pkg`/`package`, `func`/`function`), three letters +/// at least so `id`-sized fragments do not unify everything. +fn abbreviates(short: &str, long: &str) -> bool { + if short == long { + return true; + } + if short.len() < 3 || short.len() > long.len() || short.as_bytes()[0] != long.as_bytes()[0] { + return false; + } + let mut rest = long.chars(); + short.chars().all(|c| rest.any(|l| l == c)) +} + +/// `entries` -> `entry`, `hashes` -> `hash`, `parts` -> `part`; anything +/// else unchanged. +fn singular(word: &str) -> &str { + if let Some(stem) = word.strip_suffix("ies") { + // `y` is not in the stem, but `abbreviates` only needs the prefix. + return stem; + } + for tail in ["shes", "ches", "xes", "sses"] { + if word.ends_with(tail) { + return &word[..word.len() - 2]; + } + } + match word.strip_suffix('s') { + Some(stem) if !stem.ends_with('s') => stem, + _ => word, + } +} + +/// Two `_`-separated names name one kind when any word of one is, or +/// abbreviates, a word of the other, plurals aside: `unresolved_dep` and +/// `dependencies`, `other_chunk` and `chunk`, `pkg` and `package_names`. +fn same_kind(a: &str, b: &str) -> bool { + a.split('_').map(singular).any(|x| { + b.split('_') + .map(singular) + .any(|y| abbreviates(x, y) || abbreviates(y, x)) + }) +} + +/// Zero-argument methods that expose the receiver's own elements rather +/// than select a different table, so `v.as_slice()[i]` indexes `v`. +const VIEWS: &[&str] = &[ + "as_slice", + "as_mut_slice", + "slice", + "slice_mut", + "as_ref", + "as_mut", + "borrow", + "borrow_mut", + "deref", + "deref_mut", + "iter", + "iter_mut", + "unwrap", +]; + +/// Where an index lands, as an identity (`key`, roots told apart by +/// resolution), as it reads in the source (`shown`), and the names along +/// it (`root`, each field and accessor) that may say what kind indexes it. +struct Place { + key: String, + shown: String, + names: Vec, +} + +impl Place { + /// Some name along the place is the table of `kind`: `sources` or + /// `self.graph.input_files` for `source`, `lockfile.packages.names()` + /// for `pkg`. + fn named_after(&self, kind: &str) -> bool { + self.names.iter().any(|n| same_kind(n.as_str(), kind)) + } +} + +/// The place `e` denotes: a local, parameter or item at the root, then +/// fields, non-view zero-argument accessors and earlier indices. `&`, `*` +/// and HIR temporaries are transparent. Anything else at the root (a call +/// with arguments, a literal) is no place. +fn place_of(cx: &LateContext<'_>, mut e: &Expr<'_>) -> Option { + let mut path = Vec::new(); + let mut names = Vec::new(); + loop { + match e.kind { + ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(UnOp::Deref, inner) + | ExprKind::DropTemps(inner) => e = inner, + ExprKind::Field(inner, ident) => { + path.push(format!(".{}", ident.name)); + names.push(ident.name); + e = inner; + } + ExprKind::Index(inner, _, _) => { + path.push("[..]".to_string()); + e = inner; + } + ExprKind::MethodCall(seg, recv, [], _) => { + if !VIEWS.contains(&seg.ident.name.as_str()) { + path.push(format!(".{}()", seg.ident.name)); + names.push(seg.ident.name); + } + e = recv; + } + ExprKind::Path(ref qpath) => { + let root = match cx.qpath_res(qpath, e.hir_id) { + Res::Local(id) => format!("{id:?}"), + Res::Def(_, did) => format!("{did:?}"), + _ => return None, + }; + let name = match qpath { + QPath::Resolved(_, p) => p.segments.last()?.ident.name, + QPath::TypeRelative(_, seg) => seg.ident.name, + }; + names.push(name); + path.reverse(); + let tail = path.concat(); + return Some(Place { + key: format!("{root}{tail}"), + shown: format!("{name}{tail}"), + names, + }); + } + _ => return None, + } + } +} + +/// `base[idx]`, `base.get(idx)`, `base.get_mut(idx)`, +/// `base.get_unchecked(idx)`, `base.get_unchecked_mut(idx)`. +fn index_parts<'h>(e: &'h Expr<'h>) -> Option<(&'h Expr<'h>, &'h Expr<'h>)> { + match e.kind { + ExprKind::Index(base, idx, _) => Some((base, idx)), + ExprKind::MethodCall(seg, recv, [idx], _) + if matches!( + seg.ident.name.as_str(), + "get" | "get_mut" | "get_unchecked" | "get_unchecked_mut" + ) => + { + Some((recv, idx)) + } + _ => None, + } +} + +struct Site { + place: usize, + kind: Symbol, + name: Symbol, + span: Span, +} + +/// One body's index sites and the places they land on. +struct Sites { + places: Vec, + sites: Vec, +} + +impl Sites { + fn collect<'tcx>(cx: &LateContext<'tcx>, body: &Body<'tcx>) -> Self { + let mut places: Vec = Vec::new(); + let mut place_ids: HashMap = HashMap::new(); + let mut sites = Vec::new(); + for_each_expr_without_closures(body.value, |e: &'tcx Expr<'tcx>| { + if !e.span.from_expansion() + && let Some((base, idx)) = index_parts(e) + && let Some(name) = value_name(idx) + && let Some(kind) = claimed_kind(name.name.as_str()) + && let Some(place) = place_of(cx, base) + { + let next = places.len(); + let id = *place_ids.entry(place.key.clone()).or_insert(next); + if id == next { + places.push(place); + } + sites.push(Site { + place: id, + kind: Symbol::intern(kind), + name: name.name, + span: e.span, + }); + } + ControlFlow::<()>::Continue(()) + }); + Self { places, sites } + } + + /// For each place indexed by two kinds: every site of the less-used kind + /// (ties: the later-introduced one) that is not at home on the place and + /// does have a home table in this body the other kind never touches, + /// with the other kind's first site, its count, and that home table. + fn crossings(&self) -> Vec<(&Site, &Site, usize, usize)> { + let mut reach: HashMap> = HashMap::new(); + let mut by_place: HashMap> = HashMap::new(); + for site in &self.sites { + reach.entry(site.kind).or_default().insert(site.place); + by_place.entry(site.place).or_default().push(site); + } + let mut out = Vec::new(); + for (&place, place_sites) in &by_place { + let mut kinds: Vec<(Symbol, usize, &Site)> = Vec::new(); + for &site in place_sites { + match kinds.iter_mut().find(|(k, ..)| *k == site.kind) { + Some(entry) => entry.1 += 1, + None => kinds.push((site.kind, 1, site)), + } + } + kinds.sort_by_key(|&(_, n, first)| (std::cmp::Reverse(n), first.span.lo())); + for (i, &(kind, ..)) in kinds.iter().enumerate() { + if self.places[place].named_after(kind.as_str()) { + continue; + } + let home_of_kind_outside = |major: Symbol| { + reach[&kind] + .iter() + .copied() + .filter(|p| { + !reach[&major].contains(p) && self.places[*p].named_after(kind.as_str()) + }) + .min() + }; + let Some((&(_, major_n, major_first), home)) = kinds[..i] + .iter() + .filter(|(major, ..)| !same_kind(major.as_str(), kind.as_str())) + .find_map(|m| Some((m, home_of_kind_outside(m.0)?))) + else { + continue; + }; + for &site in place_sites { + if site.kind == kind { + out.push((site, major_first, major_n, home)); + } + } + } + } + out.sort_by_key(|(site, ..)| site.span.lo()); + out + } +} + +impl<'tcx> LateLintPass<'tcx> for CrossedIndex { + fn check_body(&mut self, cx: &LateContext<'tcx>, body: &Body<'tcx>) { + let sites = Sites::collect(cx, body); + for (site, major_first, major_n, home) in sites.crossings() { + emit_with_note( + cx, + CROSSED_INDEX, + site.span, + format!( + "`{place}` is indexed by `{name}` here but by `{major}` elsewhere in this \ + function ({major_n} site{s}), while `{name}` is what indexes `{home}`: \ + the names claim different index kinds and both are plain integers, so \ + the crossing compiles", + place = sites.places[site.place].shown, + name = site.name, + major = major_first.name, + s = if major_n == 1 { "" } else { "s" }, + home = sites.places[home].shown, + ), + major_first.span, + "indexed by the other kind here", + "an index newtype per table, with `Index` implemented only for its own, turns \ + the crossing into a type error", + ); + } + } +} diff --git a/src/hir_shapes.rs b/src/hir_shapes.rs index ced7333..ccb3d64 100644 --- a/src/hir_shapes.rs +++ b/src/hir_shapes.rs @@ -240,3 +240,28 @@ pub(crate) fn strip_generic_segments(path: &str) -> String { } out } + +/// The identifier an expression is called by: the last segment of a path, +/// the field of a field access, the method of a method call, the callee's +/// last segment of a call. Casts, `&`, unary operators and HIR temporaries +/// are transparent: they change representation, not what the name asserts. +pub(crate) fn value_name(mut e: &Expr<'_>) -> Option { + loop { + match &e.kind { + ExprKind::Cast(inner, _) + | ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(_, inner) + | ExprKind::DropTemps(inner) => e = inner, + ExprKind::Field(_, ident) => return Some(*ident), + ExprKind::Path(QPath::Resolved(_, path)) => return Some(path.segments.last()?.ident), + ExprKind::MethodCall(seg, ..) => return Some(seg.ident), + ExprKind::Call(callee, _) => { + let ExprKind::Path(QPath::Resolved(_, path)) = &callee.kind else { + return None; + }; + return Some(path.segments.last()?.ident); + } + _ => return None, + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e9f1779..4a4cf44 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ mod bypassed_conversion; mod bypassed_validator; mod claims; mod collapsed_error; +mod crossed_index; mod ctor_flow; mod defaulted_failure; mod dependent_field; @@ -162,10 +163,10 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto use { asymmetric_guard::AsymmetricGuard, baseline::BaselineWriter, bypassed_conversion::BypassedConversion, bypassed_validator::BypassedValidator, - collapsed_error::CollapsedError, defaulted_failure::DefaultedFailure, - dependent_field::DependentField, discarded_error::DiscardedError, - exclusive_options::ExclusiveOptions, flag_cluster::FlagCluster, - forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, + collapsed_error::CollapsedError, crossed_index::CrossedIndex, + defaulted_failure::DefaultedFailure, dependent_field::DependentField, + discarded_error::DiscardedError, exclusive_options::ExclusiveOptions, + flag_cluster::FlagCluster, forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, misbound_arg::MisboundArg, narrowed_return::NarrowedReturn, nonidentity_key::NonidentityKey, overwide_parameter::OverwideParameter, parallel_bools::ParallelBools, @@ -220,6 +221,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, DependentField::default); add(s, true, CollapsedError::default); add(s, true, UnevenNarrowing::default); + add(s, true, || CrossedIndex); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/src/unit_mismatch.rs b/src/unit_mismatch.rs index 38f55c7..7bcae1e 100644 --- a/src/unit_mismatch.rs +++ b/src/unit_mismatch.rs @@ -1,7 +1,8 @@ -use rustc_hir::{BinOpKind, Expr, ExprKind, QPath}; +use rustc_hir::{BinOpKind, Expr, ExprKind}; use rustc_lint::{LateContext, LateLintPass}; use crate::baseline::emit; +use crate::hir_shapes::value_name; rustc_session::declare_lint! { /// Flags addition, subtraction, and comparison between values whose names @@ -33,27 +34,13 @@ fn unit_class(suffix: &str) -> Option<&'static str> { }) } -/// The unit an expression's name claims, from the final identifier of a path, -/// field access, or method call. Casts and references are transparent: they -/// change representation, not the unit the name asserts. +/// The unit an expression's name claims: the suffix after the last `_` of +/// its `value_name`. fn claimed_unit(e: &Expr<'_>) -> Option<(&'static str, String)> { - let name = match &e.kind { - ExprKind::Cast(inner, _) | ExprKind::AddrOf(_, _, inner) | ExprKind::Unary(_, inner) => { - return claimed_unit(inner); - } - ExprKind::Field(_, ident) => ident.name.to_string(), - ExprKind::Path(QPath::Resolved(_, path)) => path.segments.last()?.ident.name.to_string(), - ExprKind::MethodCall(seg, ..) => seg.ident.name.to_string(), - ExprKind::Call(callee, _) => { - let ExprKind::Path(QPath::Resolved(_, path)) = &callee.kind else { - return None; - }; - path.segments.last()?.ident.name.to_string() - } - _ => return None, - }; + let name = value_name(e)?; + let name = name.name.as_str(); let (_, suffix) = name.rsplit_once('_')?; - unit_class(suffix).map(|c| (c, name)) + unit_class(suffix).map(|c| (c, name.to_string())) } impl<'tcx> LateLintPass<'tcx> for UnitMismatch { diff --git a/ui/crossed_index.rs b/ui/crossed_index.rs new file mode 100644 index 0000000..7941c0f --- /dev/null +++ b/ui/crossed_index.rs @@ -0,0 +1,116 @@ +// A place indexed by names of one kind must not also be indexed by a name of another kind. + +struct Lockfile { + dependencies: Vec<&'static str>, + // One slot per dependency, holding the package it resolved to. + resolutions: Vec, + packages: Vec<&'static str>, +} + +fn crossed_table_is_flagged(l: &Lockfile, dep_id: u32, pkg_id: u32) -> bool { + let dep = l.dependencies[dep_id as usize]; + let resolved = l.resolutions[dep_id as usize]; + let name = l.packages[pkg_id as usize]; + // Flagged: `resolutions` is the per-dependency table and `pkg_id` is what indexes `packages`. + let stale = l.resolutions[pkg_id as usize]; + dep == name && resolved == stale +} + +fn crossed_get_is_flagged( + sources: &[&str], + parts: &[u32], + source_index: usize, + part_index: usize, +) -> bool { + let path = sources[source_index]; + let part = parts.get(part_index); + let sibling = parts[part_index.saturating_sub(1)]; + // Flagged: `parts` is indexed by `part_index`; `source_index` is what indexes `sources`. + let crossed = parts.get(source_index); + path.is_empty() && part == crossed && sibling == 0 +} + +struct Graph { + files: Vec<&'static str>, + parts: Vec>, + flags: Vec, +} + +impl Graph { + fn crossed_field_is_flagged(&mut self, source_index: u32, part_index: u32) { + let _ = self.files[source_index as usize]; + let _ = self.parts[source_index as usize][part_index as usize]; + // Flagged: `self.files` is the per-source table; `part_index` indexes `self.parts[..]`. + let _ = self.files.get_mut(part_index as usize); + } + + // Fine: a two-level table is a different place at each level. + fn nested_table_is_fine(&self, source_index: u32, part_index: u32) -> u32 { + let _ = self.files[source_index as usize]; + self.parts[source_index as usize][part_index as usize] + + self.parts[source_index as usize].as_slice()[part_index as usize] + + u32::from(self.flags[part_index as usize]) + } + + // Fine: parallel columns share one index kind. + fn parallel_columns_are_fine(&self, source_index: u32) -> bool { + self.files[source_index as usize].is_empty() + && self.parts[source_index as usize].is_empty() + && self.flags[source_index as usize] + } +} + +// Fine: a role name for the same kind has no table of its own name. +fn role_name_is_fine(nodes: &[u32], depths: &[u32], node_idx: usize, parent_idx: usize) -> u32 { + nodes[node_idx] + depths[node_idx] + nodes[parent_idx] + depths[parent_idx] +} + +// Fine: the two prefixes abbreviate one word. +fn abbreviation_is_fine( + l: &Lockfile, + dep_id: u32, + dependency_id: u32, + pkg_id: u32, + package_id: u32, +) { + let _ = l.dependencies[dep_id as usize]; + let _ = l.resolutions[dep_id as usize]; + let _ = l.resolutions[dependency_id as usize]; + let _ = l.packages[pkg_id as usize]; + let _ = l.packages[package_id as usize]; +} + +// Fine: two locals of one name are two places. +fn shadowed_local_is_fine(l: &Lockfile, per_package: &[u32], dep_id: u32, pkg_id: u32) -> u32 { + let resolutions = &l.resolutions; + let a = resolutions[dep_id as usize] + l.dependencies[dep_id as usize].len() as u32; + let resolutions = per_package; + a + resolutions[pkg_id as usize] + l.packages[pkg_id as usize].len() as u32 +} + +// Fine: names without a kind suffix claim nothing. +fn unsuffixed_names_are_fine(v: &[u8], w: &[u8], i: usize, at: usize, n: usize) -> u8 { + v[i] + v[at] + w[at] + v[n] + w[0] +} + +fn main() { + let l = Lockfile { + dependencies: vec!["a"], + resolutions: vec![0], + packages: vec!["a"], + }; + let _ = crossed_table_is_flagged(&l, 0, 0); + let _ = crossed_get_is_flagged(&["a"], &[0], 0, 0); + let mut g = Graph { + files: vec!["a"], + parts: vec![vec![0]], + flags: vec![false], + }; + g.crossed_field_is_flagged(0, 0); + let _ = g.nested_table_is_fine(0, 0); + let _ = g.parallel_columns_are_fine(0); + let _ = role_name_is_fine(&[0], &[0], 0, 0); + abbreviation_is_fine(&l, 0, 0, 0, 0); + let _ = shadowed_local_is_fine(&l, &[0], 0, 0); + let _ = unsuffixed_names_are_fine(&[0], &[0], 0, 0, 0); +} diff --git a/ui/crossed_index.stderr b/ui/crossed_index.stderr new file mode 100644 index 0000000..b30c7da --- /dev/null +++ b/ui/crossed_index.stderr @@ -0,0 +1,42 @@ +warning: `l.resolutions` is indexed by `pkg_id` here but by `dep_id` elsewhere in this function (1 site), while `pkg_id` is what indexes `l.packages`: the names claim different index kinds and both are plain integers, so the crossing compiles + --> $DIR/crossed_index.rs:15:17 + | +LL | let stale = l.resolutions[pkg_id as usize]; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: indexed by the other kind here + --> $DIR/crossed_index.rs:12:20 + | +LL | let resolved = l.resolutions[dep_id as usize]; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: an index newtype per table, with `Index` implemented only for its own, turns the crossing into a type error + = note: `#[warn(crossed_index)]` on by default + +warning: `parts` is indexed by `source_index` here but by `part_index` elsewhere in this function (1 site), while `source_index` is what indexes `sources`: the names claim different index kinds and both are plain integers, so the crossing compiles + --> $DIR/crossed_index.rs:29:19 + | +LL | let crossed = parts.get(source_index); + | ^^^^^^^^^^^^^^^^^^^^^^^ + | +note: indexed by the other kind here + --> $DIR/crossed_index.rs:26:16 + | +LL | let part = parts.get(part_index); + | ^^^^^^^^^^^^^^^^^^^^^ + = help: an index newtype per table, with `Index` implemented only for its own, turns the crossing into a type error + +warning: `self.files` is indexed by `part_index` here but by `source_index` elsewhere in this function (1 site), while `part_index` is what indexes `self.parts[..]`: the names claim different index kinds and both are plain integers, so the crossing compiles + --> $DIR/crossed_index.rs:44:17 + | +LL | let _ = self.files.get_mut(part_index as usize); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: indexed by the other kind here + --> $DIR/crossed_index.rs:41:17 + | +LL | let _ = self.files[source_index as usize]; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: an index newtype per table, with `Index` implemented only for its own, turns the crossing into a type error + +warning: 3 warnings emitted + From 2292bdff356c1cd96f9403c7c276d1cc301476bb Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:14:59 -0700 Subject: [PATCH 08/34] dependent_field stays quiet on fields assigned outside the case --- src/dependent_field.rs | 60 +++++++++++++++++++++++++++------ ui/dependent_field.rs | 71 +++++++++++++++++++++++++++++++++++++++ ui/dependent_field.stderr | 10 +++++- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/src/dependent_field.rs b/src/dependent_field.rs index 60e5c4b..3e5ba5d 100644 --- a/src/dependent_field.rs +++ b/src/dependent_field.rs @@ -4,12 +4,12 @@ use crate::adt_facts::{has_fixed_repr, has_positional_fields, private_local_stru use crate::baseline::emit; use crate::enum_facts::{arm_variant, ctor_literal_variant}; use clippy_utils::eq_expr_value; -use clippy_utils::{in_automatically_derived, is_default_equivalent}; +use clippy_utils::{get_enclosing_block, in_automatically_derived, is_default_equivalent}; use rustc_ast::LitKind; use rustc_hir::def_id::DefId; use rustc_hir::{ - Arm, BinOpKind, Block, BorrowKind, Expr, ExprKind, Mutability, Node, Pat, PatExpr, PatExprKind, - PatKind, StmtKind, StructTailExpr, UnOp, + Arm, BinOpKind, Block, BorrowKind, Expr, ExprKind, HirId, Mutability, Node, Pat, PatExpr, + PatExprKind, PatKind, StmtKind, StructTailExpr, UnOp, }; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty; @@ -31,11 +31,13 @@ rustc_session::declare_lint! { /// explicit `repr`, and only on proof: one read the lint cannot place /// under such a test — an accessor, a destructuring pattern, a `Debug` /// written by hand, a test on a copy of the sibling rather than the - /// sibling itself — keeps it quiet, as does a construction that gives - /// the field a real value beside another value of the sibling, and so - /// does a sibling that is never given the tested value by any literal or - /// assignment (the field is then dead, not dependent). Reads in derived - /// impls are not counted. + /// sibling itself — keeps it quiet. So does any write of a real value + /// outside that case: a construction that gives the field one beside + /// another value of the sibling, or an assignment to it neither under + /// the test nor in a block that also assigns the sibling that value. A + /// sibling never given the tested value by any literal or assignment + /// keeps it quiet too (the field is then dead, not dependent). Reads in + /// derived impls are not counted. pub DEPENDENT_FIELD, Warn, "a field that only means something when a sibling field has one value" @@ -83,6 +85,10 @@ pub struct DependentField { /// Fields assigned after construction, whose values the sites do not /// bound. assigned: HashSet<(DefId, Symbol)>, + /// (struct, field) -> per assignment of a real value, the sibling tests + /// dominating it plus the sibling values assigned beside it in the same + /// block: the cases that write can belong to. + writes: HashMap<(DefId, Symbol), Vec>>, } rustc_session::impl_lint_pass!(DependentField => [DEPENDENT_FIELD]); @@ -301,7 +307,7 @@ fn preceding_guards( cx: &LateContext<'_>, base: &Expr<'_>, block: &Block<'_>, - child: rustc_hir::HirId, + child: HirId, out: &mut HashSet, ) { let pos = block @@ -390,6 +396,23 @@ fn dominating_tests(cx: &LateContext<'_>, read: &Expr<'_>, base: &Expr<'_>) -> H out } +/// The `.g = ` assignments among the statements of the block +/// enclosing `at`: writes that put the struct into a case at the same step. +fn assigned_beside(cx: &LateContext<'_>, base: &Expr<'_>, at: HirId, out: &mut HashSet) { + let Some(block) = get_enclosing_block(cx, at) else { + return; + }; + for stmt in block.stmts { + if let StmtKind::Semi(e) | StmtKind::Expr(e) = stmt.kind + && let ExprKind::Assign(lhs, rhs, _) = e.kind + && let Some(sibling) = sibling_of(cx, base, lhs) + && let Some(value) = expr_value(cx, rhs) + { + out.insert(Test { sibling, value }); + } + } +} + impl DependentField { fn record_read(&mut self, adt: DefId, field: Symbol, tests: HashSet) { self.reads.entry((adt, field)).or_default().push(tests); @@ -430,10 +453,18 @@ impl<'tcx> LateLintPass<'tcx> for DependentField { // `base.f = ..` writes; everything else, `base.f += ..` and // `&mut base.f` included, reads. if let Node::Expr(parent) = cx.tcx.parent_hir_node(expr.hir_id) - && let ExprKind::Assign(lhs, ..) = parent.kind + && let ExprKind::Assign(lhs, rhs, _) = parent.kind && lhs.hir_id == expr.hir_id { self.assigned.insert((adt.did(), ident.name)); + if !is_placeholder(cx, rhs) { + let mut cases = dominating_tests(cx, expr, base); + assigned_beside(cx, base, parent.hir_id, &mut cases); + self.writes + .entry((adt.did(), ident.name)) + .or_default() + .push(cases); + } return; } let tests = dominating_tests(cx, expr, base); @@ -497,6 +528,15 @@ impl<'tcx> LateLintPass<'tcx> for DependentField { if !reached { continue; } + // Every real value assigned later goes in under the test or + // together with the sibling being set to that case. + let writes = self + .writes + .get(&(*did, *field)) + .map_or(&[][..], Vec::as_slice); + if !writes.iter().all(|cases| cases.contains(&test)) { + continue; + } // Sites that give the sibling some other spelled-out value. let elsewhere = sites.iter().filter(|s| { s.get(&test.sibling) diff --git a/ui/dependent_field.rs b/ui/dependent_field.rs index c583028..5cfe598 100644 --- a/ui/dependent_field.rs +++ b/ui/dependent_field.rs @@ -86,6 +86,71 @@ fn apply(v: &Verify) -> u8 { out + u8::from(v.reject) } +// Flagged: `result` is assigned only in the block that moves `state` to +// `Done`, and read only under `state == Done`. +#[derive(Clone, Copy, PartialEq)] +enum State { + Idle, + Running, + Done, +} + +struct Job { + state: State, + result: u64, +} + +fn job() -> Job { + Job { + state: State::Idle, + result: 0, + } +} + +fn run(j: &mut Job, r: u64) { + j.state = State::Running; + if r > 2 { + j.state = State::Done; + j.result = r; + } +} + +fn finished(j: &Job) -> Option { + match j.state { + State::Done => Some(j.result), + State::Idle | State::Running => None, + } +} + +// Fine: `names` is filled in by an assignment that has nothing to do with +// `dirty`, so it means something in both cases even though the one reader +// happens to check `dirty` first. +struct Folder { + dirty: bool, + names: Option>, +} + +fn folder() -> Folder { + Folder { + dirty: false, + names: None, + } +} + +fn scan(f: &mut Folder, n: Vec, changed: bool) { + f.names = Some(n); + if changed { + f.dirty = true; + } +} + +fn sweep(f: &Folder) -> usize { + if !f.dirty { + return 0; + } + f.names.as_ref().map_or(0, Vec::len) +} + // Fine: `len` has an accessor that reads it whatever `kind` is. #[derive(Clone, Copy, PartialEq)] enum Kind { @@ -275,6 +340,12 @@ fn main() { let s = subproc(core::ptr::null_mut()); let _ = (dispatch(&c), dispatch(&s), captured(&c), format!("{c:?}")); let _ = (apply(&no_request()), apply(&with_request(true))); + let mut j = job(); + run(&mut j, 3); + let _ = finished(&j); + let mut f = folder(); + scan(&mut f, vec![1], true); + let _ = sweep(&f); let [a, b] = bufs(9); let _ = (a.len(), b.heap_len()); let [x, y, z] = addrs(3); diff --git a/ui/dependent_field.stderr b/ui/dependent_field.stderr index 23b5acb..2979a33 100644 --- a/ui/dependent_field.stderr +++ b/ui/dependent_field.stderr @@ -15,5 +15,13 @@ LL | reject: bool, | = help: the field is the payload of that one case, stored flat; an enum variant carrying it leaves the other cases nothing to fill in or misread -warning: 2 warnings emitted +warning: `result` is only read where `state == State::Done` has been tested (1 read), and every `Job` made with another `state` fills it with a placeholder (1 site) + --> $DIR/dependent_field.rs:100:5 + | +LL | result: u64, + | ^^^^^^^^^^^ + | + = help: the field is the payload of that one case, stored flat; an enum variant carrying it leaves the other cases nothing to fill in or misread + +warning: 3 warnings emitted From 7a36e3f3a5b6df92c889db56c45528d7c6401c82 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:18:41 -0700 Subject: [PATCH 09/34] add bool_beside_option --- README.md | 1 + src/bool_beside_option.rs | 269 +++++++++++++++++++++++++++++++++++ src/lib.rs | 2 + ui/bool_beside_option.rs | 233 ++++++++++++++++++++++++++++++ ui/bool_beside_option.stderr | 19 +++ 5 files changed, 524 insertions(+) create mode 100644 src/bool_beside_option.rs create mode 100644 ui/bool_beside_option.rs create mode 100644 ui/bool_beside_option.stderr diff --git a/README.md b/README.md index 37425d8..c383051 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `collapsed_error` | `f(x);` or `let _ = f(x)` on a crate fn whose `false`/`None` is the bare `Err` arm of a `Result` it held: the typed error became one bit, and this call drops the bit | | `uneven_narrowing` | an integer field or local converted with `try_from` at one site and a bare `as` at another: the check says the value may not fit, and `as` wraps silently when it doesn't | | `crossed_index` | `parts[source_index]` in a function that indexes `parts` by `part_index` and `sources` by `source_index`: two index kinds cross, and both are plain integers | +| `bool_beside_option` | a bool field written only beside an `Option` field, `true` with `Some(..)` and `false` with `None`: it is that field's `is_some()` stored twice, kept equal only by habit | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/bool_beside_option.rs b/src/bool_beside_option.rs new file mode 100644 index 0000000..525eb89 --- /dev/null +++ b/src/bool_beside_option.rs @@ -0,0 +1,269 @@ +use std::collections::{HashMap, HashSet}; + +use crate::adt_facts::{field_ty, has_fixed_repr, is_option_ty, struct_field}; +use crate::baseline::emit; +use crate::hir_shapes::{assigned_field, peel_blocks_unsafe}; +use clippy_utils::{as_some_expr, get_enclosing_block, get_parent_expr, is_none_expr}; +use rustc_ast::LitKind; +use rustc_hir::def_id::DefId; +use rustc_hir::{BorrowKind, Expr, ExprKind, HirId, Mutability}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::adjustment::{Adjust, AutoBorrow, AutoBorrowMutability}; +use rustc_middle::ty::{self, AdtDef}; +use rustc_span::{Span, Symbol}; + +rustc_session::declare_lint! { + /// Flags a bool field that stores whether a sibling `Option` field is + /// `Some`: every write to either of them, struct literal or field + /// assignment, sits beside a write to the other in the same struct + /// expression or block, `true` always with `Some(..)` and `false` always + /// with `None` (or always the reverse). The flag is the `Option`'s + /// discriminant kept a second time, and nothing but that habit keeps the + /// two agreeing. + /// + /// Only fires on fields nothing outside the crate can name, when every + /// write to the pair is a literal `true`/`false` and `Some(..)`/`None` and + /// both polarities occur. A lone write to either field, a computed value, + /// a compound assignment, or a `&mut` borrow of either (`.take()`, + /// `mem::replace`, `&mut s.opt`) is unprovable and silences it; `as_mut` + /// and `as_deref_mut` cannot change the discriminant and do not. + pub BOOL_BESIDE_OPTION, + Warn, + "bool field that repeats whether a sibling Option field is Some" +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Kind { + Bool, + Opt, +} + +struct FieldWrites { + kind: Kind, + /// Some write to the field is not a literal of its kind, or the field is + /// mutably borrowed: its value is no longer a function of the sites. + unprovable: bool, + /// (site, set): the struct expression or enclosing block that writes the + /// field, and whether it writes `true` / `Some(..)` there. + sites: HashSet<(HirId, bool)>, +} + +#[derive(Default)] +pub struct BoolBesideOption { + writes: HashMap<(DefId, Symbol), FieldWrites>, +} + +rustc_session::impl_lint_pass!(BoolBesideOption => [BOOL_BESIDE_OPTION]); + +/// A bool or `Option` field of a local struct that nothing outside the crate +/// can write: the struct with the field's kind, or None for anything else. +fn tracked_field<'tcx>( + cx: &LateContext<'tcx>, + ty: ty::Ty<'tcx>, + name: Symbol, +) -> Option<(AdtDef<'tcx>, Kind)> { + let ty::Adt(adt, _) = ty.peel_refs().kind() else { + return None; + }; + if !adt.is_struct() || !adt.did().is_local() || has_fixed_repr(*adt) { + return None; + } + let f = struct_field(*adt, name)?; + if cx.effective_visibilities.is_exported(f.did.expect_local()) { + return None; + } + let fty = field_ty(cx, f); + let kind = if fty.is_bool() { + Kind::Bool + } else if is_option_ty(cx, fty) { + Kind::Opt + } else { + return None; + }; + Some((*adt, kind)) +} + +/// Whether `value` writes `true`/`Some(..)` (Some(true)) or `false`/`None` +/// (Some(false)); None for anything computed. +fn polarity(cx: &LateContext<'_>, kind: Kind, value: &Expr<'_>) -> Option { + let value = peel_blocks_unsafe(value); + match kind { + Kind::Bool => match value.kind { + ExprKind::Lit(lit) => match lit.node { + LitKind::Bool(b) => Some(b), + _ => None, + }, + _ => None, + }, + Kind::Opt => { + if as_some_expr(cx, value).is_some() { + Some(true) + } else { + is_none_expr(cx, value).then_some(false) + } + } + } +} + +impl BoolBesideOption { + fn facts(&mut self, adt: AdtDef<'_>, name: Symbol, kind: Kind) -> &mut FieldWrites { + self.writes + .entry((adt.did(), name)) + .or_insert_with(|| FieldWrites { + kind, + unprovable: false, + sites: HashSet::new(), + }) + } + + fn record<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + base_ty: ty::Ty<'tcx>, + name: Symbol, + site: HirId, + value: &Expr<'_>, + ) { + let Some((adt, kind)) = tracked_field(cx, base_ty, name) else { + return; + }; + let facts = self.facts(adt, name, kind); + match polarity(cx, kind, value) { + Some(set) => { + facts.sites.insert((site, set)); + } + None => facts.unprovable = true, + } + } + + fn poison<'tcx>(&mut self, cx: &LateContext<'tcx>, base_ty: ty::Ty<'tcx>, name: Symbol) { + if let Some((adt, kind)) = tracked_field(cx, base_ty, name) { + self.facts(adt, name, kind).unprovable = true; + } + } +} + +/// Methods that borrow an `Option` or bool field mutably without being able +/// to replace it whole. +const PROJECTING_MUT_METHODS: &[&str] = &["as_mut", "as_deref_mut", "as_pin_mut", "iter_mut"]; + +impl<'tcx> LateLintPass<'tcx> for BoolBesideOption { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + match expr.kind { + ExprKind::Struct(_, fields, _) => { + // A derived `Clone`/`Default` writes every field from the same + // field or the same default: it preserves whatever the + // hand-written sites establish and proves nothing itself. + if expr.span.in_derive_expansion() { + return; + } + let ty = cx.typeck_results().expr_ty(expr); + for field in fields { + self.record(cx, ty, field.ident.name, expr.hir_id, field.expr); + } + } + ExprKind::Assign(place, value, _) => { + let Some((base, ident, _)) = assigned_field(place) else { + return; + }; + let base_ty = cx.typeck_results().expr_ty_adjusted(base); + match get_enclosing_block(cx, expr.hir_id) { + Some(block) => self.record(cx, base_ty, ident.name, block.hir_id, value), + None => self.poison(cx, base_ty, ident.name), + } + } + ExprKind::AssignOp(_, place, _) => { + if let Some((base, ident, _)) = assigned_field(place) { + self.poison(cx, cx.typeck_results().expr_ty_adjusted(base), ident.name); + } + } + ExprKind::AddrOf(BorrowKind::Ref | BorrowKind::Raw, Mutability::Mut, inner) => { + if let ExprKind::Field(base, ident) = peel_blocks_unsafe(inner).kind { + self.poison(cx, cx.typeck_results().expr_ty_adjusted(base), ident.name); + } + } + // The auto-`&mut` a mutating method call takes on its receiver. + ExprKind::Field(base, ident) => { + let mutably_borrowed = cx.typeck_results().expr_adjustments(expr).iter().any(|a| { + matches!( + a.kind, + Adjust::Borrow(AutoBorrow::Ref(AutoBorrowMutability::Mut { .. })) + | Adjust::Borrow(AutoBorrow::RawPtr(Mutability::Mut)) + ) + }); + if !mutably_borrowed { + return; + } + let projecting = matches!( + get_parent_expr(cx, expr).map(|p| &p.kind), + Some(ExprKind::MethodCall(seg, ..)) + if PROJECTING_MUT_METHODS.contains(&seg.ident.name.as_str()) + ); + if !projecting { + self.poison(cx, cx.typeck_results().expr_ty_adjusted(base), ident.name); + } + } + _ => {} + } + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let mut per_struct: HashMap> = HashMap::new(); + for ((did, name), facts) in self.writes.drain() { + if !facts.unprovable && !facts.sites.is_empty() { + per_struct.entry(did).or_default().push((name, facts)); + } + } + let mut findings: Vec<(Span, String)> = Vec::new(); + for (did, mut fields) in per_struct { + fields.sort_by_key(|(name, _)| name.as_str().to_owned()); + let adt = cx.tcx.adt_def(did); + for (flag, flag_facts) in fields.iter().filter(|(_, f)| f.kind == Kind::Bool) { + // A flag only ever `false` (or only ever `true`) is a constant, + // not a copy of anything. + if !flag_facts.sites.iter().any(|&(_, set)| set) + || !flag_facts.sites.iter().any(|&(_, set)| !set) + { + continue; + } + for (opt, opt_facts) in fields.iter().filter(|(_, f)| f.kind == Kind::Opt) { + let inverted: HashSet<(HirId, bool)> = + opt_facts.sites.iter().map(|&(s, set)| (s, !set)).collect(); + let reading = if flag_facts.sites == opt_facts.sites { + "is_some" + } else if flag_facts.sites == inverted { + "is_none" + } else { + continue; + }; + let (with_true, with_false) = match reading { + "is_some" => ("Some(..)", "None"), + _ => ("None", "Some(..)"), + }; + let Some(field) = struct_field(adt, *flag) else { + continue; + }; + findings.push(( + cx.tcx.def_span(field.did), + format!( + "`{flag}` is only ever written beside `{opt}` of `{}`, `true` with `{with_true}` and `false` with `{with_false}` ({} sites): it stores `{opt}.{reading}()` a second time", + cx.tcx.def_path_str(did), + flag_facts.sites.len(), + ), + )); + break; + } + } + } + findings.sort_by_key(|(span, _)| span.lo()); + for (span, msg) in findings { + emit( + cx, + BOOL_BESIDE_OPTION, + span, + msg, + "the `Option` already carries this state; drop the flag and ask the `Option`, so the two cannot disagree", + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 4a4cf44..f51db85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -21,6 +21,7 @@ use rustc_data_structures::sync; mod adt_facts; mod asymmetric_guard; mod baseline; +mod bool_beside_option; mod bypassed_conversion; mod bypassed_validator; mod claims; @@ -222,6 +223,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, CollapsedError::default); add(s, true, UnevenNarrowing::default); add(s, true, || CrossedIndex); + add(s, true, bool_beside_option::BoolBesideOption::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/ui/bool_beside_option.rs b/ui/bool_beside_option.rs new file mode 100644 index 0000000..d0208c2 --- /dev/null +++ b/ui/bool_beside_option.rs @@ -0,0 +1,233 @@ +// A bool field written only beside an Option field, `true` with `Some`, is that Option's `is_some()`. + +// Flagged: `connected` is `peer.is_some()`: false/None at construction, +// true/Some in `open`, false/None in `close`, and nowhere else. +struct Conn { + peer: Option, + connected: bool, + bytes: u64, +} + +impl Conn { + fn new() -> Self { + Conn { + peer: None, + connected: false, + bytes: 0, + } + } + + fn open(&mut self, peer: String) { + self.peer = Some(peer); + self.connected = true; + } + + fn close(&mut self) { + self.connected = false; + self.peer = None; + self.bytes = 0; + } + + fn peer(&self) -> Option<&str> { + self.peer.as_deref() + } + + fn is_up(&self) -> bool { + self.connected + } +} + +// Flagged: the reverse polarity, `stale` is `fresh.is_none()`; reading the +// Option through `as_mut` cannot change that. +struct Cache { + fresh: Option, + stale: bool, +} + +impl Cache { + fn filled(v: u32) -> Self { + Cache { + fresh: Some(v), + stale: false, + } + } + + fn invalidate(&mut self) { + self.fresh = None; + self.stale = true; + } + + fn bump(&mut self) { + if let Some(v) = self.fresh.as_mut() { + *v += 1; + } + } +} + +// Fine: `done` is also set on its own, so it is not `error.is_some()`. +struct Scan { + error: Option, + done: bool, +} + +impl Scan { + fn new() -> Self { + Scan { + error: None, + done: false, + } + } + + fn fail(&mut self, e: String) { + self.error = Some(e); + self.done = true; + } + + fn finish(&mut self) { + self.done = true; + } +} + +// Fine: the Option is drained with `take()`, a write the flag does not follow. +struct Exit { + status: Option, + exited: bool, +} + +impl Exit { + fn new() -> Self { + Exit { + status: None, + exited: false, + } + } + + fn on_exit(&mut self, code: i32) { + self.exited = true; + self.status = Some(code); + } + + fn reap(&mut self) -> Option { + self.status.take() + } +} + +// Fine: the flag is written from a computed value once. +struct Probe { + addr: Option, + reachable: bool, +} + +impl Probe { + fn new() -> Self { + Probe { + addr: None, + reachable: false, + } + } + + fn resolve(&mut self, addr: u32, ok: bool) { + self.addr = Some(addr); + self.reachable = ok; + } +} + +// Fine: the flag is only ever `false`; a constant is not a copy of anything. +struct Idle { + job: Option, + busy: bool, +} + +impl Idle { + fn new() -> Self { + Idle { + job: None, + busy: false, + } + } + + fn park(&mut self) { + self.job = None; + self.busy = false; + } +} + +// Fine: the two writes are in different blocks, so one can run without the other. +struct Lazy { + value: Option, + loaded: bool, +} + +impl Lazy { + fn new() -> Self { + Lazy { + value: None, + loaded: false, + } + } + + fn load(&mut self, v: Option) { + self.loaded = true; + if let Some(v) = v { + self.value = Some(v); + } + } +} + +// Fine: exported fields can be written by other crates. +pub struct Public { + pub handle: Option, + pub attached: bool, +} + +impl Public { + pub fn new() -> Self { + Public { + handle: None, + attached: false, + } + } + + pub fn attach(&mut self, h: u32) { + self.handle = Some(h); + self.attached = true; + } +} + +fn main() { + let mut c = Conn::new(); + c.open("p".to_owned()); + let _ = (c.peer(), c.is_up()); + c.close(); + c.bytes += 1; + + let mut k = Cache::filled(1); + k.bump(); + k.invalidate(); + let _ = k.stale; + + let mut s = Scan::new(); + s.fail("e".to_owned()); + s.finish(); + let _ = (s.done, s.error.is_some()); + + let mut e = Exit::new(); + e.on_exit(0); + let _ = (e.exited, e.reap()); + + let mut p = Probe::new(); + p.resolve(1, true); + let _ = (p.reachable, p.addr); + + let mut i = Idle::new(); + i.park(); + let _ = (i.busy, i.job); + + let mut l = Lazy::new(); + l.load(Some(1)); + let _ = (l.loaded, l.value); + + let mut u = Public::new(); + u.attach(1); + let _ = (u.attached, u.handle); +} diff --git a/ui/bool_beside_option.stderr b/ui/bool_beside_option.stderr new file mode 100644 index 0000000..3b7e577 --- /dev/null +++ b/ui/bool_beside_option.stderr @@ -0,0 +1,19 @@ +warning: `connected` is only ever written beside `peer` of `Conn`, `true` with `Some(..)` and `false` with `None` (3 sites): it stores `peer.is_some()` a second time + --> $DIR/bool_beside_option.rs:7:5 + | +LL | connected: bool, + | ^^^^^^^^^^^^^^^ + | + = help: the `Option` already carries this state; drop the flag and ask the `Option`, so the two cannot disagree + = note: `#[warn(bool_beside_option)]` on by default + +warning: `stale` is only ever written beside `fresh` of `Cache`, `true` with `None` and `false` with `Some(..)` (2 sites): it stores `fresh.is_none()` a second time + --> $DIR/bool_beside_option.rs:44:5 + | +LL | stale: bool, + | ^^^^^^^^^^^ + | + = help: the `Option` already carries this state; drop the flag and ask the `Option`, so the two cannot disagree + +warning: 2 warnings emitted + From 3b1a9b88e67e2646687a70fc0a0700e373312970 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:19:26 -0700 Subject: [PATCH 10/34] add parallel_vecs --- README.md | 1 + src/hir_shapes.rs | 63 +++++- src/lib.rs | 15 +- src/parallel_vecs.rs | 443 ++++++++++++++++++++++++++++++++++++++++ ui/parallel_vecs.rs | 190 +++++++++++++++++ ui/parallel_vecs.stderr | 27 +++ 6 files changed, 728 insertions(+), 11 deletions(-) create mode 100644 src/parallel_vecs.rs create mode 100644 ui/parallel_vecs.rs create mode 100644 ui/parallel_vecs.stderr diff --git a/README.md b/README.md index c383051..f97741d 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `collapsed_error` | `f(x);` or `let _ = f(x)` on a crate fn whose `false`/`None` is the bare `Err` arm of a `Result` it held: the typed error became one bit, and this call drops the bit | | `uneven_narrowing` | an integer field or local converted with `try_from` at one site and a bare `as` at another: the check says the value may not fit, and `as` wraps silently when it doesn't | | `crossed_index` | `parts[source_index]` in a function that indexes `parts` by `part_index` and `sources` by `source_index`: two index kinds cross, and both are plain integers | +| `parallel_vecs` | sequence fields of one struct that only change length side by side and are read at one index: element `i` of each is one record, so the type lets the lengths differ | | `bool_beside_option` | a bool field written only beside an `Option` field, `true` with `Some(..)` and `false` with `None`: it is that field's `is_some()` stored twice, kept equal only by habit | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/hir_shapes.rs b/src/hir_shapes.rs index ccb3d64..35ba397 100644 --- a/src/hir_shapes.rs +++ b/src/hir_shapes.rs @@ -17,10 +17,15 @@ pub(crate) fn is_self_path(e: &Expr<'_>) -> bool { if p.segments.len() == 1 && p.segments[0].ident.name == kw::SelfLower) } -/// `self.field`, as the `self` expression it is read off and the field name. -pub(crate) fn self_field<'h>(e: &Expr<'h>) -> Option<(&'h Expr<'h>, Ident)> { +/// `self.field`: the `self` expression it is read off and the field name. +pub(crate) struct SelfField<'h> { + pub base: &'h Expr<'h>, + pub ident: Ident, +} + +pub(crate) fn self_field<'h>(e: &Expr<'h>) -> Option> { match e.kind { - ExprKind::Field(base, ident) if is_self_path(base) => Some((base, ident)), + ExprKind::Field(base, ident) if is_self_path(base) => Some(SelfField { base, ident }), _ => None, } } @@ -42,7 +47,12 @@ pub(crate) fn assigned_field<'h>( /// `root.a.b` as `root` and `[a, b]`, read through `&`, `*` and HIR /// temporaries at any level, which all name the same place. -pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> (&'h Expr<'h>, Vec) { +pub(crate) struct FieldChain<'h> { + pub root: &'h Expr<'h>, + pub fields: Vec, +} + +pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> FieldChain<'h> { let mut fields = Vec::new(); loop { match e.kind { @@ -55,7 +65,7 @@ pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> (&'h Expr<'h>, Vec | ExprKind::DropTemps(inner) => e = inner, _ => { fields.reverse(); - return (e, fields); + return FieldChain { root: e, fields }; } } } @@ -265,3 +275,46 @@ pub(crate) fn value_name(mut e: &Expr<'_>) -> Option { } } } + +/// `base.field.method(args)`: a method call whose receiver is a field, split +/// at that field; `field_chain(base)` names the rest of the place. +pub(crate) struct FieldMethodCall<'h> { + pub base: &'h Expr<'h>, + pub field: Ident, + pub method: Ident, + /// Receiver excluded. + pub args: &'h [Expr<'h>], +} + +/// A method call on a field (`self.items.push(x)`, `(*this).a.b.len()`), +/// through explicit derefs of the receiver; None when the receiver is not a +/// field. +pub(crate) fn field_method_call<'h>(e: &'h Expr<'h>) -> Option> { + let ExprKind::MethodCall(seg, recv, args, _) = e.kind else { + return None; + }; + let (base, field, _) = assigned_field(recv)?; + Some(FieldMethodCall { + base, + field, + method: seg.ident, + args, + }) +} + +/// `base.field[index]`: an index expression whose base is a field. +pub(crate) struct IndexedField<'h> { + pub base: &'h Expr<'h>, + pub field: Ident, + pub index: &'h Expr<'h>, +} + +/// An index expression on a field, through explicit derefs of the indexed +/// place; None when what is indexed is not a field. +pub(crate) fn indexed_field<'h>(e: &'h Expr<'h>) -> Option> { + let ExprKind::Index(place, index, _) = e.kind else { + return None; + }; + let (base, field, _) = assigned_field(place)?; + Some(IndexedField { base, field, index }) +} diff --git a/src/lib.rs b/src/lib.rs index f51db85..7a55487 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ mod narrowed_return; mod nonidentity_key; mod overwide_parameter; mod parallel_bools; +mod parallel_vecs; mod reimplemented_helper; mod same_match_twice; mod stale_across_reentry; @@ -171,12 +172,13 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, misbound_arg::MisboundArg, narrowed_return::NarrowedReturn, nonidentity_key::NonidentityKey, overwide_parameter::OverwideParameter, parallel_bools::ParallelBools, - stale_across_reentry::StaleAcrossReentry, stale_panic_message::StalePanicMessage, - stale_safety_comment::StaleSafetyComment, stored_projection::StoredProjection, - stringified_error::StringifiedError, stringly_error::StringlyError, - unchecked_input_len::UncheckedInputLen, uneven_narrowing::UnevenNarrowing, - unit_mismatch::UnitMismatch, unread_error_variant::UnreadErrorVariant, - unread_none::UnreadNone, wildcard_local_enum::WildcardLocalEnum, + parallel_vecs::ParallelVecs, stale_across_reentry::StaleAcrossReentry, + stale_panic_message::StalePanicMessage, stale_safety_comment::StaleSafetyComment, + stored_projection::StoredProjection, stringified_error::StringifiedError, + stringly_error::StringlyError, unchecked_input_len::UncheckedInputLen, + uneven_narrowing::UnevenNarrowing, unit_mismatch::UnitMismatch, + unread_error_variant::UnreadErrorVariant, unread_none::UnreadNone, + wildcard_local_enum::WildcardLocalEnum, }; dylint_linting::init_config(sess); let config: MordantConfig = dylint_linting::config_or_default(env!("CARGO_PKG_NAME")); @@ -223,6 +225,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, CollapsedError::default); add(s, true, UnevenNarrowing::default); add(s, true, || CrossedIndex); + add(s, true, ParallelVecs::default); add(s, true, bool_beside_option::BoolBesideOption::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); diff --git a/src/parallel_vecs.rs b/src/parallel_vecs.rs new file mode 100644 index 0000000..14f9775 --- /dev/null +++ b/src/parallel_vecs.rs @@ -0,0 +1,443 @@ +use std::collections::{HashMap, HashSet}; + +use clippy_utils::res::MaybeResPath; +use clippy_utils::{SpanlessEq, get_parent_expr, hash_expr}; +use rustc_hir::def_id::{DefId, LocalDefId}; +use rustc_hir::{BorrowKind, Expr, ExprKind, HirId, Mutability, Node, UnOp}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::{self, Ty}; +use rustc_span::{DesugaringKind, Ident, Symbol}; + +use crate::adt_facts::field_ty; +use crate::baseline::emit; +use crate::hir_shapes::{assigned_field, field_method_call, indexed_field}; + +rustc_session::declare_lint! { + /// Flags two or more growable-sequence fields of one struct (`Vec`, + /// `VecDeque`, or any type with `push`/`append`, `len` and indexing) + /// whose lengths the crate only ever changes side by side -- every + /// `push`, `pop`, `clear`, `truncate`, reassignment or `&mut` borrow of + /// one sits in the same block as one of the other, on the same value -- + /// and that some function reads at one index (`s.a[i]` with `s.b[i]`, + /// `s.a.get(i)` with `s.b.get(i)`) or zips together. Element `i` of each + /// is one record kept in several places by hand: the type admits + /// sequences of different lengths, and only the discipline of every + /// writer keeps `a[i]` describing the same thing as `b[i]`. One `Vec` of + /// a struct with those fields holds the pairing in the type. + /// + /// Only fields nothing outside the crate can write are considered: the + /// struct is private to the crate, or the field is. One length change of + /// either field without the other beside it disproves the pairing and + /// the lint stays quiet; so does a pair grown together but never read in + /// step, and pushes to two different values of the type. + pub PARALLEL_VECS, + Warn, + "sequence fields only ever grown together and read at one index" +} + +/// Methods that change a sequence's length. +const LEN_OPS: &[&str] = &[ + "push", + "push_back", + "push_front", + "append", + "insert", + "extend", + "extend_from_slice", + "extend_from_within", + "resize", + "resize_with", + "pop", + "pop_back", + "pop_front", + "clear", + "truncate", + "remove", + "swap_remove", + "swap_remove_back", + "swap_remove_front", + "drain", + "retain", + "retain_mut", + "dedup", + "dedup_by", + "dedup_by_key", + "split_off", + "set_len", + "splice", + "append_assume_capacity", +]; + +/// Positional reads with one index argument. +const GET_OPS: &[&str] = &["get", "get_mut", "get_unchecked", "get_unchecked_mut"]; + +/// Adapters between a sequence and the `zip` that pairs it: `s.a.iter()`. +const ITER_ADAPTERS: &[&str] = &[ + "iter", + "iter_mut", + "into_iter", + "copied", + "cloned", + "by_ref", + "drain", +]; + +/// The value a sequence field is read off, `s` or `self.tape`: by shape, +/// and by identity too when it is a local binding or a projection of one, +/// since every local hashes alike. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct Place { + local: Option, + shape: u64, +} + +/// Where a length change happens: the body, the nearest block or match arm, +/// and the value it is applied to. +type Site = (DefId, HirId, Place); + +/// An indexed read of a sequence field, kept until its body ends. +struct Read { + owner: LocalDefId, + adt: DefId, + field: Symbol, + place: Place, + index: HirId, +} + +#[derive(Default)] +pub struct ParallelVecs { + /// Struct -> its sequence fields the crate alone can write; cached, and + /// empty for structs that do not qualify. + candidates: HashMap>, + /// (struct, field) -> the sites that change its length. + writes: HashMap<(DefId, Symbol), HashSet>, + /// (struct, field, field), names ordered: read at one index somewhere. + in_step: HashSet<(DefId, Symbol, Symbol)>, + reads: Vec, +} + +rustc_session::impl_lint_pass!(ParallelVecs => [PARALLEL_VECS]); + +fn has_inherent_method(cx: &LateContext<'_>, did: DefId, names: &[&str]) -> bool { + cx.tcx.inherent_impls(did).iter().any(|imp| { + names.iter().any(|n| { + cx.tcx + .associated_items(*imp) + .filter_by_name_unhygienic(Symbol::intern(n)) + .next() + .is_some() + }) + }) +} + +/// `Vec`, `VecDeque`, or a type that grows (`push`/`append`), reports a +/// `len`, and is read by position (`Index` or `get`). +fn is_sequence<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool { + let ty::Adt(adt, _) = ty.kind() else { + return false; + }; + let did = adt.did(); + if cx + .tcx + .get_diagnostic_name(did) + .is_some_and(|n| matches!(n.as_str(), "Vec" | "VecDeque")) + { + return true; + } + let indexed = cx + .tcx + .lang_items() + .index_trait() + .is_some_and(|t| cx.tcx.non_blanket_impls_for_ty(t, ty).next().is_some()) + || has_inherent_method(cx, did, &["get", "at"]); + indexed + && has_inherent_method(cx, did, &["push", "push_back", "append"]) + && has_inherent_method(cx, did, &["len"]) +} + +impl ParallelVecs { + /// The sequence fields of the local struct behind `ty` that only this + /// crate can write, when there are at least two of them. + fn candidates<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + ty: Ty<'tcx>, + ) -> Option<(DefId, &[Symbol])> { + let ty::Adt(adt, _) = ty.peel_refs().kind() else { + return None; + }; + if !adt.is_struct() || !adt.did().is_local() { + return None; + } + let did = adt.did(); + let fields = self.candidates.entry(did).or_insert_with(|| { + let exported = cx.effective_visibilities.is_exported(did.expect_local()); + let fields: Vec = adt + .non_enum_variant() + .fields + .iter() + .filter(|f| (!exported || !f.vis.is_public()) && is_sequence(cx, field_ty(cx, f))) + .map(|f| f.name) + .collect(); + if fields.len() >= 2 { + fields + } else { + Vec::new() + } + }); + (!fields.is_empty()).then_some((did, fields.as_slice())) + } + + /// `base.field` as a candidate sequence field: its struct, its name, and + /// the value it is read off. + fn sequence_field<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + base: &'tcx Expr<'tcx>, + field: Ident, + ) -> Option<(DefId, Symbol, Place)> { + let (adt, fields) = self.candidates(cx, cx.typeck_results().expr_ty_adjusted(base))?; + if !fields.contains(&field.name) { + return None; + } + let place = Place { + local: local_root(base), + shape: hash_expr(cx, base), + }; + Some((adt, field.name, place)) + } + + fn record_write<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + at: &'tcx Expr<'tcx>, + base: &'tcx Expr<'tcx>, + field: Ident, + ) { + let Some((adt, field, place)) = self.sequence_field(cx, base, field) else { + return; + }; + let body = cx.tcx.hir_enclosing_body_owner(at.hir_id).to_def_id(); + self.writes.entry((adt, field)).or_default().insert(( + body, + step_scope(cx, at.hir_id), + place, + )); + } + + fn record_index<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + at: &'tcx Expr<'tcx>, + base: &'tcx Expr<'tcx>, + field: Ident, + index: &'tcx Expr<'tcx>, + ) { + let Some((adt, field, place)) = self.sequence_field(cx, base, field) else { + return; + }; + let owner = cx.tcx.hir_enclosing_body_owner(at.hir_id); + for earlier in &self.reads { + if earlier.owner != owner + || earlier.adt != adt + || earlier.field == field + || earlier.place != place + { + continue; + } + let other = cx.tcx.hir_expect_expr(earlier.index); + if SpanlessEq::new(cx).eq_expr(at.span.ctxt(), other, index) { + self.in_step.insert(ordered(adt, earlier.field, field)); + } + } + self.reads.push(Read { + owner, + adt, + field, + place, + index: index.hir_id, + }); + } + + /// `zip` over two sequence fields of one value reads them in step. + fn record_zip<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + l: &'tcx Expr<'tcx>, + r: &'tcx Expr<'tcx>, + ) { + let (Some((lb, lf)), Some((rb, rf))) = (zipped_field(l), zipped_field(r)) else { + return; + }; + let (Some((adt, lf, lp)), Some((radt, rf, rp))) = ( + self.sequence_field(cx, lb, lf), + self.sequence_field(cx, rb, rf), + ) else { + return; + }; + if adt == radt && lf != rf && lp == rp { + self.in_step.insert(ordered(adt, lf, rf)); + } + } +} + +/// The local binding a place expression projects from, through fields, +/// indexing, `&` and `*`. +fn local_root(mut e: &Expr<'_>) -> Option { + loop { + match e.kind { + ExprKind::Field(inner, _) + | ExprKind::Index(inner, _, _) + | ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(UnOp::Deref, inner) + | ExprKind::DropTemps(inner) => e = inner, + _ => return e.res_local_id(), + } + } +} + +fn ordered(adt: DefId, a: Symbol, b: Symbol) -> (DefId, Symbol, Symbol) { + if a.as_str() <= b.as_str() { + (adt, a, b) + } else { + (adt, b, a) + } +} + +/// The nearest block or match arm around `hir_id`: two length changes are +/// side by side when they share it. +fn step_scope(cx: &LateContext<'_>, hir_id: HirId) -> HirId { + for (id, node) in cx.tcx.hir_parent_iter(hir_id) { + match node { + Node::Block(_) | Node::Arm(_) => return id, + Node::Item(_) | Node::ImplItem(_) | Node::TraitItem(_) => break, + _ => {} + } + } + hir_id +} + +/// The `base.field` a `zip` operand iterates: `&s.a`, `s.a.iter()`, +/// `s.a.iter().copied()`. +fn zipped_field<'h>(mut e: &'h Expr<'h>) -> Option<(&'h Expr<'h>, Ident)> { + loop { + match e.kind { + ExprKind::AddrOf(_, _, inner) | ExprKind::DropTemps(inner) => e = inner, + ExprKind::MethodCall(seg, recv, _, _) + if ITER_ADAPTERS.contains(&seg.ident.name.as_str()) => + { + e = recv; + } + ExprKind::Field(base, ident) => return Some((base, ident)), + _ => return None, + } + } +} + +/// `&mut s.a` in a `for` head iterates; anywhere else it is handed to code +/// that may change the length. +fn is_for_loop_head(cx: &LateContext<'_>, e: &Expr<'_>) -> bool { + get_parent_expr(cx, e).is_some_and(|p| { + matches!(p.kind, ExprKind::Call(..)) && p.span.is_desugaring(DesugaringKind::ForLoop) + }) +} + +impl<'tcx> LateLintPass<'tcx> for ParallelVecs { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + match expr.kind { + ExprKind::MethodCall(seg, recv, [arg], _) if seg.ident.name.as_str() == "zip" => { + self.record_zip(cx, recv, arg); + } + ExprKind::Call(callee, [l, r]) + if matches!(callee.kind, ExprKind::Path(ref qp) + if cx.qpath_res(qp, callee.hir_id).opt_def_id() + .is_some_and(|d| cx.tcx.opt_item_name(d) + .is_some_and(|n| n.as_str() == "zip"))) => + { + self.record_zip(cx, l, r); + } + ExprKind::MethodCall(..) => { + let Some(call) = field_method_call(expr) else { + return; + }; + let name = call.method.name.as_str(); + if LEN_OPS.contains(&name) { + self.record_write(cx, expr, call.base, call.field); + } else if GET_OPS.contains(&name) + && let [index] = call.args + { + self.record_index(cx, expr, call.base, call.field, index); + } + } + ExprKind::Index(..) => { + if let Some(read) = indexed_field(expr) { + self.record_index(cx, expr, read.base, read.field, read.index); + } + } + ExprKind::Assign(place, _, _) | ExprKind::AssignOp(_, place, _) => { + if let Some((base, field, _)) = assigned_field(place) { + self.record_write(cx, expr, base, field); + } + } + ExprKind::AddrOf(BorrowKind::Ref, Mutability::Mut, inner) => { + if let Some((base, field, _)) = assigned_field(inner) + && !is_for_loop_head(cx, expr) + { + self.record_write(cx, expr, base, field); + } + } + _ => {} + } + } + + fn check_body_post(&mut self, cx: &LateContext<'tcx>, body: &rustc_hir::Body<'tcx>) { + let owner = cx.tcx.hir_body_owner_def_id(body.id()); + self.reads.retain(|r| r.owner != owner); + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let mut per_struct: HashMap)>> = HashMap::new(); + for ((adt, field), sites) in self.writes.drain() { + per_struct.entry(adt).or_default().push((field, sites)); + } + let mut findings: Vec<(DefId, Vec, usize)> = Vec::new(); + for (adt, mut fields) in per_struct { + fields.sort_by_key(|(f, _)| f.as_str().to_owned()); + let mut groups: Vec<(&HashSet, Vec)> = Vec::new(); + for (field, sites) in &fields { + match groups.iter_mut().find(|(s, _)| *s == sites) { + Some((_, members)) => members.push(*field), + None => groups.push((sites, vec![*field])), + } + } + for (sites, members) in groups { + let read_in_step = members.iter().any(|a| { + members + .iter() + .any(|b| self.in_step.contains(&ordered(adt, *a, *b))) + }); + if members.len() >= 2 && read_in_step { + findings.push((adt, members, sites.len())); + } + } + } + findings.sort_by_key(|(adt, ..)| cx.tcx.def_span(*adt).lo()); + for (adt, members, sites) in findings { + let names: Vec = members.iter().map(|m| format!("`{m}`")).collect(); + emit( + cx, + PARALLEL_VECS, + cx.tcx.def_span(adt), + format!( + "parallel vecs: the fields {} of `{}` only change length together ({} {}) and are read at one index, so element `i` of each is one record kept in {} places", + names.join(", "), + cx.tcx.def_path_str(adt), + sites, + if sites == 1 { "block" } else { "blocks" }, + members.len(), + ), + "one `Vec` of a struct with these fields holds the pairing in the type and cannot let the lengths differ", + ); + } + } +} diff --git a/ui/parallel_vecs.rs b/ui/parallel_vecs.rs new file mode 100644 index 0000000..764732e --- /dev/null +++ b/ui/parallel_vecs.rs @@ -0,0 +1,190 @@ +// Sequence fields that only change length together and are read at one index are one Vec of a record. +#![allow(dead_code)] + +use std::collections::VecDeque; + +// Flagged: `names` and `ages` are pushed together, truncated together, and read at one index. +struct People { + names: Vec, + ages: Vec, + seen: usize, +} + +impl People { + fn add(&mut self, name: String, age: u32) { + self.names.push(name); + self.ages.push(age); + self.seen += 1; + } + + fn forget(&mut self, keep: usize) { + self.names.truncate(keep); + self.ages.truncate(keep); + } + + fn describe(&self, i: usize) -> String { + format!("{} is {}", self.names[i], self.ages[i]) + } + + // A `for` over `&mut self.ages` changes no length. + fn birthday(&mut self) { + for age in &mut self.ages { + *age += 1; + } + } +} + +// Flagged: three columns grown through a local binding and read by `zip`. +struct Table { + keys: Vec, + values: Vec, + phases: VecDeque, +} + +fn insert(t: &mut Table, key: u32, value: String, phase: u8) { + t.keys.push(key); + t.values.push(value); + t.phases.push_back(phase); +} + +fn dump(t: &Table) { + for (k, v) in t.keys.iter().zip(&t.values) { + println!("{k} {v}"); + } +} + +// Flagged: slices from one mark, through a field of `self`. +struct Tape { + items: Vec, + locs: Vec, +} + +struct Builder { + tape: Tape, +} + +impl Builder { + fn item(&mut self, item: u8, loc: u32) { + self.tape.items.push(item); + self.tape.locs.push(loc); + } + + fn close(&mut self, mark: usize) -> usize { + let n = self.tape.items[mark..].len() + self.tape.locs[mark..].len(); + self.tape.items.truncate(mark); + self.tape.locs.truncate(mark); + n + } +} + +// Fine: `errors` is also pushed alone, so it is not in step with `lines`. +struct Report { + lines: Vec, + errors: Vec, +} + +impl Report { + fn line(&mut self, l: String) { + self.lines.push(l.clone()); + self.errors.push(l); + } + + fn fail(&mut self, e: String) { + self.errors.push(e); + } + + fn pair(&self, i: usize) -> (&str, &str) { + (&self.lines[i], &self.errors[i]) + } +} + +// Fine: grown together but never read in step; each is consumed whole. +struct Rules { + ltr: Vec, + rtl: Vec, +} + +impl Rules { + fn add(&mut self, l: u8, r: u8) { + self.ltr.push(l); + self.rtl.push(r); + } + + fn total(&self) -> usize { + self.ltr.iter().map(|b| *b as usize).sum::() + self.rtl.len() + } +} + +// Fine: pushes to two different values are not a pair. +struct Lanes { + xs: Vec, + ys: Vec, +} + +fn cross(a: &mut Lanes, b: &mut Lanes) { + a.xs.push(1); + b.ys.push(2); +} + +fn lane(l: &Lanes, i: usize) -> u8 { + l.xs[i] + l.ys[i] +} + +// Fine: a `&mut` borrow handed to a function is a lone length change. +struct Swap { + a: Vec, + b: Vec, +} + +impl Swap { + fn both(&mut self, x: u8) { + self.a.push(x); + self.b.push(x); + } + + fn reset(&mut self) -> Vec { + std::mem::take(&mut self.a) + } + + fn sum(&self, i: usize) -> u8 { + self.a[i] + self.b[i] + } +} + +// Fine: the two pushes sit in different match arms. +enum Side { + Left, + Right, +} + +struct Split { + left: Vec, + right: Vec, +} + +impl Split { + fn put(&mut self, side: Side, x: u8) { + match side { + Side::Left => self.left.push(x), + Side::Right => self.right.push(x), + } + } + + fn at(&self, i: usize) -> u8 { + self.left[i] + self.right[i] + } +} + +// Fine: public fields of an exported struct can be grown by any crate. +pub struct Open { + pub firsts: Vec, + pub seconds: Vec, +} + +pub fn open(o: &mut Open, i: usize) -> u8 { + o.firsts.push(1); + o.seconds.push(2); + o.firsts[i] + o.seconds[i] +} + +fn main() {} diff --git a/ui/parallel_vecs.stderr b/ui/parallel_vecs.stderr new file mode 100644 index 0000000..4803774 --- /dev/null +++ b/ui/parallel_vecs.stderr @@ -0,0 +1,27 @@ +warning: parallel vecs: the fields `ages`, `names` of `People` only change length together (2 blocks) and are read at one index, so element `i` of each is one record kept in 2 places + --> $DIR/parallel_vecs.rs:7:1 + | +LL | struct People { + | ^^^^^^^^^^^^^ + | + = help: one `Vec` of a struct with these fields holds the pairing in the type and cannot let the lengths differ + = note: `#[warn(parallel_vecs)]` on by default + +warning: parallel vecs: the fields `keys`, `phases`, `values` of `Table` only change length together (1 block) and are read at one index, so element `i` of each is one record kept in 3 places + --> $DIR/parallel_vecs.rs:38:1 + | +LL | struct Table { + | ^^^^^^^^^^^^ + | + = help: one `Vec` of a struct with these fields holds the pairing in the type and cannot let the lengths differ + +warning: parallel vecs: the fields `items`, `locs` of `Tape` only change length together (2 blocks) and are read at one index, so element `i` of each is one record kept in 2 places + --> $DIR/parallel_vecs.rs:57:1 + | +LL | struct Tape { + | ^^^^^^^^^^^ + | + = help: one `Vec` of a struct with these fields holds the pairing in the type and cannot let the lengths differ + +warning: 3 warnings emitted + From 18b09ffb0eca9f98fe0c415f7e8354cff877e21b Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:19:34 -0700 Subject: [PATCH 11/34] collapsed_error stays quiet on zero-sized errors --- src/collapsed_error.rs | 21 ++++++++++----------- src/hir_shapes.rs | 20 +++++--------------- ui/collapsed_error.rs | 16 ++++++++++++++++ 3 files changed, 31 insertions(+), 26 deletions(-) diff --git a/src/collapsed_error.rs b/src/collapsed_error.rs index fa980aa..704c4f2 100644 --- a/src/collapsed_error.rs +++ b/src/collapsed_error.rs @@ -12,8 +12,9 @@ use rustc_hir::{ StmtKind, }; use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::Ty; +use rustc_middle::ty::layout::LayoutOf; use rustc_middle::ty::print::with_no_trimmed_paths; -use rustc_middle::ty::{self, Ty}; use rustc_span::Span; use crate::adt_facts::{is_option_ty, result_err_ty}; @@ -35,7 +36,9 @@ rustc_session::declare_lint! { /// /// Silent when the `Err` arm or `else` block does anything besides exit /// (logs, stores or converts the error: it was looked at); when the error - /// type is `()`, `!` or a bare number (`binary_search`'s `Err(idx)` is an + /// type had no kind to lose -- zero-sized (`()`, `AllocError`, + /// `TryFromIntError`, a lone unit variant), where `false` says as much as + /// the error did, or a bare number (`binary_search`'s `Err(idx)` is an /// answer, not a failure); on trait methods and non-Rust-ABI functions /// (the signature is not the function's to choose); on collapses inside /// closures; on callees in other crates; on calls in tests or produced @@ -90,18 +93,14 @@ pub struct CollapsedError { rustc_session::impl_lint_pass!(CollapsedError => [COLLAPSED_ERROR]); -/// The error type of `e`'s `Result`, when it is one worth calling an error: -/// not `()`, `!`, a bare primitive or an uninhabited enum. +/// The error type of `e`'s `Result`, when it has a kind that `false` loses: +/// not a bare primitive, and not zero-sized (`()`, `!`, a unit struct, a +/// lone unit variant), which distinguishes nothing a `bool` does not. A type +/// whose layout is unknown here (generic) is given the benefit of the doubt. fn err_of<'tcx>(cx: &LateContext<'tcx>, e: &Expr<'tcx>) -> Option> { let ty = cx.typeck_results().expr_ty(e).peel_refs(); let err = result_err_ty(cx.tcx, ty)?.peel_refs(); - if err.is_unit() || err.is_never() || err.is_primitive() || err.is_str() { - return None; - } - if let ty::Adt(adt, _) = err.kind() - && adt.is_enum() - && adt.variants().is_empty() - { + if err.is_primitive() || err.is_str() || cx.layout_of(err).is_ok_and(|l| l.is_zst()) { return None; } Some(err) diff --git a/src/hir_shapes.rs b/src/hir_shapes.rs index 35ba397..5f785e3 100644 --- a/src/hir_shapes.rs +++ b/src/hir_shapes.rs @@ -17,15 +17,10 @@ pub(crate) fn is_self_path(e: &Expr<'_>) -> bool { if p.segments.len() == 1 && p.segments[0].ident.name == kw::SelfLower) } -/// `self.field`: the `self` expression it is read off and the field name. -pub(crate) struct SelfField<'h> { - pub base: &'h Expr<'h>, - pub ident: Ident, -} - -pub(crate) fn self_field<'h>(e: &Expr<'h>) -> Option> { +/// `self.field`, as the `self` expression it is read off and the field name. +pub(crate) fn self_field<'h>(e: &Expr<'h>) -> Option<(&'h Expr<'h>, Ident)> { match e.kind { - ExprKind::Field(base, ident) if is_self_path(base) => Some(SelfField { base, ident }), + ExprKind::Field(base, ident) if is_self_path(base) => Some((base, ident)), _ => None, } } @@ -47,12 +42,7 @@ pub(crate) fn assigned_field<'h>( /// `root.a.b` as `root` and `[a, b]`, read through `&`, `*` and HIR /// temporaries at any level, which all name the same place. -pub(crate) struct FieldChain<'h> { - pub root: &'h Expr<'h>, - pub fields: Vec, -} - -pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> FieldChain<'h> { +pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> (&'h Expr<'h>, Vec) { let mut fields = Vec::new(); loop { match e.kind { @@ -65,7 +55,7 @@ pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> FieldChain<'h> { | ExprKind::DropTemps(inner) => e = inner, _ => { fields.reverse(); - return FieldChain { root: e, fields }; + return (e, fields); } } } diff --git a/ui/collapsed_error.rs b/ui/collapsed_error.rs index 3b81adc..e55a460 100644 --- a/ui/collapsed_error.rs +++ b/ui/collapsed_error.rs @@ -158,6 +158,21 @@ fn ping_discarded() { let _ = pinged(true); } +// Fine: a zero-sized error has one value, so `false` already says all it did. +struct Full; + +fn alloc_slot(n: usize) -> Result { + if n > 8 { Err(Full) } else { Ok(n) } +} + +fn try_alloc(n: usize) -> bool { + alloc_slot(n).is_ok() +} + +fn alloc_discarded() { + let _ = try_alloc(9); +} + // Fine: a trait method's signature is the trait's, not the impl's. trait Sink { fn put(&mut self, byte: u8) -> bool; @@ -227,6 +242,7 @@ fn main() { let _ = mark_checked(&mut buf); sorted_discarded(&[1, 2, 3]); ping_discarded(); + alloc_discarded(); put_dropped(&mut buf); exported_discarded(); folded_discarded(&mut buf); From 62c007d4e84d6ec78af063775236ac60b63a2584 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:20:46 -0700 Subject: [PATCH 12/34] add sentinel_int --- README.md | 1 + src/lib.rs | 16 +- src/sentinel_int.rs | 488 +++++++++++++++++++++++++++++++++++++++++ ui/sentinel_int.rs | 162 ++++++++++++++ ui/sentinel_int.stderr | 27 +++ 5 files changed, 687 insertions(+), 7 deletions(-) create mode 100644 src/sentinel_int.rs create mode 100644 ui/sentinel_int.rs create mode 100644 ui/sentinel_int.stderr diff --git a/README.md b/README.md index f97741d..dcaa90c 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `crossed_index` | `parts[source_index]` in a function that indexes `parts` by `part_index` and `sources` by `source_index`: two index kinds cross, and both are plain integers | | `parallel_vecs` | sequence fields of one struct that only change length side by side and are read at one index: element `i` of each is one record, so the type lets the lengths differ | | `bool_beside_option` | a bool field written only beside an `Option` field, `true` with `Some(..)` and `false` with `None`: it is that field's `is_some()` stored twice, kept equal only by habit | +| `sentinel_int` | an integer field one function tests against `MAX`, `-1` or an `INVALID` constant and another indexes with or offsets a pointer by untested: `Option` spelled as an int | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/lib.rs b/src/lib.rs index 7a55487..e8ecedf 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -49,6 +49,7 @@ mod parallel_bools; mod parallel_vecs; mod reimplemented_helper; mod same_match_twice; +mod sentinel_int; mod stale_across_reentry; mod stale_panic_message; mod stale_safety_comment; @@ -172,13 +173,13 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, misbound_arg::MisboundArg, narrowed_return::NarrowedReturn, nonidentity_key::NonidentityKey, overwide_parameter::OverwideParameter, parallel_bools::ParallelBools, - parallel_vecs::ParallelVecs, stale_across_reentry::StaleAcrossReentry, - stale_panic_message::StalePanicMessage, stale_safety_comment::StaleSafetyComment, - stored_projection::StoredProjection, stringified_error::StringifiedError, - stringly_error::StringlyError, unchecked_input_len::UncheckedInputLen, - uneven_narrowing::UnevenNarrowing, unit_mismatch::UnitMismatch, - unread_error_variant::UnreadErrorVariant, unread_none::UnreadNone, - wildcard_local_enum::WildcardLocalEnum, + parallel_vecs::ParallelVecs, sentinel_int::SentinelInt, + stale_across_reentry::StaleAcrossReentry, stale_panic_message::StalePanicMessage, + stale_safety_comment::StaleSafetyComment, stored_projection::StoredProjection, + stringified_error::StringifiedError, stringly_error::StringlyError, + unchecked_input_len::UncheckedInputLen, uneven_narrowing::UnevenNarrowing, + unit_mismatch::UnitMismatch, unread_error_variant::UnreadErrorVariant, + unread_none::UnreadNone, wildcard_local_enum::WildcardLocalEnum, }; dylint_linting::init_config(sess); let config: MordantConfig = dylint_linting::config_or_default(env!("CARGO_PKG_NAME")); @@ -227,6 +228,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, || CrossedIndex); add(s, true, ParallelVecs::default); add(s, true, bool_beside_option::BoolBesideOption::default); + add(s, true, SentinelInt::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/src/sentinel_int.rs b/src/sentinel_int.rs new file mode 100644 index 0000000..508bdc7 --- /dev/null +++ b/src/sentinel_int.rs @@ -0,0 +1,488 @@ +use std::collections::{HashMap, HashSet}; + +use clippy_utils::higher::Range; +use clippy_utils::res::MaybeResPath; +use rustc_ast::LitKind; +use rustc_hir::def::{DefKind, Res}; +use rustc_hir::def_id::DefId; +use rustc_hir::{BinOpKind, Expr, ExprKind, HirId, LetStmt, PatKind, QPath, UnOp}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_span::{Span, Symbol}; + +use crate::adt_facts::{field_ty, struct_field}; +use crate::baseline::emit; +use crate::hir_shapes::{assigned_field, callee_of, peel_blocks_unsafe}; + +rustc_session::declare_lint! { + /// Flags an integer struct field that the crate itself treats as + /// sometimes absent — some function compares it `==`/`!=` against + /// `T::MAX`, `-1`, or a constant named `INVALID`/`NONE`/`SENTINEL` — and + /// that another function indexes with (`v[x.f as usize]`, a slice range + /// end, `buf[off..off + len]`, `get_unchecked`) or offsets a pointer by, + /// with no test of the field anywhere in that function. One reader knows + /// the magic value means "none"; the other turns it into an out-of-bounds + /// index or a wild pointer. The type is `u32` when the value set is + /// `Option`, and only convention tells the readers apart. + /// + /// Reported on the unchecked reader. A function counts as checking the + /// field if it compares the field, or a local read off it, against + /// anything (`==`, `!=`, an ordering test against a length), clamps it + /// (`min`, `checked_add`, ..), or directly calls a predicate (a + /// `bool`-returning function) that does; a function all of whose visible + /// callers check is their unchecked half and stays quiet too. `.get(i)` + /// already answers for an index that is not there and is not counted. + /// Plain arithmetic on the field is not a use: positions and lengths are + /// summed everywhere and the sum is only wrong where it meets memory. A + /// field only ever *assigned* `MAX` and never compared to it is a bound, + /// not a missing value, and is left alone. + pub SENTINEL_INT, + Warn, + "an integer field compared to a sentinel by one reader and indexed with unchecked by another" +} + +/// A struct (local or not) and one of its integer fields. +type Field = (DefId, Symbol); + +#[derive(Default)] +struct Evidence { + /// How the first comparison seen spells the sentinel. + spelling: String, + compared: usize, +} + +#[derive(Clone, Copy)] +enum Use { + Index, + Offset, +} + +struct Read { + body: DefId, + span: Span, + how: Use, +} + +#[derive(Default)] +pub struct SentinelInt { + evidence: HashMap, + reads: HashMap>, + /// The function tests the field, or a value read off it, somewhere. + checked: HashSet<(DefId, Field)>, + /// `let i = x.f as usize`: locals that carry a field's value. + locals: HashMap, + /// Function -> local `bool`-returning functions it calls directly. + calls: HashMap>, + /// Local function -> functions that call it directly. + callers: HashMap>, + /// Local functions referenced other than by a direct call: their caller + /// set is unknowable. + poisoned: HashSet, +} + +rustc_session::impl_lint_pass!(SentinelInt => [SENTINEL_INT]); + +enum Sentinel { + Max, + MinusOne, + Named(DefId), +} + +/// `INVALID_ID`, `Slot::NONE`, `NOT_SET_SENTINEL`: a constant whose name says +/// the value stands for no value. +fn names_absence(name: &str) -> bool { + name.split('_') + .any(|w| matches!(w, "INVALID" | "NONE" | "SENTINEL")) +} + +/// The sentinel an expression spells, if it is one of the three forms. +fn sentinel_of<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option { + let e = peel_blocks_unsafe(e); + if !cx.typeck_results().expr_ty(e).is_integral() { + return None; + } + match e.kind { + ExprKind::Unary(UnOp::Neg, inner) => match peel_blocks_unsafe(inner).kind { + ExprKind::Lit(lit) if matches!(lit.node, LitKind::Int(v, _) if v.get() == 1) => { + Some(Sentinel::MinusOne) + } + _ => None, + }, + ExprKind::Path(ref qpath) => match cx.qpath_res(qpath, e.hir_id) { + Res::Def(DefKind::Const { .. } | DefKind::AssocConst { .. }, did) => { + let name = cx.tcx.item_name(did); + if name.as_str() == "MAX" && cx.tcx.crate_name(did.krate).as_str() == "core" { + Some(Sentinel::Max) + } else if names_absence(name.as_str()) { + Some(Sentinel::Named(did)) + } else { + None + } + } + _ => None, + }, + _ => None, + } +} + +fn spelling(cx: &LateContext<'_>, s: &Sentinel, e: &Expr<'_>) -> String { + match s { + Sentinel::Max => format!("{}::MAX", cx.typeck_results().expr_ty(e)), + Sentinel::MinusOne => "-1".to_owned(), + Sentinel::Named(did) => cx.tcx.item_name(*did).to_string(), + } +} + +/// `base.name` as a struct and its integer field. +fn field_key(cx: &LateContext<'_>, base: &Expr<'_>, name: Symbol) -> Option { + let adt = cx + .typeck_results() + .expr_ty_adjusted(base) + .peel_refs() + .ty_adt_def()?; + if !adt.is_struct() { + return None; + } + let f = struct_field(adt, name)?; + field_ty(cx, f).is_integral().then_some((adt.did(), name)) +} + +/// The function an expression belongs to, with closures folded into the +/// function that wrote them: a check before a `.map(|..| v[x.f])` covers it. +fn owner_fn(cx: &LateContext<'_>, hir_id: HirId) -> DefId { + let mut did = cx.tcx.hir_enclosing_body_owner(hir_id).to_def_id(); + while cx.tcx.is_closure_like(did) || matches!(cx.tcx.def_kind(did), DefKind::InlineConst) { + did = cx.tcx.parent(did); + } + did +} + +/// Value-preserving wrappers a field read is still visible through. +const ADAPTERS: &[&str] = &[ + "clone", + "into", + "try_into", + "unwrap", + "expect", + "cast_signed", + "cast_unsigned", +]; + +/// Calls that index their receiver by their one argument and do not answer +/// for an index that is not there. +const INDEXERS: &[&str] = &[ + "get_unchecked", + "get_unchecked_mut", + "split_at", + "split_at_mut", + "split_off", + "remove", + "swap_remove", +]; + +const OFFSETS: &[&str] = &[ + "add", + "sub", + "offset", + "byte_add", + "byte_sub", + "byte_offset", +]; + +impl SentinelInt { + /// The field whose value `e` carries: the field itself through casts, + /// borrows, derefs and value-preserving adapters, or a local bound to one. + fn read_of<'tcx>(&self, cx: &LateContext<'tcx>, mut e: &'tcx Expr<'tcx>) -> Option { + loop { + e = peel_blocks_unsafe(e); + match e.kind { + ExprKind::Cast(inner, _) + | ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(UnOp::Deref, inner) => e = inner, + ExprKind::MethodCall(seg, recv, args, _) + if args.len() <= 1 && ADAPTERS.contains(&seg.ident.as_str()) => + { + e = recv; + } + // `usize::from(x.f)`, `u32::try_from(x.f)`. + ExprKind::Call(callee, [arg]) + if matches!(callee.kind, ExprKind::Path(QPath::TypeRelative(_, seg)) + if matches!(seg.ident.as_str(), "from" | "try_from")) => + { + e = arg; + } + // `off + len`, `idx - 1`: the sum still carries the sentinel. + ExprKind::Binary(op, l, r) + if matches!(op.node, BinOpKind::Add | BinOpKind::Sub | BinOpKind::Mul) => + { + return self.read_of(cx, l).or_else(|| self.read_of(cx, r)); + } + ExprKind::Field(base, ident) => return field_key(cx, base, ident.name), + ExprKind::Path(_) => { + return e + .res_local_id() + .and_then(|id| self.locals.get(&id).copied()); + } + _ => return None, + } + } + } + + fn compared(&mut self, cx: &LateContext<'_>, field: Field, s: &Sentinel, at: &Expr<'_>) { + let ev = self.evidence.entry(field).or_default(); + if ev.compared == 0 { + ev.spelling = spelling(cx, s, at); + } + ev.compared += 1; + } + + fn read<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + operand: &'tcx Expr<'tcx>, + span: Span, + how: Use, + ) { + if let Some(field) = self.read_of(cx, operand) { + let body = owner_fn(cx, operand.hir_id); + self.reads + .entry(field) + .or_default() + .push(Read { body, span, how }); + } + } + + /// An index operand: the value itself, or either end of a range. + fn indexed<'tcx>(&mut self, cx: &LateContext<'tcx>, idx: &'tcx Expr<'tcx>, span: Span) { + match Range::hir(cx, idx) { + Some(range) => { + for end in [range.start, range.end].into_iter().flatten() { + self.read(cx, end, span, Use::Index); + } + } + None => self.read(cx, idx, span, Use::Index), + } + } + + fn checks(&self, body: DefId, field: Field) -> bool { + self.checked.contains(&(body, field)) + || self + .calls + .get(&body) + .is_some_and(|cs| cs.iter().any(|c| self.checked.contains(&(*c, field)))) + } + + /// Every visible caller of `body` checks the field first: `body` is the + /// unchecked half of a checked pair, not an unchecked reader. + fn callers_check(&self, body: DefId, field: Field) -> bool { + if self.poisoned.contains(&body) { + return false; + } + self.callers + .get(&body) + .is_some_and(|cs| !cs.is_empty() && cs.iter().all(|c| self.checks(*c, field))) + } + + fn record_call<'tcx>(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + match callee_of(cx, expr) { + Some(callee) => { + let def = callee.def(); + if def.is_local() && matches!(cx.tcx.def_kind(def), DefKind::Fn | DefKind::AssocFn) + { + let body = owner_fn(cx, expr.hir_id); + self.callers.entry(def).or_default().insert(body); + // Only a predicate (`is_root()`, `has_parent()`) stands + // in for a comparison; a call that happens to compare + // inside says nothing about the caller's own reads. + let returns_bool = cx + .tcx + .fn_sig(def) + .instantiate_identity() + .skip_normalization() + .output() + .skip_binder() + .is_bool(); + if returns_bool { + self.calls.entry(body).or_default().insert(def); + } + } + } + None => { + let ExprKind::Path(qpath) = &expr.kind else { + return; + }; + if matches!( + clippy_utils::get_parent_expr(cx, expr), + Some(Expr { kind: ExprKind::Call(callee, _), .. }) if callee.hir_id == expr.hir_id + ) { + return; + } + if let Res::Def(DefKind::Fn | DefKind::AssocFn, def) = + cx.qpath_res(qpath, expr.hir_id) + && def.is_local() + { + self.poisoned.insert(def); + } + } + } + } +} + +impl<'tcx> LateLintPass<'tcx> for SentinelInt { + fn check_local(&mut self, cx: &LateContext<'tcx>, local: &'tcx LetStmt<'tcx>) { + if let Some(init) = local.init + && let PatKind::Binding(_, id, _, None) = local.pat.kind + && let Some(field) = self.read_of(cx, init) + { + self.locals.insert(id, field); + } + } + + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + self.record_call(cx, expr); + match expr.kind { + ExprKind::Struct(_, fields, _) => { + let Some(adt) = cx.typeck_results().expr_ty(expr).ty_adt_def() else { + return; + }; + if !adt.is_struct() { + return; + } + for init in fields { + if let Some(s) = sentinel_of(cx, init.expr) + && let Some(f) = struct_field(adt, init.ident.name) + && field_ty(cx, f).is_integral() + { + // A literal that writes the sentinel is where the + // spelling is clearest, but it is not a check. + let ev = self + .evidence + .entry((adt.did(), init.ident.name)) + .or_default(); + if ev.spelling.is_empty() { + ev.spelling = spelling(cx, &s, init.expr); + } + } + } + } + ExprKind::Assign(place, val, _) => { + if let Some((base, ident, _)) = assigned_field(place) + && let Some(field) = field_key(cx, base, ident.name) + && let Some(s) = sentinel_of(cx, val) + { + let ev = self.evidence.entry(field).or_default(); + if ev.spelling.is_empty() { + ev.spelling = spelling(cx, &s, val); + } + } + } + ExprKind::Binary(op, l, r) => match op.node { + BinOpKind::Eq | BinOpKind::Ne => { + let body = owner_fn(cx, expr.hir_id); + for (a, b) in [(l, r), (r, l)] { + if let Some(field) = self.read_of(cx, a) { + self.checked.insert((body, field)); + if let Some(s) = sentinel_of(cx, b) { + self.compared(cx, field, &s, b); + } + } + } + } + BinOpKind::Lt | BinOpKind::Le | BinOpKind::Gt | BinOpKind::Ge => { + let body = owner_fn(cx, expr.hir_id); + for a in [l, r] { + if let Some(field) = self.read_of(cx, a) { + self.checked.insert((body, field)); + } + } + } + _ => {} + }, + ExprKind::Index(_, idx, _) => self.indexed(cx, idx, expr.span), + ExprKind::MethodCall(seg, recv, args, _) => { + let name = seg.ident.as_str(); + // The author deciding what an out-of-range value does: + // overflow-aware arithmetic on it, or a clamp of it. + let bounded = ["checked_", "saturating_", "wrapping_", "overflowing_"] + .iter() + .any(|p| name.starts_with(p)) + || matches!(name, "min" | "max" | "clamp"); + if bounded { + for operand in std::iter::once(recv).chain(args.iter()) { + if let Some(field) = self.read_of(cx, operand) { + self.checked.insert((owner_fn(cx, expr.hir_id), field)); + } + } + } + if let [arg] = args { + if INDEXERS.contains(&name) { + self.indexed(cx, arg, expr.span); + } else if OFFSETS.contains(&name) + && cx.typeck_results().expr_ty_adjusted(recv).is_raw_ptr() + { + self.read(cx, arg, expr.span, Use::Offset); + } + } + } + _ => {} + } + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let mut findings: Vec<(Span, String)> = Vec::new(); + for (field, reads) in &self.reads { + let Some(ev) = self.evidence.get(field) else { + continue; + }; + if ev.compared == 0 { + continue; + } + // One report per unchecked function, at its first use. + let mut first: HashMap = HashMap::new(); + for read in reads { + first + .entry(read.body) + .and_modify(|r| { + if read.span.lo() < r.span.lo() { + *r = read; + } + }) + .or_insert(read); + } + for (body, read) in first { + if self.checks(body, *field) || self.callers_check(body, *field) { + continue; + } + let how = match read.how { + Use::Index => "indexes with", + Use::Offset => "offsets a pointer by", + }; + let reader = match cx.tcx.opt_item_name(body) { + Some(name) => format!("`{name}`"), + None => "this body".to_owned(), + }; + findings.push(( + read.span, + format!( + "sentinel `{}` can reach this use: {reader} {how} `{}.{}` and nothing in it tests the field, which the crate compares against that sentinel at {} other site{}", + ev.spelling, + cx.tcx.item_name(field.0), + field.1, + ev.compared, + if ev.compared == 1 { "" } else { "s" }, + ), + )); + } + } + findings.sort_by_key(|(span, _)| span.lo()); + findings.dedup_by_key(|(span, _)| *span); + for (span, msg) in findings { + emit( + cx, + SENTINEL_INT, + span, + msg, + "the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case", + ); + } + } +} diff --git a/ui/sentinel_int.rs b/ui/sentinel_int.rs new file mode 100644 index 0000000..4fcc422 --- /dev/null +++ b/ui/sentinel_int.rs @@ -0,0 +1,162 @@ +// An integer field one function tests against a sentinel and another indexes +// with or offsets by unchecked: the sentinel reaches that use. + +const INVALID_SLOT: u32 = u32::MAX; + +struct Entry { + slot: u32, + parent: u32, + depth: i32, + width: u32, + rank: u32, +} + +struct Table { + names: Vec<&'static str>, + entries: Vec, +} + +impl Table { + // Fine: the comparison is the check. + fn name(&self, e: &Entry) -> Option<&'static str> { + if e.slot == INVALID_SLOT { + None + } else { + Some(self.names[e.slot as usize]) + } + } + + // Flagged: indexes with `slot` and nothing in this body tests it. + fn rename(&mut self, e: &Entry, to: &'static str) { + self.names[e.slot as usize] = to; + } + + // Fine: bounds-tested through a local before the index. + fn name_or_empty(&self, e: &Entry) -> &'static str { + let i = e.slot as usize; + if i >= self.names.len() { + return ""; + } + self.names[i] + } + + fn detach(&mut self, i: usize) { + self.entries[i].parent = u32::MAX; + } + + fn is_root(&self, e: &Entry) -> bool { + e.parent == u32::MAX + } + + // Flagged: `parent` is `u32::MAX` for a root, and here it offsets a + // pointer with no test in sight. + fn parent_ptr(&self, e: &Entry) -> *const Entry { + unsafe { self.entries.as_ptr().add(e.parent as usize) } + } + + // Fine: calls the helper that tests `parent`. + fn parent_of(&self, e: &Entry) -> Option<&Entry> { + if self.is_root(e) { + return None; + } + Some(&self.entries[e.parent as usize]) + } + + // Fine: only ever called from a function that tested `parent` first. + fn parent_unchecked(&self, e: &Entry) -> &Entry { + &self.entries[e.parent as usize] + } + + fn grandparent(&self, e: &Entry) -> Option<&Entry> { + if e.parent != u32::MAX { + self.parent_of(self.parent_unchecked(e)) + } else { + None + } + } + + fn forget_depth(&mut self, i: usize) { + self.entries[i].depth = -1; + } + + fn depth_known(&self, e: &Entry) -> bool { + e.depth != -1 + } + + fn depth_or_zero(&self, e: &Entry) -> i32 { + if self.depth_known(e) { e.depth } else { 0 } + } + + // Flagged: `depth` is `-1` when unknown, and the sum indexes. + fn below(&self, e: &Entry) -> &Entry { + &self.entries[(e.depth + 1) as usize] + } + + // Fine: arithmetic alone meets no memory. + fn deeper(&self, e: &Entry) -> i32 { + e.depth + 1 + } + + // Fine: the clamp is the author deciding what an out-of-range value does. + fn below_clamped(&self, e: &Entry) -> &Entry { + let i = (e.depth + 1).max(0) as usize; + &self.entries[i] + } + + // Fine: `width` is set to `MAX` to mean unbounded and only ever ordered + // against, never tested for equality: a bound, not a missing value. + fn unbound(&mut self, i: usize) { + self.entries[i].width = u32::MAX; + } + + fn slack(&self, e: &Entry, used: u32) -> u32 { + e.width - used + } + + // Fine: `.get` answers for the sentinel itself. + fn ranked(&self, e: &Entry) -> Option<&&'static str> { + if e.rank == u32::MAX { + return None; + } + self.names.get(e.rank as usize) + } + + fn ranked_loose(&self, e: &Entry) -> Option<&&'static str> { + self.names.get(e.rank as usize) + } +} + +fn main() { + let mut t = Table { + names: vec!["a", "b"], + entries: vec![Entry { + slot: 0, + parent: INVALID_SLOT, + depth: 0, + width: 1, + rank: 0, + }], + }; + let e = Entry { + slot: 1, + parent: 0, + depth: -1, + width: u32::MAX, + rank: u32::MAX, + }; + let _ = t.name(&e); + t.rename(&e, "c"); + let _ = t.name_or_empty(&e); + t.detach(0); + let _ = t.parent_ptr(&e); + let _ = t.parent_of(&e).is_some(); + let _ = t.grandparent(&e).is_some(); + t.forget_depth(0); + let _ = t.depth_or_zero(&e); + let _ = t.below(&t.entries[0]).slot + t.below_clamped(&e).slot; + let _ = t.deeper(&e); + t.unbound(0); + let _ = t.slack(&e, 0); + let _ = t.ranked(&e); + let _ = t.ranked_loose(&e); +} diff --git a/ui/sentinel_int.stderr b/ui/sentinel_int.stderr new file mode 100644 index 0000000..c8c3d99 --- /dev/null +++ b/ui/sentinel_int.stderr @@ -0,0 +1,27 @@ +warning: sentinel `INVALID_SLOT` can reach this use: `rename` indexes with `Entry.slot` and nothing in it tests the field, which the crate compares against that sentinel at 1 other site + --> $DIR/sentinel_int.rs:31:9 + | +LL | self.names[e.slot as usize] = to; + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case + = note: `#[warn(sentinel_int)]` on by default + +warning: sentinel `u32::MAX` can reach this use: `parent_ptr` offsets a pointer by `Entry.parent` and nothing in it tests the field, which the crate compares against that sentinel at 2 other sites + --> $DIR/sentinel_int.rs:54:18 + | +LL | unsafe { self.entries.as_ptr().add(e.parent as usize) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case + +warning: sentinel `-1` can reach this use: `below` indexes with `Entry.depth` and nothing in it tests the field, which the crate compares against that sentinel at 1 other site + --> $DIR/sentinel_int.rs:92:10 + | +LL | &self.entries[(e.depth + 1) as usize] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case + +warning: 3 warnings emitted + From 7caf758300faf30d6fcecbe31b4bde7b59ecec31 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:23:13 -0700 Subject: [PATCH 13/34] add stringly_state --- README.md | 1 + src/lib.rs | 9 +- src/stringly_state.rs | 456 +++++++++++++++++++++++++++++++++++++++ ui/stringly_state.rs | 261 ++++++++++++++++++++++ ui/stringly_state.stderr | 51 +++++ 5 files changed, 775 insertions(+), 3 deletions(-) create mode 100644 src/stringly_state.rs create mode 100644 ui/stringly_state.rs create mode 100644 ui/stringly_state.stderr diff --git a/README.md b/README.md index dcaa90c..36bb9ce 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `parallel_vecs` | sequence fields of one struct that only change length side by side and are read at one index: element `i` of each is one record, so the type lets the lengths differ | | `bool_beside_option` | a bool field written only beside an `Option` field, `true` with `Some(..)` and `false` with `None`: it is that field's `is_some()` stored twice, kept equal only by habit | | `sentinel_int` | an integer field one function tests against `MAX`, `-1` or an `INVALID` constant and another indexes with or offsets a pointer by untested: `Option` spelled as an int | +| `stringly_state` | a string field or local only ever storing one of a closed set of literals and then compared against them: an undeclared enum, so a misspelt state still compiles | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/lib.rs b/src/lib.rs index e8ecedf..7cf1e0b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -56,6 +56,7 @@ mod stale_safety_comment; mod stored_projection; mod stringified_error; mod stringly_error; +mod stringly_state; mod unchecked_input_len; mod uneven_narrowing; mod unit_mismatch; @@ -177,9 +178,10 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto stale_across_reentry::StaleAcrossReentry, stale_panic_message::StalePanicMessage, stale_safety_comment::StaleSafetyComment, stored_projection::StoredProjection, stringified_error::StringifiedError, stringly_error::StringlyError, - unchecked_input_len::UncheckedInputLen, uneven_narrowing::UnevenNarrowing, - unit_mismatch::UnitMismatch, unread_error_variant::UnreadErrorVariant, - unread_none::UnreadNone, wildcard_local_enum::WildcardLocalEnum, + stringly_state::StringlyState, unchecked_input_len::UncheckedInputLen, + uneven_narrowing::UnevenNarrowing, unit_mismatch::UnitMismatch, + unread_error_variant::UnreadErrorVariant, unread_none::UnreadNone, + wildcard_local_enum::WildcardLocalEnum, }; dylint_linting::init_config(sess); let config: MordantConfig = dylint_linting::config_or_default(env!("CARGO_PKG_NAME")); @@ -229,6 +231,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, ParallelVecs::default); add(s, true, bool_beside_option::BoolBesideOption::default); add(s, true, SentinelInt::default); + add(s, true, StringlyState::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/src/stringly_state.rs b/src/stringly_state.rs new file mode 100644 index 0000000..51fb0c3 --- /dev/null +++ b/src/stringly_state.rs @@ -0,0 +1,456 @@ +use std::collections::{BTreeSet, HashMap}; + +use clippy_utils::last_path_segment; +use rustc_ast::LitKind; +use rustc_hir::def::Res; +use rustc_hir::def_id::DefId; +use rustc_hir::{ + Arm, BinOpKind, BindingMode, BorrowKind, ByRef, Expr, ExprKind, HirId, LetStmt, MatchSource, + Mutability, Pat, PatExpr, PatExprKind, PatKind, QPath, StructTailExpr, +}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::adjustment::{Adjust, AutoBorrow, AutoBorrowMutability}; +use rustc_middle::ty::{self, Ty}; +use rustc_span::{Span, Symbol, sym}; + +use crate::adt_facts::{field_ty, has_fixed_repr, has_positional_fields, struct_field}; +use crate::baseline::emit; +use crate::hir_shapes::{Callee, assigned_field, callee_of, peel_blocks_unsafe}; + +rustc_session::declare_lint! { + /// Flags a string or byte-string field or local whose every value the + /// code ever stores is one of a fixed set of literals, and which is then + /// compared against literals to decide what to do. The set is an enum + /// the type does not declare: a misspelt literal on either side compiles, + /// a new state needs every comparison found by hand, and nothing says + /// which strings are possible. + /// + /// Only fires where every store is visible: a local, or a field no other + /// crate can name (the struct or the field is private to this one). Every + /// store must write a literal (or an `if`/`match` choosing between + /// literals), and at least two distinct literals must occur. A single + /// non-literal store, a `..base` construction, a `&mut` borrow, an + /// explicit `repr`, or a value that is only ever formatted or written out + /// and never compared keeps it silent. + pub STRINGLY_STATE, + Warn, + "a string only ever holding one of a closed set of literals, then compared against them" +} + +/// A place whose every store this crate can see. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +enum Slot { + Field(DefId, Symbol), + Local(HirId), +} + +#[derive(Default)] +struct Facts { + /// A store the lint cannot read as a literal, or a mutable borrow. + open: bool, + stores: usize, + values: BTreeSet, + compared: bool, +} + +#[derive(Default)] +pub struct StringlyState { + slots: HashMap, + /// Where and under what name to report a local. + locals: HashMap, +} + +rustc_session::impl_lint_pass!(StringlyState => [STRINGLY_STATE]); + +/// `&str`, `String`, `Box`, `&[u8]`, `Box<[u8]>`, `Vec`. +fn is_stringy(cx: &LateContext<'_>, ty: Ty<'_>) -> bool { + fn is_text(ty: Ty<'_>) -> bool { + match ty.kind() { + ty::Str => true, + ty::Slice(elem) => matches!(elem.kind(), ty::Uint(ty::UintTy::U8)), + _ => false, + } + } + match ty.kind() { + ty::Ref(_, inner, _) => is_text(*inner), + ty::Adt(adt, args) => { + if cx.tcx.is_lang_item(adt.did(), rustc_hir::LangItem::String) { + true + } else if adt.is_box() { + is_text(args.type_at(0)) + } else if cx.tcx.is_diagnostic_item(sym::Vec, adt.did()) { + matches!(args.type_at(0).kind(), ty::Uint(ty::UintTy::U8)) + } else { + false + } + } + _ => false, + } +} + +/// The field this lint tracks behind `ty`.`name`, if any: a string field of +/// a struct this crate defines that no other crate can name, whether because +/// the struct is private or the field is. Either way every store to it is in +/// this crate. +fn tracked_field<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>, name: Symbol) -> Option { + let ty::Adt(adt, _) = ty.peel_refs().kind() else { + return None; + }; + if !adt.is_struct() || !adt.did().is_local() { + return None; + } + if has_fixed_repr(*adt) || has_positional_fields(adt.non_enum_variant()) { + return None; + } + let f = struct_field(*adt, name)?; + if cx.effective_visibilities.is_exported(f.did.expect_local()) { + return None; + } + is_stringy(cx, field_ty(cx, f)).then_some(Slot::Field(adt.did(), name)) +} + +fn local_of(cx: &LateContext<'_>, e: &Expr<'_>) -> Option { + if let ExprKind::Path(QPath::Resolved(None, path)) = e.kind + && let Res::Local(id) = path.res + && is_stringy(cx, cx.typeck_results().node_type(id)) + { + Some(id) + } else { + None + } +} + +fn lit_text(lit: &LitKind) -> Option { + match lit { + LitKind::Str(s, _) => Some(format!("{:?}", s.as_str())), + LitKind::ByteStr(b, _) => Some(format!("\"{}\"", b.as_byte_str().escape_ascii())), + _ => None, + } +} + +/// The literals a stored expression spells, through the conversions that +/// turn a literal into an owned string without changing its text and through +/// an `if` or `match` each of whose arms spells one. False when any part is +/// something else. +fn stored_literals(e: &Expr<'_>, out: &mut BTreeSet) -> bool { + let e = peel_blocks_unsafe(e); + match e.kind { + ExprKind::Lit(lit) => match lit_text(&lit.node) { + Some(text) => { + out.insert(text); + true + } + None => false, + }, + ExprKind::AddrOf(BorrowKind::Ref, Mutability::Not, inner) + | ExprKind::DropTemps(inner) + | ExprKind::Cast(inner, _) => stored_literals(inner, out), + ExprKind::MethodCall(seg, recv, [], _) + if matches!( + seg.ident.name.as_str(), + "to_string" + | "to_owned" + | "into" + | "to_vec" + | "as_bytes" + | "into_bytes" + | "into_boxed_str" + | "into_boxed_slice" + | "clone" + ) => + { + stored_literals(recv, out) + } + ExprKind::Call(callee, [arg]) + if matches!(callee.kind, ExprKind::Path(ref qp) + if last_path_segment(qp).ident.name == sym::from) => + { + stored_literals(arg, out) + } + ExprKind::If(_, then, Some(els)) => stored_literals(then, out) && stored_literals(els, out), + ExprKind::Match(_, arms, _) if !arms.is_empty() => { + arms.iter().all(|a| stored_literals(a.body, out)) + } + _ => false, + } +} + +fn is_literal_expr(e: &Expr<'_>) -> bool { + let e = peel_blocks_unsafe(e); + match e.kind { + ExprKind::Lit(lit) => lit_text(&lit.node).is_some(), + ExprKind::AddrOf(BorrowKind::Ref, Mutability::Not, inner) | ExprKind::DropTemps(inner) => { + is_literal_expr(inner) + } + _ => false, + } +} + +fn pat_has_literal(pat: &Pat<'_>) -> bool { + match pat.kind { + PatKind::Expr(PatExpr { + kind: PatExprKind::Lit { lit, .. }, + .. + }) => lit_text(&lit.node).is_some(), + PatKind::Or(pats) => pats.iter().any(|p| pat_has_literal(p)), + PatKind::Ref(inner, ..) | PatKind::Box(inner) | PatKind::Deref(inner) => { + pat_has_literal(inner) + } + _ => false, + } +} + +/// The tracked place `e` reads, through the views that leave its text as it +/// is: `&`, `*`, `x.as_str()`, `x.as_bytes()`, `&x[..]`. +fn read_slot<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option { + let e = peel_blocks_unsafe(e); + match e.kind { + ExprKind::Field(base, ident) => { + let ty = cx.typeck_results().expr_ty_adjusted(base); + tracked_field(cx, ty, ident.name) + } + ExprKind::Path(_) => local_of(cx, e).map(Slot::Local), + ExprKind::AddrOf(BorrowKind::Ref, Mutability::Not, inner) + | ExprKind::Unary(rustc_hir::UnOp::Deref, inner) + | ExprKind::DropTemps(inner) => read_slot(cx, inner), + ExprKind::Index(inner, idx, _) + if matches!( + peel_blocks_unsafe(idx).kind, + ExprKind::Struct(_, [], StructTailExpr::None) + ) => + { + read_slot(cx, inner) + } + ExprKind::MethodCall(seg, recv, [], _) + if matches!( + seg.ident.name.as_str(), + "as_str" | "as_bytes" | "as_ref" | "as_slice" | "as_deref" | "deref" | "borrow" + ) => + { + read_slot(cx, recv) + } + _ => None, + } +} + +/// The place an assignment target or a `&mut` operand names directly. +fn written_slot<'tcx>(cx: &LateContext<'tcx>, place: &'tcx Expr<'tcx>) -> Option { + if let Some((base, ident, _)) = assigned_field(place) { + // The adjusted type, so a write through a `Box`, a guard or any + // other `Deref` container reaches the struct behind it. + return tracked_field(cx, cx.typeck_results().expr_ty_adjusted(base), ident.name); + } + local_of(cx, peel_blocks_unsafe(place)).map(Slot::Local) +} + +impl StringlyState { + fn facts(&mut self, slot: Slot) -> &mut Facts { + self.slots.entry(slot).or_default() + } + + fn store(&mut self, slot: Slot, value: &Expr<'_>) { + let facts = self.facts(slot); + if stored_literals(value, &mut facts.values) { + facts.stores += 1; + } else { + facts.open = true; + } + } + + /// `a == "lit"`, `a != b"lit"`, either way round. + fn note_comparison<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + l: &'tcx Expr<'tcx>, + r: &'tcx Expr<'tcx>, + ) { + for (place, other) in [(l, r), (r, l)] { + if is_literal_expr(other) + && let Some(slot) = read_slot(cx, place) + { + self.facts(slot).compared = true; + } + } + } + + /// `eql(x.f, b"lit")`, `x.eq_ignore_ascii_case("lit")` and kin: a call + /// whose name says it compares, with the place and a literal among its + /// operands. + fn note_comparing_call<'tcx>(&mut self, cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) { + let Some(callee) = callee_of(cx, e) else { + return; + }; + let Some(name) = cx.tcx.opt_item_name(callee.def()) else { + return; + }; + let name = name.as_str(); + let compares = name.starts_with("eql") + || name.starts_with("eq_ignore_ascii_case") + || name == "eq" + || name == "ne" + || name == "starts_with" + || name == "ends_with" + || name.starts_with("has_prefix") + || name.starts_with("has_suffix"); + if !compares { + return; + } + let operands: Vec<&'tcx Expr<'tcx>> = match callee { + Callee::Path { args, .. } => args.iter().collect(), + Callee::Method { recv, args, .. } => std::iter::once(recv).chain(args.iter()).collect(), + }; + if !operands.iter().any(|o| is_literal_expr(o)) { + return; + } + for o in operands { + if let Some(slot) = read_slot(cx, o) { + self.facts(slot).compared = true; + } + } + } + + fn note_match<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + scrut: &'tcx Expr<'tcx>, + arms: &'tcx [Arm<'tcx>], + ) { + if let Some(slot) = read_slot(cx, scrut) + && arms.iter().any(|a| pat_has_literal(a.pat)) + { + self.facts(slot).compared = true; + } + } + + fn message(&self, cx: &LateContext<'_>, slot: Slot, facts: &Facts) -> Option<(Span, String)> { + let (span, name, scope) = match slot { + Slot::Field(did, name) => { + let field = struct_field(cx.tcx.adt_def(did), name)?; + (cx.tcx.def_span(field.did), name, " across the crate") + } + Slot::Local(id) => { + let &(span, name) = self.locals.get(&id)?; + (span, name, "") + } + }; + const SHOWN: usize = 6; + let mut list: Vec = facts + .values + .iter() + .take(SHOWN) + .map(|v| format!("`{v}`")) + .collect(); + let more = facts.values.len().saturating_sub(SHOWN); + if more > 0 { + list.push(format!("{more} more")); + } + Some(( + span, + format!( + "every value stored in `{}` is one of {} ({} {}{}), and it is read by comparing \ + against literals", + name, + list.join(", "), + facts.stores, + if facts.stores == 1 { "store" } else { "stores" }, + scope, + ), + )) + } +} + +impl<'tcx> LateLintPass<'tcx> for StringlyState { + fn check_local(&mut self, cx: &LateContext<'tcx>, l: &'tcx LetStmt<'tcx>) { + let PatKind::Binding(BindingMode(ByRef::No, _), id, ident, None) = l.pat.kind else { + return; + }; + if l.span.from_expansion() || !is_stringy(cx, cx.typeck_results().node_type(id)) { + return; + } + self.locals.insert(id, (l.pat.span, ident.name)); + if let Some(init) = l.init { + self.store(Slot::Local(id), init); + } + } + + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + match expr.kind { + ExprKind::Struct(_, fields, tail) => { + let ty = cx.typeck_results().expr_ty(expr); + let Some(adt) = ty.ty_adt_def().filter(|a| a.is_struct()) else { + return; + }; + for f in fields { + if let Some(slot) = tracked_field(cx, ty, f.ident.name) { + self.store(slot, f.expr); + } + } + if matches!(tail, StructTailExpr::None) { + return; + } + // `..base` fills every unlisted field from a value this site + // does not spell. + for f in &adt.non_enum_variant().fields { + if !fields.iter().any(|l| l.ident.name == f.name) + && let Some(slot) = tracked_field(cx, ty, f.name) + { + self.facts(slot).open = true; + } + } + } + ExprKind::Assign(place, value, _) => { + if let Some(slot) = written_slot(cx, place) { + self.store(slot, value); + } + } + ExprKind::AssignOp(_, place, _) + | ExprKind::AddrOf(BorrowKind::Ref | BorrowKind::Raw, Mutability::Mut, place) => { + if let Some(slot) = written_slot(cx, place) { + self.facts(slot).open = true; + } + } + // The auto-`&mut` a mutating method call takes: the place is + // written through something this lint does not read. + ExprKind::Field(..) | ExprKind::Path(..) => { + let mutably_borrowed = cx.typeck_results().expr_adjustments(expr).iter().any(|a| { + matches!( + a.kind, + Adjust::Borrow(AutoBorrow::Ref(AutoBorrowMutability::Mut { .. })) + | Adjust::Borrow(AutoBorrow::RawPtr(Mutability::Mut)) + ) + }); + if mutably_borrowed && let Some(slot) = written_slot(cx, expr) { + self.facts(slot).open = true; + } + } + ExprKind::Binary(op, l, r) if matches!(op.node, BinOpKind::Eq | BinOpKind::Ne) => { + self.note_comparison(cx, l, r); + } + ExprKind::Match(scrut, arms, MatchSource::Normal | MatchSource::Postfix) => { + self.note_match(cx, scrut, arms); + } + ExprKind::Call(..) | ExprKind::MethodCall(..) => self.note_comparing_call(cx, expr), + _ => {} + } + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let mut findings: Vec<(Span, String)> = self + .slots + .iter() + .filter(|(_, f)| !f.open && f.compared && f.values.len() >= 2) + .filter_map(|(slot, f)| self.message(cx, *slot, f)) + .collect(); + findings.sort_by_key(|(span, _)| span.lo()); + for (span, msg) in findings { + emit( + cx, + STRINGLY_STATE, + span, + msg, + "these strings are the variants of an enum; store the enum and keep the text in an \ + `as_str` method, so a misspelt state is a compile error", + ); + } + } +} diff --git a/ui/stringly_state.rs b/ui/stringly_state.rs new file mode 100644 index 0000000..0b06d13 --- /dev/null +++ b/ui/stringly_state.rs @@ -0,0 +1,261 @@ +// A string field or local only ever holding one of a closed set of literals, then compared against them. + +// Flagged: every store of `kind` is one of two literals and readers compare it. +struct Task { + kind: &'static str, + id: u32, +} + +fn download(id: u32) -> Task { + Task { kind: "download", id } +} + +fn extract(id: u32) -> Task { + let mut t = Task { kind: "download", id }; + t.kind = "extract"; + t +} + +fn run(t: &Task) -> u32 { + if t.kind == "download" { t.id } else { 0 } +} + +// Flagged: owned bytes built from literals, matched on with literal arms. +struct Phase { + name: Vec, +} + +fn phases() -> [Phase; 2] { + [ + Phase { + name: b"resolve".to_vec(), + }, + Phase { + name: b"link".to_vec(), + }, + ] +} + +fn is_link(p: &Phase) -> bool { + match p.name.as_slice() { + b"link" => true, + _ => false, + } +} + +// Flagged: a `String` compared through a comparing method. +struct Mode { + label: String, +} + +fn modes(fast: bool) -> Mode { + if fast { + Mode { + label: String::from("fast"), + } + } else { + Mode { + label: "slow".to_string(), + } + } +} + +fn is_fast(m: &Mode) -> bool { + m.label.eq_ignore_ascii_case("FAST") +} + +// Flagged: the struct is public but the field is not, so every store is still in this crate. +pub struct Conn { + scheme: &'static [u8], + pub port: u16, +} + +pub fn plain() -> Conn { + Conn { + scheme: b"ws", + port: 80, + } +} + +pub fn secure() -> Conn { + Conn { + scheme: b"wss", + port: 443, + } +} + +pub fn default_port(c: &Conn) -> bool { + matches!(c.scheme, b"wss") == (c.port == 443) +} + +// Flagged: a local assigned literals on every path and then only compared. +fn severity(major: bool, minor: bool) -> u8 { + let mut color = "green"; + if major { + color = "red"; + } else if minor { + color = "yellow"; + } + if color == "red" { + 2 + } else if color == "yellow" { + 1 + } else { + 0 + } +} + +// Flagged: one store, but it chooses between two literals. +fn tier(n: u32) -> bool { + let level = if n > 9 { "hi" } else { "lo" }; + matches!(level, "hi") +} + +// Fine: the local is only formatted. +fn banner(wide: bool) -> String { + let rule = if wide { "====" } else { "--" }; + format!("{rule} title {rule}") +} + +// Fine: one store comes from a call, so the set is open. +fn from_env(f: fn() -> &'static str) -> bool { + let mut mode = "auto"; + if f().is_empty() { + mode = f(); + } + mode == "auto" +} + +// Fine: mutated in place through a method taking `&mut self`. +fn grown() -> bool { + let mut s = String::from("a"); + s.push('b'); + s = "c".to_string(); + s == "ab" +} + +// Fine: one store is not a literal, so the set is open. +struct Named { + name: &'static str, +} + +fn named(n: &'static str) -> [Named; 3] { + [Named { name: "a" }, Named { name: "b" }, Named { name: n }] +} + +fn is_a(n: &Named) -> bool { + n.name == "a" +} + +// Fine: only ever formatted, never compared. +struct Label { + text: &'static str, +} + +fn labels() -> [Label; 2] { + [Label { text: "one" }, Label { text: "two" }] +} + +fn show(l: &Label) -> String { + format!("{}", l.text) +} + +// Fine: a single literal is a constant, not a state. +struct Fixed { + tag: &'static str, +} + +fn fixed() -> [Fixed; 2] { + [Fixed { tag: "x" }, Fixed { tag: "x" }] +} + +fn is_x(f: &Fixed) -> bool { + f.tag == "x" +} + +// Fine: the field is mutated in place, so the literals are only seeds. +struct Buf { + text: String, +} + +fn bufs() -> [Buf; 2] { + [ + Buf { + text: "a".to_owned(), + }, + Buf { + text: "b".to_owned(), + }, + ] +} + +fn grow(b: &mut Buf) -> bool { + b.text.push('!'); + b.text == "a!" +} + +// Fine: `..base` fills the field from somewhere this site does not spell. +#[derive(Clone)] +struct Opts { + level: &'static str, + n: u32, +} + +fn opts(base: &Opts) -> [Opts; 3] { + [ + Opts { level: "hi", n: 0 }, + Opts { level: "lo", n: 1 }, + Opts { + n: 2, + ..base.clone() + }, + ] +} + +fn is_hi(o: &Opts) -> bool { + o.level == "hi" +} + +// Fine: exported, so other crates may store anything. +pub struct Public { + pub state: &'static str, +} + +pub fn publics() -> [Public; 2] { + [Public { state: "on" }, Public { state: "off" }] +} + +pub fn is_on(p: &Public) -> bool { + p.state == "on" +} + +// Fine: an explicit repr means the layout is fixed from outside. +#[repr(C)] +struct Wire { + op: &'static str, +} + +fn wires() -> [Wire; 2] { + [Wire { op: "get" }, Wire { op: "set" }] +} + +fn is_get(w: &Wire) -> bool { + w.op == "get" +} + +fn main() { + let _ = run(&download(1)) + run(&extract(2)); + let _ = phases().iter().any(is_link); + let _ = is_fast(&modes(true)); + let _ = severity(true, false) + tier(3) as u8; + let _ = banner(true); + let _ = from_env(|| "x") || grown(); + let _ = named("c").iter().any(is_a); + let _ = labels().iter().map(show).count(); + let _ = fixed().iter().any(is_x); + let _ = bufs().iter_mut().any(grow); + let base = Opts { level: "mid", n: 9 }; + let _ = opts(&base).iter().any(is_hi); + let _ = publics().iter().any(is_on); + let _ = wires().iter().any(is_get); +} diff --git a/ui/stringly_state.stderr b/ui/stringly_state.stderr new file mode 100644 index 0000000..ace8dd7 --- /dev/null +++ b/ui/stringly_state.stderr @@ -0,0 +1,51 @@ +warning: every value stored in `kind` is one of `"download"`, `"extract"` (3 stores across the crate), and it is read by comparing against literals + --> $DIR/stringly_state.rs:5:5 + | +LL | kind: &'static str, + | ^^^^^^^^^^^^^^^^^^ + | + = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error + = note: `#[warn(stringly_state)]` on by default + +warning: every value stored in `name` is one of `"link"`, `"resolve"` (2 stores across the crate), and it is read by comparing against literals + --> $DIR/stringly_state.rs:25:5 + | +LL | name: Vec, + | ^^^^^^^^^^^^^ + | + = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error + +warning: every value stored in `label` is one of `"fast"`, `"slow"` (2 stores across the crate), and it is read by comparing against literals + --> $DIR/stringly_state.rs:48:5 + | +LL | label: String, + | ^^^^^^^^^^^^^ + | + = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error + +warning: every value stored in `scheme` is one of `"ws"`, `"wss"` (2 stores across the crate), and it is read by comparing against literals + --> $DIR/stringly_state.rs:69:5 + | +LL | scheme: &'static [u8], + | ^^^^^^^^^^^^^^^^^^^^^ + | + = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error + +warning: every value stored in `color` is one of `"green"`, `"red"`, `"yellow"` (3 stores), and it is read by comparing against literals + --> $DIR/stringly_state.rs:93:9 + | +LL | let mut color = "green"; + | ^^^^^^^^^ + | + = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error + +warning: every value stored in `level` is one of `"hi"`, `"lo"` (1 store), and it is read by comparing against literals + --> $DIR/stringly_state.rs:110:9 + | +LL | let level = if n > 9 { "hi" } else { "lo" }; + | ^^^^^ + | + = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error + +warning: 6 warnings emitted + From e5ba762b4aab8ec00a56273972dc0d32b3d86de6 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:25:05 -0700 Subject: [PATCH 14/34] add parallel_params --- README.md | 14 +- src/lib.rs | 18 +- src/parallel_params.rs | 405 ++++++++++++++++++++++++++++++++++++++ ui/parallel_params.rs | 158 +++++++++++++++ ui/parallel_params.stderr | 19 ++ ui_off/parallel_params.rs | 24 +++ 6 files changed, 632 insertions(+), 6 deletions(-) create mode 100644 src/parallel_params.rs create mode 100644 ui/parallel_params.rs create mode 100644 ui/parallel_params.stderr create mode 100644 ui_off/parallel_params.rs diff --git a/README.md b/README.md index 36bb9ce..e548e9a 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `bool_beside_option` | a bool field written only beside an `Option` field, `true` with `Some(..)` and `false` with `None`: it is that field's `is_some()` stored twice, kept equal only by habit | | `sentinel_int` | an integer field one function tests against `MAX`, `-1` or an `INVALID` constant and another indexes with or offsets a pointer by untested: `Option` spelled as an int | | `stringly_state` | a string field or local only ever storing one of a closed set of literals and then compared against them: an undeclared enum, so a misspelt state still compiles | +| `parallel_params` | opt-in via `parallel-params-enabled`: parameters several functions declare alike and hand each other unchanged in one call: one value with no type, passable by halves | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. @@ -95,18 +96,21 @@ exclusive-options-min-fields = 2 flag-cluster-min-bools = 3 stored-projection-min-sites = 2 reimplemented-helper-min-nodes = 12 +parallel-params-min-fns = 3 # Opt-in: also count `Box` as a stringly error type. stringly-error-include-box-dyn = true -# Opt-in: `flag_cluster`, `stale_safety_comment` and `unchecked_input_len` are -# surveys to run once over a codebase (most of what they name is legitimate -# once the real cases are fixed; for the last, a length the caller vouches for -# that the function also uses as some other value's limit), so they are off -# until turned on here. +# Opt-in: `flag_cluster`, `stale_safety_comment`, `unchecked_input_len` and +# `parallel_params` are surveys to run once over a codebase (most of what they +# name is legitimate once the real cases are fixed; for the third, a length the +# caller vouches for that the function also uses as some other value's limit; +# for the last, a context and the position it reports at, passed along together +# by design), so they are off until turned on here. flag-cluster-enabled = true stale-safety-comment-enabled = true unchecked-input-len-enabled = true +parallel-params-enabled = true # Opt-in: flag composite keys (tuples, structs one level deep) that carry a # denied type unless one of the fixing types sits beside it. With these two diff --git a/src/lib.rs b/src/lib.rs index 7cf1e0b..8ce5593 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -46,6 +46,7 @@ mod narrowed_return; mod nonidentity_key; mod overwide_parameter; mod parallel_bools; +mod parallel_params; mod parallel_vecs; mod reimplemented_helper; mod same_match_twice; @@ -159,6 +160,15 @@ pub struct MordantConfig { /// which nothing inside the function tells from a missed check; run it /// once over parsing code and read the list. pub unchecked_input_len_enabled: bool, + /// Opt-in: run `parallel_params`. Off by default because a context and + /// the position it reports at, or a pointer and its length before a slice + /// exists, pass between functions together by design, and nothing in the + /// signatures tells those from a value nobody declared; run it once and + /// read the list. + pub parallel_params_enabled: bool, + /// Functions a parameter group must pass between, unchanged, before + /// `parallel_params` names it. + pub parallel_params_min_fns: usize = 3, } #[expect(clippy::no_mangle_with_rust_abi)] @@ -174,7 +184,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto insert_then_unwrap::InsertThenUnwrap, lock_order::LockOrder, misbound_arg::MisboundArg, narrowed_return::NarrowedReturn, nonidentity_key::NonidentityKey, overwide_parameter::OverwideParameter, parallel_bools::ParallelBools, - parallel_vecs::ParallelVecs, sentinel_int::SentinelInt, + parallel_params::ParallelParams, parallel_vecs::ParallelVecs, sentinel_int::SentinelInt, stale_across_reentry::StaleAcrossReentry, stale_panic_message::StalePanicMessage, stale_safety_comment::StaleSafetyComment, stored_projection::StoredProjection, stringified_error::StringifiedError, stringly_error::StringlyError, @@ -232,6 +242,9 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, bool_beside_option::BoolBesideOption::default); add(s, true, SentinelInt::default); add(s, true, StringlyState::default); + add(s, config.parallel_params_enabled, move || { + ParallelParams::new(config) + }); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } @@ -266,6 +279,7 @@ fn ui() { flag-cluster-enabled = true stale-safety-comment-enabled = true unchecked-input-len-enabled = true + parallel-params-enabled = true [[mordant.forbidden-reach]] from = "hot_path" @@ -307,6 +321,8 @@ fn config_default_thresholds_match_docs() { assert!(!c.flag_cluster_enabled); assert!(!c.stale_safety_comment_enabled); assert!(!c.unchecked_input_len_enabled); + assert!(!c.parallel_params_enabled); + assert_eq!(c.parallel_params_min_fns, 3); } /// An empty table (file present, keys omitted) must not drift from diff --git a/src/parallel_params.rs b/src/parallel_params.rs new file mode 100644 index 0000000..64757e2 --- /dev/null +++ b/src/parallel_params.rs @@ -0,0 +1,405 @@ +use std::collections::{HashMap, HashSet}; + +use clippy_utils::res::MaybeResPath; +use clippy_utils::source::snippet_opt; +use clippy_utils::visitors::for_each_expr; +use rustc_abi::ExternAbi; +use rustc_hir::def::{DefKind, Res}; +use rustc_hir::def_id::DefId; +use rustc_hir::intravisit::FnKind; +use rustc_hir::{Body, Expr, ExprKind, FnDecl, HirId, Impl, ItemKind, Node, PatKind, UnOp}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::print::with_no_trimmed_paths; +use rustc_span::def_id::LocalDefId; +use rustc_span::symbol::kw; +use rustc_span::{Span, Symbol}; + +use crate::MordantConfig; +use crate::baseline::emit; +use crate::hir_shapes::{Callee, callee_of}; + +rustc_session::declare_lint! { + /// Flags two or more parameters that `parallel-params-min-fns` or more + /// crate-private functions declare under the same names and types and + /// pass among themselves: every counted function hands the group, + /// unchanged and in one call, to another of them, or receives it that + /// way. The group arrives together, is checked together and leaves + /// together: it is one value, and the only place it has no name is the + /// type system, so nothing keeps a caller from passing half of it, or two + /// halves of different wholes. + /// + /// Stays quiet on exported functions, trait methods and their impls, + /// non-Rust ABIs, `#[no_mangle]` items and any function also used as a + /// value (a fn pointer's signature is fixed by its type), on `self`, on + /// `_`-prefixed parameters, on functions that merely declare the same + /// pair without handing it on, and whenever the callee renames or retypes + /// what it receives — that call is a translation, not a hand-off. + /// + /// Runs only with `parallel-params-enabled = true` in `dylint.toml`: a + /// context and the position it reports at, or a pointer and its length + /// before a slice exists, travel together by design, and nothing in the + /// signatures tells those from an undeclared struct. + pub PARALLEL_PARAMS, + Warn, + "parameters that several functions declare and forward as a group" +} + +struct Param { + name: Symbol, + /// The region-erased type, printed untrimmed: the identity two + /// signatures are compared by. + ty: String, + /// The type as written in this signature, for the message. + written: String, + hir_id: HirId, + span: Span, +} + +struct FnFacts { + /// By body param index; `None` for `self`, `_x` and destructuring + /// patterns, so call arguments still line up with indices. + params: Vec>, + span: Span, +} + +/// One call in `from`'s body passing two or more of `from`'s own params +/// bare to `to`, as (from's param index, to's param index) pairs. +struct Forward { + from: DefId, + to: DefId, + bound: Vec<(usize, usize)>, + span: Span, +} + +/// A parameter's identity across signatures: its name and erased type. +type Slot = (Symbol, String); + +pub struct ParallelParams { + min_fns: usize, + fns: HashMap, + forwards: Vec, + /// Functions referenced other than by direct call: their signature is + /// pinned by the fn-pointer type they were coerced to. + poisoned: HashSet, +} + +rustc_session::impl_lint_pass!(ParallelParams => [PARALLEL_PARAMS]); + +impl ParallelParams { + pub fn new(config: &MordantConfig) -> Self { + Self { + min_fns: config.parallel_params_min_fns, + fns: HashMap::new(), + forwards: Vec::new(), + poisoned: HashSet::new(), + } + } +} + +/// The param local an argument passes on unchanged: `x`, `&x`, `&mut *x`. +fn forwarded_local(mut arg: &Expr<'_>) -> Option { + while let ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(UnOp::Deref, inner) + | ExprKind::DropTemps(inner) = arg.kind + { + arg = inner; + } + arg.res_local_id() +} + +/// A signature the crate is free to change: not exported, not extern, not +/// dictated by a trait. +fn owns_signature(cx: &LateContext<'_>, kind: FnKind<'_>, def_id: LocalDefId) -> bool { + let header = match kind { + FnKind::ItemFn(_, _, header) => header, + FnKind::Method(_, sig) => sig.header, + FnKind::Closure => return false, + }; + if header.abi != ExternAbi::Rust + || cx.effective_visibilities.is_exported(def_id) + || cx.tcx.codegen_fn_attrs(def_id).contains_extern_indicator() + { + return false; + } + let parent = cx + .tcx + .parent_hir_node(cx.tcx.local_def_id_to_hir_id(def_id)); + !matches!( + parent, + Node::Item(item) if matches!( + item.kind, + ItemKind::Impl(Impl { of_trait: Some(_), .. }) | ItemKind::Trait { .. } + ) + ) +} + +impl<'tcx> LateLintPass<'tcx> for ParallelParams { + fn check_fn( + &mut self, + cx: &LateContext<'tcx>, + kind: FnKind<'tcx>, + decl: &'tcx FnDecl<'tcx>, + body: &'tcx Body<'tcx>, + _span: Span, + def_id: LocalDefId, + ) { + if !owns_signature(cx, kind, def_id) { + return; + } + let params: Vec> = body + .params + .iter() + .enumerate() + .map(|(i, param)| { + let PatKind::Binding(_, hir_id, ident, None) = param.pat.kind else { + return None; + }; + if ident.name == kw::SelfLower || ident.as_str().starts_with('_') { + return None; + } + let ty = cx + .tcx + .erase_and_anonymize_regions(cx.typeck_results().pat_ty(param.pat)); + let written = decl + .inputs + .get(i) + .and_then(|t| snippet_opt(cx, t.span)) + .unwrap_or_else(|| with_no_trimmed_paths!(ty.to_string())); + Some(Param { + name: ident.name, + ty: with_no_trimmed_paths!(ty.to_string()), + written, + hir_id, + span: param.span, + }) + }) + .collect(); + if params.iter().flatten().count() < 2 { + return; + } + let from = def_id.to_def_id(); + for_each_expr(cx, body.value, |e: &Expr<'tcx>| { + if let Some(callee) = callee_of(cx, e) + && let to = callee.def() + && to != from + && to.is_local() + && matches!(cx.tcx.def_kind(to), DefKind::Fn | DefKind::AssocFn) + { + // A method's receiver is body param 0, so explicit args start + // at 1 there; `T::m(x, ..)` through a path lines up as is. + let args: Vec<(usize, &Expr<'tcx>)> = match callee { + Callee::Path { args, .. } => args.iter().enumerate().collect(), + Callee::Method { args, .. } => { + args.iter().enumerate().map(|(i, a)| (i + 1, a)).collect() + } + }; + let mut bound: Vec<(usize, usize)> = Vec::new(); + for (to_idx, arg) in args { + if let Some(local) = forwarded_local(arg) + && let Some(from_idx) = params + .iter() + .position(|p| p.as_ref().is_some_and(|p| p.hir_id == local)) + && !bound.iter().any(|(f, _)| *f == from_idx) + { + bound.push((from_idx, to_idx)); + } + } + if bound.len() >= 2 { + self.forwards.push(Forward { + from, + to, + bound, + span: e.span, + }); + } + } + std::ops::ControlFlow::<()>::Continue(()) + }); + self.fns.insert( + from, + FnFacts { + params, + span: cx.tcx.def_span(from), + }, + ); + } + + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + // A bare reference to a local fn (fn pointer, higher-order use). The + // callee position of a direct call is not one. + let ExprKind::Path(qpath) = &expr.kind else { + return; + }; + if matches!( + clippy_utils::get_parent_expr(cx, expr), + Some(Expr { kind: ExprKind::Call(callee, _), .. }) if callee.hir_id == expr.hir_id + ) { + return; + } + if let Res::Def(DefKind::Fn | DefKind::AssocFn, def) = cx.qpath_res(qpath, expr.hir_id) + && def.is_local() + { + self.poisoned.insert(def); + } + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let facts = |def: &DefId| { + if self.poisoned.contains(def) { + None + } else { + self.fns.get(def) + } + }; + let slot = |p: &Param| -> Slot { (p.name, p.ty.clone()) }; + // Unordered slot pair -> the hand-offs that carry both. + let mut links: HashMap<(Slot, Slot), Vec<&Forward>> = HashMap::new(); + for fwd in &self.forwards { + let (Some(from), Some(to)) = (facts(&fwd.from), facts(&fwd.to)) else { + continue; + }; + // Forwarded params the callee declares under the same name and + // type: those travel; anything renamed or converted does not. + let kept: Vec<&Param> = fwd + .bound + .iter() + .filter_map(|&(fi, ti)| { + let p = from.params.get(fi)?.as_ref()?; + let q = to.params.get(ti)?.as_ref()?; + (p.name == q.name && p.ty == q.ty).then_some(p) + }) + .collect(); + for (i, a) in kept.iter().enumerate() { + for b in &kept[i + 1..] { + let (sa, sb) = (slot(a), slot(b)); + let key = if sa <= sb { (sa, sb) } else { (sb, sa) }; + links.entry(key).or_default().push(fwd); + } + } + } + // The functions each linked pair passes between, in source order; + // pairs sharing a function set are one group. + let mut groups: HashMap, (Vec, Vec<&Forward>)> = HashMap::new(); + let mut keys: Vec<&(Slot, Slot)> = links.keys().collect(); + keys.sort(); + for key @ (a, b) in keys { + let mut sig: Vec = Vec::new(); + for def in links[key].iter().flat_map(|f| [f.from, f.to]) { + if !sig.contains(&def) { + sig.push(def); + } + } + if sig.len() < self.min_fns { + continue; + } + sig.sort_by_key(|def| (self.fns[def].span.lo(), def.index)); + let (slots, witnesses) = groups.entry(sig).or_default(); + for s in [a, b] { + if !slots.contains(s) { + slots.push(s.clone()); + } + } + witnesses.extend(links[key].iter().copied()); + } + let mut findings: Vec<(Span, String)> = Vec::new(); + for (sig, (mut slots, mut witnesses)) in groups { + let anchor = &self.fns[&sig[0]]; + // Slots in the anchor's declaration order, printed as written there. + let pos = |s: &Slot| { + anchor + .params + .iter() + .position(|p| p.as_ref().is_some_and(|p| slot(p) == *s)) + }; + slots.sort_by_key(pos); + let shown: Vec = slots + .iter() + .filter_map(|s| { + let p = anchor.params[pos(s)?].as_ref()?; + Some(format!("`{}: {}`", p.name, p.written)) + }) + .collect(); + let Some(first) = slots.first().and_then(pos) else { + continue; + }; + let Some(at) = anchor.params[first].as_ref().map(|p| p.span) else { + continue; + }; + witnesses.sort_by_key(|f| f.span.lo()); + let witness = witnesses[0]; + let names: Vec = sig + .iter() + .map(|def| format!("`{}`", cx.tcx.item_name(*def))) + .collect(); + findings.push(( + at, + format!( + "parameters {} pass unchanged between {} (`{}` hands them to `{}` in one call): one value travelling as {} parameters", + join(&shown), + listed(&names), + cx.tcx.item_name(witness.from), + cx.tcx.item_name(witness.to), + slots.len(), + ), + )); + } + findings.sort_by_key(|(span, _)| span.lo()); + for (span, msg) in findings { + emit( + cx, + PARALLEL_PARAMS, + span, + msg, + "a struct with these fields names the value; each function then takes, checks and forwards one parameter", + ); + } + } +} + +/// `a`, `a and b`, `a, b and c`. +fn join(items: &[String]) -> String { + match items { + [] => String::new(), + [one] => one.clone(), + [head @ .., last] => format!("{} and {last}", head.join(", ")), + } +} + +/// Up to four names, then a count of the rest. +fn listed(names: &[String]) -> String { + const SHOWN: usize = 4; + if names.len() <= SHOWN { + join(names) + } else { + format!( + "{} and {} more functions", + names[..SHOWN].join(", "), + names.len() - SHOWN + ) + } +} + +#[cfg(test)] +mod tests { + use super::{join, listed}; + + fn owned(xs: &[&str]) -> Vec { + xs.iter().map(|s| (*s).to_owned()).collect() + } + + #[test] + fn join_reads_as_prose() { + assert_eq!(join(&owned(&["a"])), "a"); + assert_eq!(join(&owned(&["a", "b"])), "a and b"); + assert_eq!(join(&owned(&["a", "b", "c"])), "a, b and c"); + } + + #[test] + fn listed_caps_the_names_it_prints() { + assert_eq!(listed(&owned(&["a", "b", "c", "d"])), "a, b, c and d"); + assert_eq!( + listed(&owned(&["a", "b", "c", "d", "e", "f"])), + "a, b, c, d and 2 more functions" + ); + } +} diff --git a/ui/parallel_params.rs b/ui/parallel_params.rs new file mode 100644 index 0000000..ffefa86 --- /dev/null +++ b/ui/parallel_params.rs @@ -0,0 +1,158 @@ +// Parameters several functions declare alike and hand on together are one value the types never name. + +// Flagged: `w` and `h` are declared by `decode`, `scale` and `checksum`, and +// `decode` hands both unchanged to `scale`. +fn decode(src: &[u8], w: u32, h: u32) -> Vec { + let out = scale(src, w, h, 2); + let _ = checksum(w, h, &out); + out +} + +fn scale(src: &[u8], w: u32, h: u32, factor: u32) -> Vec { + src.iter() + .cycle() + .take((w * h * factor) as usize) + .copied() + .collect() +} + +fn checksum(w: u32, h: u32, px: &[u8]) -> u64 { + u64::from(w) * u64::from(h) + px.len() as u64 +} + +// Flagged: three parameters through methods; the receiver is not one of them, +// and `&mut *sink` is still `sink`. +struct Conn { + retries: u8, +} + +impl Conn { + fn send(&self, host: &str, port: u16, sink: &mut Vec) { + for _ in 0..self.retries { + self.dial(host, port, &mut *sink); + } + } + + fn dial(&self, host: &str, port: u16, sink: &mut Vec) { + log_target(host, port, sink); + } +} + +fn log_target(host: &str, port: u16, sink: &mut Vec) { + sink.push(format!("{host}:{port}")); +} + +// Fine: only `derive` and `mix` declare `key` and `salt`; the threshold is +// three functions. +fn derive(key: &[u8], salt: &[u8]) -> u8 { + mix(key, salt) ^ 0x5c +} + +fn mix(key: &[u8], salt: &[u8]) -> u8 { + key.iter().chain(salt).fold(0, |a, b| a ^ b) +} + +// Fine: three functions declare `lo` and `hi`, but only `clamp` hands the +// pair on, to `within`; `width` declaring the same names is not travel, so +// two functions are linked and the threshold is three. +fn clamp(v: i64, lo: i64, hi: i64) -> i64 { + if within(v, lo, hi) { v } else { lo } +} + +fn within(v: i64, lo: i64, hi: i64) -> bool { + lo <= v && v <= hi +} + +fn width(lo: i64, hi: i64) -> i64 { + hi - lo +} + +// Fine: `slice` renames what it receives, so `copy_range`'s call is a +// translation, not a hand-off; the other two never forward. +fn copy_range(buf: &[u8], from: usize, to: usize) -> Vec { + slice(buf, from, to).to_vec() +} + +fn slice(buf: &[u8], start: usize, end: usize) -> &[u8] { + &buf[start..end] +} + +fn check_range(buf: &[u8], from: usize, to: usize) -> bool { + from <= to && to <= buf.len() +} + +fn print_range(from: usize, to: usize) { + println!("{from}..{to}"); +} + +// Fine: `resize` is a trait method and its impl, whose signatures the trait +// dictates; that leaves two functions declaring `rows` and `cols`. +trait Shape { + fn resize(&mut self, rows: usize, cols: usize); +} + +struct Grid { + cells: Vec, +} + +impl Shape for Grid { + fn resize(&mut self, rows: usize, cols: usize) { + self.cells = grid_cells(rows, cols); + } +} + +fn grid_cells(rows: usize, cols: usize) -> Vec { + grid_fill(rows, cols, 0) +} + +fn grid_fill(rows: usize, cols: usize, byte: u8) -> Vec { + vec![byte; rows * cols] +} + +// Fine: `invert` is also passed as a fn pointer, which pins its signature; +// without it only `blend` and `luma` declare `r`, `g`, `b`. +fn apply(px: &mut [u8; 3], op: fn(u8, u8, u8) -> u8) { + px[0] = op(px[0], px[1], px[2]); +} + +fn blend(r: u8, g: u8, b: u8) -> u8 { + luma(r, g, b) / 2 + invert(r, g, b) / 2 +} + +fn luma(r: u8, g: u8, b: u8) -> u8 { + r / 3 + g / 3 + b / 3 +} + +fn invert(r: u8, g: u8, b: u8) -> u8 { + 255 - luma(r, g, b) +} + +// Fine: exported functions' signatures belong to callers this crate cannot +// see. +pub fn open(path: &str, mode: u32) -> usize { + create(path, mode) + probe(path, mode) +} + +pub fn create(path: &str, mode: u32) -> usize { + path.len() + mode as usize +} + +pub fn probe(path: &str, mode: u32) -> usize { + path.len() ^ mode as usize +} + +fn main() { + let _ = decode(&[1, 2, 3], 4, 4); + let mut log = Vec::new(); + Conn { retries: 2 }.send("h", 80, &mut log); + let _ = derive(b"k", b"s"); + let _ = (clamp(5, 0, 9), within(5, 0, 9), width(0, 9)); + let _ = (copy_range(b"abc", 0, 2), check_range(b"abc", 0, 2)); + print_range(0, 2); + let mut g = Grid { cells: Vec::new() }; + g.resize(2, 2); + let mut px = [1, 2, 3]; + apply(&mut px, invert); + let _ = blend(1, 2, 3); + let _ = open("/", 0o644); +} diff --git a/ui/parallel_params.stderr b/ui/parallel_params.stderr new file mode 100644 index 0000000..e617ecb --- /dev/null +++ b/ui/parallel_params.stderr @@ -0,0 +1,19 @@ +warning: parameters `w: u32` and `h: u32` pass unchanged between `decode`, `scale` and `checksum` (`decode` hands them to `scale` in one call): one value travelling as 2 parameters + --> $DIR/parallel_params.rs:5:23 + | +LL | fn decode(src: &[u8], w: u32, h: u32) -> Vec { + | ^^^^^^ + | + = help: a struct with these fields names the value; each function then takes, checks and forwards one parameter + = note: `#[warn(parallel_params)]` on by default + +warning: parameters `host: &str`, `port: u16` and `sink: &mut Vec` pass unchanged between `send`, `dial` and `log_target` (`send` hands them to `dial` in one call): one value travelling as 3 parameters + --> $DIR/parallel_params.rs:30:20 + | +LL | fn send(&self, host: &str, port: u16, sink: &mut Vec) { + | ^^^^^^^^^^ + | + = help: a struct with these fields names the value; each function then takes, checks and forwards one parameter + +warning: 2 warnings emitted + diff --git a/ui_off/parallel_params.rs b/ui_off/parallel_params.rs new file mode 100644 index 0000000..243efc0 --- /dev/null +++ b/ui_off/parallel_params.rs @@ -0,0 +1,24 @@ +// The same shape ui/parallel_params.rs flags; without `parallel-params-enabled` +// nothing is reported. + +fn decode(src: &[u8], w: u32, h: u32) -> Vec { + let out = scale(src, w, h, 2); + let _ = checksum(w, h, &out); + out +} + +fn scale(src: &[u8], w: u32, h: u32, factor: u32) -> Vec { + src.iter() + .cycle() + .take((w * h * factor) as usize) + .copied() + .collect() +} + +fn checksum(w: u32, h: u32, px: &[u8]) -> u64 { + u64::from(w) * u64::from(h) + px.len() as u64 +} + +fn main() { + let _ = decode(&[1, 2, 3], 4, 4); +} From 9e7b301462a8591215c3aab1031618ae54494d77 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:28:51 -0700 Subject: [PATCH 15/34] add bool_params --- README.md | 1 + src/bool_params.rs | 211 +++++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 +- ui/bool_params.rs | 95 ++++++++++++++++++ ui/bool_params.stderr | 29 ++++++ ui/misbound_arg.stderr | 16 ++- ui/stringly_state.stderr | 16 ++- 7 files changed, 369 insertions(+), 3 deletions(-) create mode 100644 src/bool_params.rs create mode 100644 ui/bool_params.rs create mode 100644 ui/bool_params.stderr diff --git a/README.md b/README.md index e548e9a..5f15b06 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `sentinel_int` | an integer field one function tests against `MAX`, `-1` or an `INVALID` constant and another indexes with or offsets a pointer by untested: `Option` spelled as an int | | `stringly_state` | a string field or local only ever storing one of a closed set of literals and then compared against them: an undeclared enum, so a misspelt state still compiles | | `parallel_params` | opt-in via `parallel-params-enabled`: parameters several functions declare alike and hand each other unchanged in one call: one value with no type, passable by halves | +| `bool_params` | a crate-private fn with two or more `bool` parameters that a call fills with bare `true`/`false`: `f(x, true, false)` names neither flag, and the swapped call compiles | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/bool_params.rs b/src/bool_params.rs new file mode 100644 index 0000000..935f470 --- /dev/null +++ b/src/bool_params.rs @@ -0,0 +1,211 @@ +use std::collections::HashMap; + +use rustc_abi::ExternAbi; +use rustc_ast::LitKind; +use rustc_hir::def::DefKind; +use rustc_hir::def_id::DefId; +use rustc_hir::{Expr, ExprKind}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_span::Span; + +use crate::baseline::emit_with_note; +use crate::hir_shapes::{Callee, callee_of}; + +rustc_session::declare_lint! { + /// Flags a crate-private function with two or more `bool` parameters + /// when a call site fills at least two of them with bare `true` / + /// `false`. At `f(x, true, false)` nothing says which flag each literal + /// sets, and the literals share a type, so the call with them in the + /// other order (or the wrong one flipped) is a program the compiler + /// accepts. The parameter names exist only in the signature; a + /// two-variant enum per flag (or an options struct) carries them to + /// every call and makes a swap a type error. + /// + /// Stays quiet on a single `bool` parameter (nothing to confuse it + /// with), on exported, `extern` and trait functions (their signature is + /// not the crate's to change), when every call passes named values or + /// at most one bare literal, and on a literal a comment beside it names + /// (`/* force */ true`, or `true, // force` before a line break). + pub BOOL_PARAMS, + Warn, + "several bool parameters filled with bare literals at a call site" +} + +#[derive(Default)] +struct Calls { + sites: usize, + /// Calls with two or more unnamed bool literals, as written (`f(.., true, false)`). + bare: Vec<(Span, String)>, +} + +#[derive(Default)] +pub struct BoolParams { + calls: HashMap, +} + +rustc_session::impl_lint_pass!(BoolParams => [BOOL_PARAMS]); + +/// Signature indices of `def`'s `bool` parameters, when it is a crate-private +/// Rust-ABI fn of this crate whose signature is its own (not a trait's). +fn bool_params(cx: &LateContext<'_>, def: DefId) -> Option> { + let local = def.as_local()?; + if !matches!(cx.tcx.def_kind(def), DefKind::Fn | DefKind::AssocFn) + || cx.effective_visibilities.is_exported(local) + || cx.tcx.trait_of_assoc(def).is_some() + || cx.tcx.trait_impl_of_assoc(def).is_some() + || cx.tcx.def_span(def).from_expansion() + { + return None; + } + let sig = cx + .tcx + .fn_sig(def) + .instantiate_identity() + .skip_normalization(); + if sig.abi() != ExternAbi::Rust { + return None; + } + let bools: Vec = sig + .inputs() + .skip_binder() + .iter() + .enumerate() + .filter(|(_, ty)| ty.is_bool()) + .map(|(i, _)| i) + .collect(); + (bools.len() >= 2).then_some(bools) +} + +fn bare_bool(e: &Expr<'_>) -> Option { + match e.kind { + ExprKind::Lit(lit) if !e.span.from_expansion() => match lit.node { + LitKind::Bool(b) => Some(b), + _ => None, + }, + _ => None, + } +} + +/// A comment beside the argument names it: `/* force */ true` or a comment +/// line above it (leading), `true, // force` before a line break (trailing). +/// The first line of a gap between two arguments belongs to the earlier one, +/// the rest to the later one, so one comment never names both neighbours. +fn commented(cx: &LateContext<'_>, before: Span, arg: Span, after: Span) -> bool { + let sm = cx.tcx.sess.source_map(); + let has_comment = |s: &str| s.contains("//") || s.contains("/*"); + let lead = sm + .span_to_snippet(before.between(arg)) + .is_ok_and(|s| match s.split_once('\n') { + Some((_, rest)) => has_comment(rest), + None => has_comment(&s), + }); + let trail = sm.span_to_snippet(arg.between(after)).is_ok_and(|s| { + s.split_once('\n') + .is_some_and(|(line, _)| has_comment(line)) + }); + lead || trail +} + +impl<'tcx> LateLintPass<'tcx> for BoolParams { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + if expr.span.from_expansion() { + return; + } + // (callee, anchor before the first argument, arguments, signature + // index of argument 0: a method's receiver is input 0). + let (def, anchor, args, offset) = match (callee_of(cx, expr), expr.kind) { + (Some(Callee::Path { def, args }), ExprKind::Call(callee, _)) => { + (def, callee.span, args, 0) + } + (Some(Callee::Method { def, args, .. }), ExprKind::MethodCall(seg, ..)) => { + (def, seg.ident.span, args, 1) + } + _ => return, + }; + let Some(bools) = bool_params(cx, def) else { + return; + }; + let calls = self.calls.entry(def).or_default(); + calls.sites += 1; + let close = expr.span.shrink_to_hi(); + let mut unnamed = 0; + let mut shape: Vec<&str> = Vec::new(); + for (i, arg) in args.iter().enumerate() { + let before = if i == 0 { anchor } else { args[i - 1].span }; + let after = args.get(i + 1).map_or(close, |a| a.span); + let word = match bare_bool(arg) { + Some(b) + if bools.contains(&(i + offset)) && !commented(cx, before, arg.span, after) => + { + unnamed += 1; + if b { "true" } else { "false" } + } + _ => "..", + }; + if word != ".." || shape.last() != Some(&"..") { + shape.push(word); + } + } + if unnamed >= 2 { + let name = cx.tcx.item_name(def); + calls + .bare + .push((expr.span, format!("{name}({})", shape.join(", ")))); + } + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let mut findings: Vec<(Span, String, Span)> = Vec::new(); + for (def, calls) in &mut self.calls { + calls.bare.sort_by_key(|(span, _)| span.lo()); + let Some((first, written)) = calls.bare.first() else { + continue; + }; + let Some(bools) = bool_params(cx, *def) else { + continue; + }; + let idents = cx.tcx.fn_arg_idents(*def); + let mut names: Vec = bools + .iter() + .map(|&i| match idents.get(i).copied().flatten() { + Some(ident) => format!("`{}`", ident.name), + None => "`_`".to_string(), + }) + .collect(); + let last = names.pop().unwrap_or_default(); + let which = if names.len() == 1 { + "both" + } else { + "at least two of them" + }; + let (k, n) = (calls.bare.len(), calls.sites); + let pass = if k == 1 { "passes" } else { "pass" }; + let span = cx + .tcx + .def_ident_span(*def) + .unwrap_or_else(|| cx.tcx.def_span(*def)); + findings.push(( + span, + format!( + "`{}` takes `bool` parameters {} and {last}, and {k} of its {n} call sites {pass} bare `true`/`false` for {which}: nothing at `{written}` says which flag each one sets", + cx.tcx.item_name(*def), + names.join(", "), + ), + *first, + )); + } + // `calls` is a HashMap; report in source order. + findings.sort_by_key(|(span, ..)| span.lo()); + for (span, msg, call) in findings { + emit_with_note( + cx, + BOOL_PARAMS, + span, + msg, + call, + "one such call", + "a two-variant enum per flag, or one options struct, names every argument at the call site and turns a swapped pair into a type error", + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 8ce5593..bbbe601 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ mod adt_facts; mod asymmetric_guard; mod baseline; mod bool_beside_option; +mod bool_params; mod bypassed_conversion; mod bypassed_validator; mod claims; @@ -175,7 +176,7 @@ pub struct MordantConfig { #[unsafe(no_mangle)] pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintStore) { use { - asymmetric_guard::AsymmetricGuard, baseline::BaselineWriter, + asymmetric_guard::AsymmetricGuard, baseline::BaselineWriter, bool_params::BoolParams, bypassed_conversion::BypassedConversion, bypassed_validator::BypassedValidator, collapsed_error::CollapsedError, crossed_index::CrossedIndex, defaulted_failure::DefaultedFailure, dependent_field::DependentField, @@ -245,6 +246,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, config.parallel_params_enabled, move || { ParallelParams::new(config) }); + add(s, true, BoolParams::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/ui/bool_params.rs b/ui/bool_params.rs new file mode 100644 index 0000000..1f3ddbe --- /dev/null +++ b/ui/bool_params.rs @@ -0,0 +1,95 @@ +// Several `bool` parameters filled with bare `true`/`false` at a call site: +// the flag names live only in the signature, so a swapped call compiles. + +// Flagged: two of the three calls pass both flags as bare literals. +fn render(text: &str, wrap: bool, color: bool) -> usize { + text.len() + usize::from(wrap) + usize::from(color) +} + +struct File { + len: usize, +} + +impl File { + // Flagged: a method; the receiver is not a flag, the three bools are, and + // both the method-call and the path-call form leave two of them unnamed. + fn open(&mut self, create: bool, truncate: bool, append: bool) -> usize { + self.len + usize::from(create) + usize::from(truncate) + usize::from(append) + } + + // Fine: every call names what it passes, by a binding or by a comment. + fn lock(&mut self, shared: bool, blocking: bool) -> usize { + self.len + usize::from(shared) + usize::from(blocking) + } + + // Fine: one bool parameter has nothing to be swapped with. + fn sync(&mut self, data_only: bool) -> usize { + self.len + usize::from(data_only) + } +} + +// Fine: exported, so its signature is not the crate's alone to change. +pub fn spawn(cmd: &str, inherit_stdout: bool, inherit_stderr: bool) -> usize { + cmd.len() + usize::from(inherit_stdout) + usize::from(inherit_stderr) +} + +trait Sink { + fn write(&mut self, flush: bool, sync: bool) -> usize; +} + +// Fine: a trait method's signature is dictated by the trait. +impl Sink for File { + fn write(&mut self, flush: bool, sync: bool) -> usize { + self.len + usize::from(flush) + usize::from(sync) + } +} + +// Fine: one bool among other parameters, called with a literal everywhere. +fn add(list: &mut Vec, enabled: bool, item: u8) { + if enabled { + list.push(item); + } +} + +// Fine: two bool parameters, but at most one is ever a bare literal per call. +fn connect(host: &str, tls: bool, keepalive: bool) -> usize { + host.len() + usize::from(tls) + usize::from(keepalive) +} + +fn main() { + let wrap = true; + let color = false; + let _ = render("a", true, false); + let _ = render("b", false, false); + let _ = render("c", wrap, color); + + let mut f = File { len: 0 }; + let append = false; + let _ = f.open(true, false, append); + let _ = File::open(&mut f, false, false, false); + + let shared = true; + let blocking = false; + let _ = f.lock(shared, blocking); + let _ = f.lock(/* shared */ true, /* blocking */ false); + let _ = f.lock( + true, // shared + false, + ); + + let _ = f.sync(true); + let _ = f.sync(false); + + let _ = spawn("ls", true, false); + let _ = f.write(true, true); + let _ = Sink::write(&mut f, false, false); + + let mut list = Vec::new(); + add(&mut list, true, 1); + add(&mut list, true, 2); + add(&mut list, false, 3); + + let keepalive = true; + let _ = connect("h", true, keepalive); + let _ = connect("h", cfg!(unix), cfg!(windows)); +} diff --git a/ui/bool_params.stderr b/ui/bool_params.stderr new file mode 100644 index 0000000..c87d22a --- /dev/null +++ b/ui/bool_params.stderr @@ -0,0 +1,29 @@ +warning: `render` takes `bool` parameters `wrap` and `color`, and 2 of its 3 call sites pass bare `true`/`false` for both: nothing at `render(.., true, false)` says which flag each one sets + --> $DIR/bool_params.rs:5:4 + | +LL | fn render(text: &str, wrap: bool, color: bool) -> usize { + | ^^^^^^ + | +note: one such call + --> $DIR/bool_params.rs:62:13 + | +LL | let _ = render("a", true, false); + | ^^^^^^^^^^^^^^^^^^^^^^^^ + = help: a two-variant enum per flag, or one options struct, names every argument at the call site and turns a swapped pair into a type error + = note: `#[warn(bool_params)]` on by default + +warning: `open` takes `bool` parameters `create`, `truncate` and `append`, and 2 of its 2 call sites pass bare `true`/`false` for at least two of them: nothing at `open(true, false, ..)` says which flag each one sets + --> $DIR/bool_params.rs:16:8 + | +LL | fn open(&mut self, create: bool, truncate: bool, append: bool) -> usize { + | ^^^^ + | +note: one such call + --> $DIR/bool_params.rs:68:13 + | +LL | let _ = f.open(true, false, append); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: a two-variant enum per flag, or one options struct, names every argument at the call site and turns a swapped pair into a type error + +warning: 2 warnings emitted + diff --git a/ui/misbound_arg.stderr b/ui/misbound_arg.stderr index a1e2f3b..9d72bd2 100644 --- a/ui/misbound_arg.stderr +++ b/ui/misbound_arg.stderr @@ -31,5 +31,19 @@ LL | c.blit(dst, src) | = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error -warning: 4 warnings emitted +warning: `prefixed_names_are_fine` takes `bool` parameters `dir` and `is_follow`, and 1 of its 1 call sites passes bare `true`/`false` for both: nothing at `prefixed_names_are_fine(true, false)` says which flag each one sets + --> $DIR/misbound_arg.rs:74:4 + | +LL | fn prefixed_names_are_fine(dir: bool, is_follow: bool) -> usize { + | ^^^^^^^^^^^^^^^^^^^^^^^ + | +note: one such call + --> $DIR/misbound_arg.rs:116:13 + | +LL | let _ = prefixed_names_are_fine(true, false); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: a two-variant enum per flag, or one options struct, names every argument at the call site and turns a swapped pair into a type error + = note: `#[warn(bool_params)]` on by default + +warning: 5 warnings emitted diff --git a/ui/stringly_state.stderr b/ui/stringly_state.stderr index ace8dd7..267e720 100644 --- a/ui/stringly_state.stderr +++ b/ui/stringly_state.stderr @@ -47,5 +47,19 @@ LL | let level = if n > 9 { "hi" } else { "lo" }; | = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error -warning: 6 warnings emitted +warning: `severity` takes `bool` parameters `major` and `minor`, and 1 of its 1 call sites passes bare `true`/`false` for both: nothing at `severity(true, false)` says which flag each one sets + --> $DIR/stringly_state.rs:92:4 + | +LL | fn severity(major: bool, minor: bool) -> u8 { + | ^^^^^^^^ + | +note: one such call + --> $DIR/stringly_state.rs:250:13 + | +LL | let _ = severity(true, false) + tier(3) as u8; + | ^^^^^^^^^^^^^^^^^^^^^ + = help: a two-variant enum per flag, or one options struct, names every argument at the call site and turns a swapped pair into a type error + = note: `#[warn(bool_params)]` on by default + +warning: 7 warnings emitted From f22af50f0d2cfe62bb8aad29a0ddd973c0f1e46f Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:30:13 -0700 Subject: [PATCH 16/34] misbound_arg stays quiet on receivers and symmetric pairs --- src/misbound_arg.rs | 109 +++++++++++++++++++++++++++++++---------- ui/misbound_arg.rs | 43 +++++++++++++++- ui/misbound_arg.stderr | 24 ++++----- 3 files changed, 134 insertions(+), 42 deletions(-) diff --git a/src/misbound_arg.rs b/src/misbound_arg.rs index f99c4a6..947680b 100644 --- a/src/misbound_arg.rs +++ b/src/misbound_arg.rs @@ -1,5 +1,10 @@ +use std::ops::ControlFlow; + +use clippy_utils::get_parent_expr; +use clippy_utils::visitors::for_each_expr; use rustc_hir::def::{DefKind, Res}; -use rustc_hir::{Expr, ExprKind, QPath, UnOp}; +use rustc_hir::def_id::DefId; +use rustc_hir::{BinOpKind, Expr, ExprKind, QPath, UnOp}; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::Ty; use rustc_span::Symbol; @@ -21,8 +26,11 @@ rustc_session::declare_lint! { /// dropping a leading `is_`/`has_`/`_` and a trailing `_`, when the /// parameter the name points at already receives an argument of that /// name, when the bound parameter's name contains the argument's as a - /// word (`from_index` receiving `index`), for one-character names, - /// method receivers, and calls through closures or fn pointers. + /// word (`from_index` receiving `index`), when the same condition also + /// calls the callee the other way round (`sub(a, b) && sub(b, a)` is a + /// symmetric use), for `self`/`this` on either side (a receiver slot is + /// a grammatical position, not a role), for one-character names, and + /// for calls through closures or fn pointers. pub MISBOUND_ARG, Warn, "argument named as a different same-typed parameter of the callee" @@ -51,12 +59,71 @@ fn arg_name(mut e: &Expr<'_>) -> Option { } } -/// `is_open_` and `open` name the same thing for this lint's purposes. -fn normalized(name: &str) -> &str { +/// The role a name claims: `is_open_` and `open` claim the same one; +/// `self`, `self_` and `this` name a position, not a role, so they claim +/// none, and neither does a single character. +fn role(name: Symbol) -> Option { + let name = name.as_str(); let name = name.trim_start_matches('_').trim_end_matches('_'); - name.strip_prefix("is_") + let name = name + .strip_prefix("is_") .or_else(|| name.strip_prefix("has_")) - .unwrap_or(name) + .unwrap_or(name); + (name.len() >= 2 && name != "self" && name != "this").then(|| Symbol::intern(name)) +} + +/// The role the argument in signature slot `slot` of a call is named for. +fn received(args: &[Expr<'_>], offset: usize, slot: usize) -> Option { + let arg = args.get(slot.checked_sub(offset)?)?; + role(arg_name(arg)?) +} + +fn args_of<'tcx>(callee: &Callee<'tcx>) -> (&'tcx [Expr<'tcx>], usize) { + match *callee { + Callee::Path { args, .. } => (args, 0), + // The receiver is bound to `self`; explicit args start one slot on. + Callee::Method { args, .. } => (args, 1), + } +} + +/// `f(a, b) && f(b, a)`: the condition `expr` sits in also calls `def` +/// with slots `slot` and `other` receiving their own names, so the crossed +/// call is the deliberate other half of a symmetric test. +fn mirrored_in_condition<'tcx>( + cx: &LateContext<'tcx>, + expr: &'tcx Expr<'tcx>, + def: DefId, + (slot, slot_role): (usize, Symbol), + (other, other_role): (usize, Symbol), +) -> bool { + let mut root = expr; + while let Some(parent) = get_parent_expr(cx, root) + && match parent.kind { + ExprKind::Unary(UnOp::Not, _) | ExprKind::DropTemps(_) => true, + ExprKind::Binary(op, ..) => matches!(op.node, BinOpKind::And | BinOpKind::Or), + _ => false, + } + { + root = parent; + } + if root.hir_id == expr.hir_id { + return false; + } + for_each_expr(cx, root, |e: &'tcx Expr<'tcx>| { + if e.hir_id != expr.hir_id + && let Some(c) = callee_of(cx, e) + && c.def() == def + { + let (args, offset) = args_of(&c); + if received(args, offset, slot) == Some(slot_role) + && received(args, offset, other) == Some(other_role) + { + return ControlFlow::Break(()); + } + } + ControlFlow::Continue(()) + }) + .is_some() } /// `from_file_index` qualifies `file_index` rather than naming another slot. @@ -88,12 +155,7 @@ impl<'tcx> LateLintPass<'tcx> for MisboundArg { if !matches!(cx.tcx.def_kind(def), DefKind::Fn | DefKind::AssocFn) { return; } - // A method call's receiver is bound to `self`; explicit args start - // one signature slot later. - let (args, offset) = match callee { - Callee::Path { args, .. } => (args, 0), - Callee::Method { args, .. } => (args, 1), - }; + let (args, offset) = args_of(&callee); let idents = cx.tcx.fn_arg_idents(def); let sig = cx.tcx.erase_and_anonymize_regions( cx.tcx.instantiate_bound_regions_with_erased( @@ -108,12 +170,6 @@ impl<'tcx> LateLintPass<'tcx> for MisboundArg { return; } let param_name = |i: usize| idents[i].map(|id| id.name); - // The name each signature slot actually receives at this call. - let received = |slot: usize| { - slot.checked_sub(offset) - .and_then(|i| args.get(i)) - .and_then(|a| arg_name(a)) - }; let mut crossings: Vec> = Vec::new(); for (i, arg) in args.iter().enumerate() { if arg.span.from_expansion() { @@ -123,21 +179,22 @@ impl<'tcx> LateLintPass<'tcx> for MisboundArg { let (Some(name), Some(bound_to)) = (arg_name(arg), param_name(slot)) else { continue; }; - let an = normalized(name.as_str()); - let bn = normalized(bound_to.as_str()); - if an.len() < 2 || bn.len() < 2 || an == bn || qualifies(bn, an) { + let (Some(an), Some(bn)) = (role(name), role(bound_to)) else { + continue; + }; + if an == bn || qualifies(bn.as_str(), an.as_str()) { continue; } let Some(other) = (0..inputs.len()).find(|&q| { - q != slot - && inputs[q] == inputs[slot] - && param_name(q).is_some_and(|p| normalized(p.as_str()) == an) + q != slot && inputs[q] == inputs[slot] && param_name(q).and_then(role) == Some(an) }) else { continue; }; // `f(name, name)`: the namesake parameter already gets its name, // so nothing is transposed, one value fills two roles. - if received(other).is_some_and(|r| normalized(r.as_str()) == an) { + if received(args, offset, other) == Some(an) + || mirrored_in_condition(cx, expr, def, (slot, bn), (other, an)) + { continue; } crossings.push(Crossing { diff --git a/ui/misbound_arg.rs b/ui/misbound_arg.rs index 6dd6523..04c3cc6 100644 --- a/ui/misbound_arg.rs +++ b/ui/misbound_arg.rs @@ -45,6 +45,27 @@ impl Canvas { } } +fn under(url: &str, registry: &str) -> bool { + url.starts_with(registry) +} + +fn has_prefix(self_: &[u8], str: &[u8]) -> bool { + self_.starts_with(str) +} + +struct Span(u32, u32); + +impl Span { + fn within(&self, other: &Span) -> bool { + other.0 <= self.0 && self.1 <= other.1 + } + + fn encloses(&self, inner: &Span) -> bool { + // Fine: `self` in an argument slot names a position, not a role. + inner.within(self) + } +} + fn swapped_pair_is_flagged(width: u32, height: u32) -> u32 { // Flagged: both names cross, reported once for the call. resize(height, width) @@ -65,6 +86,11 @@ fn method_args_are_flagged(c: &Canvas, src: usize, dst: usize) -> usize { c.blit(dst, src) } +fn lone_reversal_is_flagged(url: &str, registry: &str) -> bool { + // Flagged: nothing nearby applies `under` the right way round. + under(registry, url) +} + fn correct_order_is_fine(width: u32, height: u32, opts: &SpawnOptions) -> u32 { // Fine: every name sits in its own slot. let _ = spawn(opts.inherit_stdout, opts.inherit_stderr); @@ -97,6 +123,16 @@ fn unnamed_args_are_fine(d: &Daemon) -> bool { spawn(true, d.detached) } +fn symmetric_pair_is_fine(url: &str, registry: &str) -> bool { + // Fine: both orders in one condition is an equality test, not a slip. + !(under(url, registry) && under(registry, url)) +} + +fn pseudo_receiver_is_fine(str: &[u8]) -> bool { + // Fine: `self_` is a receiver slot; whatever fills it is the subject. + has_prefix(str, b"./") +} + fn closures_are_fine(width: u32, height: u32) -> u32 { // Fine: a closure's parameters are not a signature anyone reads by name. let f = |width: u32, height: u32| width + height; @@ -113,10 +149,15 @@ fn main() { let _ = one_misbound_is_flagged(1, 2); let _ = method_args_are_flagged(&Canvas, 1, 2); let _ = correct_order_is_fine(1, 2, &opts); - let _ = prefixed_names_are_fine(true, false); + let (dir, follow) = (true, opts.inherit_stderr); + let _ = prefixed_names_are_fine(dir, follow); let _ = different_types_are_fine(2, 1.0); let _ = same_value_twice_is_fine("n"); let _ = qualified_param_is_fine(2, 3); let _ = unnamed_args_are_fine(&Daemon { detached: false }); let _ = closures_are_fine(1, 2); + let _ = lone_reversal_is_flagged("u", "r"); + let _ = symmetric_pair_is_fine("u", "r"); + let _ = pseudo_receiver_is_fine(b"s"); + let _ = Span(1, 2).encloses(&Span(0, 3)); } diff --git a/ui/misbound_arg.stderr b/ui/misbound_arg.stderr index 9d72bd2..3ba7afd 100644 --- a/ui/misbound_arg.stderr +++ b/ui/misbound_arg.stderr @@ -1,5 +1,5 @@ warning: arguments `height` and `width` are bound to `resize`'s parameters `width` and `height`; all are `u32`, so the transposition type-checks - --> $DIR/misbound_arg.rs:50:5 + --> $DIR/misbound_arg.rs:71:5 | LL | resize(height, width) | ^^^^^^^^^^^^^^^^^^^^^ @@ -8,7 +8,7 @@ LL | resize(height, width) = note: `#[warn(misbound_arg)]` on by default warning: arguments `inherit_stderr` and `inherit_stdout` are bound to `spawn`'s parameters `inherit_stdout` and `inherit_stderr`; all are `bool`, so the transposition type-checks - --> $DIR/misbound_arg.rs:55:5 + --> $DIR/misbound_arg.rs:76:5 | LL | spawn(opts.inherit_stderr, opts.inherit_stdout) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -16,7 +16,7 @@ LL | spawn(opts.inherit_stderr, opts.inherit_stdout) = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: argument `column` is bound to `place`'s parameter `line`, but `place` also takes a parameter `column` of the same type `u32` - --> $DIR/misbound_arg.rs:60:11 + --> $DIR/misbound_arg.rs:81:11 | LL | place(column, extra, 0) | ^^^^^^ @@ -24,26 +24,20 @@ LL | place(column, extra, 0) = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: arguments `dst` and `src` are bound to `blit`'s parameters `src` and `dst`; all are `usize`, so the transposition type-checks - --> $DIR/misbound_arg.rs:65:5 + --> $DIR/misbound_arg.rs:86:5 | LL | c.blit(dst, src) | ^^^^^^^^^^^^^^^^ | = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error -warning: `prefixed_names_are_fine` takes `bool` parameters `dir` and `is_follow`, and 1 of its 1 call sites passes bare `true`/`false` for both: nothing at `prefixed_names_are_fine(true, false)` says which flag each one sets - --> $DIR/misbound_arg.rs:74:4 +warning: arguments `registry` and `url` are bound to `under`'s parameters `url` and `registry`; all are `&str`, so the transposition type-checks + --> $DIR/misbound_arg.rs:91:5 | -LL | fn prefixed_names_are_fine(dir: bool, is_follow: bool) -> usize { - | ^^^^^^^^^^^^^^^^^^^^^^^ +LL | under(registry, url) + | ^^^^^^^^^^^^^^^^^^^^ | -note: one such call - --> $DIR/misbound_arg.rs:116:13 - | -LL | let _ = prefixed_names_are_fine(true, false); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - = help: a two-variant enum per flag, or one options struct, names every argument at the call site and turns a swapped pair into a type error - = note: `#[warn(bool_params)]` on by default + = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: 5 warnings emitted From 9d7e19c749532eb3c9e52d0719a1b3e4b7226b6d Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:32:45 -0700 Subject: [PATCH 17/34] add unnamed_tuple --- README.md | 1 + src/asymmetric_guard.rs | 4 +- src/guard_flag.rs | 4 +- src/hir_shapes.rs | 20 +- src/insert_then_unwrap.rs | 4 +- src/lib.rs | 6 +- src/lock_order.rs | 4 +- src/stale_across_reentry.rs | 5 +- src/unnamed_tuple.rs | 492 ++++++++++++++++++++++++++++++++++++ ui/unnamed_tuple.rs | 246 ++++++++++++++++++ ui/unnamed_tuple.stderr | 40 +++ 11 files changed, 809 insertions(+), 17 deletions(-) create mode 100644 src/unnamed_tuple.rs create mode 100644 ui/unnamed_tuple.rs create mode 100644 ui/unnamed_tuple.stderr diff --git a/README.md b/README.md index 5f15b06..9adc2b4 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `stringly_state` | a string field or local only ever storing one of a closed set of literals and then compared against them: an undeclared enum, so a misspelt state still compiles | | `parallel_params` | opt-in via `parallel-params-enabled`: parameters several functions declare alike and hand each other unchanged in one call: one value with no type, passable by halves | | `bool_params` | a crate-private fn with two or more `bool` parameters that a call fills with bare `true`/`false`: `f(x, true, false)` names neither flag, and the swapped call compiles | +| `unnamed_tuple` | a private fn's tuple return with two members of one type that every caller destructures under the same names: only the type lacks them, and it accepts them transposed | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/asymmetric_guard.rs b/src/asymmetric_guard.rs index 2b47e71..49a99d9 100644 --- a/src/asymmetric_guard.rs +++ b/src/asymmetric_guard.rs @@ -12,7 +12,7 @@ use rustc_span::{Span, Symbol}; use crate::adt_facts::impl_self_adt; use crate::baseline::emit; use crate::hir_shapes::{ - Callee, callee_of, ends_in_return, is_self_path, peel_not, self_field, stmt_expr, + Callee, SelfField, callee_of, ends_in_return, is_self_path, peel_not, self_field, stmt_expr, }; rustc_session::declare_lint! { @@ -205,7 +205,7 @@ impl<'tcx> LateLintPass<'tcx> for AsymmetricGuard { // other `self` reaching the walk is one that got away. let mut followed: HashSet = HashSet::new(); for_each_expr(cx, body.value, |e: &Expr<'_>| { - if let Some((base, ident)) = self_field(e) { + if let Some(SelfField { base, ident }) = self_field(e) { facts.touched.insert(ident.name); followed.insert(base.hir_id); } else if let Some((m, _)) = self_method_call(cx, e) diff --git a/src/guard_flag.rs b/src/guard_flag.rs index 9e30a7d..f577c26 100644 --- a/src/guard_flag.rs +++ b/src/guard_flag.rs @@ -2,7 +2,7 @@ use std::collections::{HashMap, HashSet}; use crate::adt_facts::{field_ty, struct_field}; use crate::baseline::emit; -use crate::hir_shapes::{assigned_adt_field, ends_in_return, peel_not, self_field}; +use crate::hir_shapes::{SelfField, assigned_adt_field, ends_in_return, peel_not, self_field}; use rustc_hir::def_id::DefId; use rustc_hir::intravisit::FnKind; use rustc_hir::{Body, Expr, ExprKind, FnDecl, Mutability, Stmt, StmtKind}; @@ -42,7 +42,7 @@ fn self_bool_field<'tcx>( cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>, ) -> Option<(ty::AdtDef<'tcx>, Symbol)> { - let (base, ident) = self_field(e)?; + let SelfField { base, ident } = self_field(e)?; let ty::Adt(adt, _) = cx.typeck_results().expr_ty(base).peel_refs().kind() else { return None; }; diff --git a/src/hir_shapes.rs b/src/hir_shapes.rs index 5f785e3..35ba397 100644 --- a/src/hir_shapes.rs +++ b/src/hir_shapes.rs @@ -17,10 +17,15 @@ pub(crate) fn is_self_path(e: &Expr<'_>) -> bool { if p.segments.len() == 1 && p.segments[0].ident.name == kw::SelfLower) } -/// `self.field`, as the `self` expression it is read off and the field name. -pub(crate) fn self_field<'h>(e: &Expr<'h>) -> Option<(&'h Expr<'h>, Ident)> { +/// `self.field`: the `self` expression it is read off and the field name. +pub(crate) struct SelfField<'h> { + pub base: &'h Expr<'h>, + pub ident: Ident, +} + +pub(crate) fn self_field<'h>(e: &Expr<'h>) -> Option> { match e.kind { - ExprKind::Field(base, ident) if is_self_path(base) => Some((base, ident)), + ExprKind::Field(base, ident) if is_self_path(base) => Some(SelfField { base, ident }), _ => None, } } @@ -42,7 +47,12 @@ pub(crate) fn assigned_field<'h>( /// `root.a.b` as `root` and `[a, b]`, read through `&`, `*` and HIR /// temporaries at any level, which all name the same place. -pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> (&'h Expr<'h>, Vec) { +pub(crate) struct FieldChain<'h> { + pub root: &'h Expr<'h>, + pub fields: Vec, +} + +pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> FieldChain<'h> { let mut fields = Vec::new(); loop { match e.kind { @@ -55,7 +65,7 @@ pub(crate) fn field_chain<'h>(mut e: &'h Expr<'h>) -> (&'h Expr<'h>, Vec | ExprKind::DropTemps(inner) => e = inner, _ => { fields.reverse(); - return (e, fields); + return FieldChain { root: e, fields }; } } } diff --git a/src/insert_then_unwrap.rs b/src/insert_then_unwrap.rs index 4d45174..a93b994 100644 --- a/src/insert_then_unwrap.rs +++ b/src/insert_then_unwrap.rs @@ -5,7 +5,7 @@ use rustc_middle::ty; use rustc_span::sym; use crate::baseline::emit; -use crate::hir_shapes::{dotted, field_chain, stmt_expr}; +use crate::hir_shapes::{FieldChain, dotted, field_chain, stmt_expr}; rustc_session::declare_lint! { /// Flags `map.get(&k).unwrap()` when the presence it bets on was proved a @@ -28,7 +28,7 @@ rustc_session::declare_lint_pass!(InsertThenUnwrap => [INSERT_THEN_UNWRAP]); /// (which name the same value); `-k` and `!k` are different values from `k`. /// Anything else is `None`, and untrackable means unprovable means silent. fn identity(e: &Expr<'_>) -> Option { - let (root, fields) = field_chain(e); + let FieldChain { root, fields } = field_chain(e); let head = match &root.kind { ExprKind::Path(QPath::Resolved(None, p)) if p.segments.len() == 1 => { p.segments[0].ident.name.to_string() diff --git a/src/lib.rs b/src/lib.rs index bbbe601..159e95e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -62,6 +62,7 @@ mod stringly_state; mod unchecked_input_len; mod uneven_narrowing; mod unit_mismatch; +mod unnamed_tuple; mod unread_error_variant; mod unread_none; mod variant_flow; @@ -191,8 +192,8 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto stringified_error::StringifiedError, stringly_error::StringlyError, stringly_state::StringlyState, unchecked_input_len::UncheckedInputLen, uneven_narrowing::UnevenNarrowing, unit_mismatch::UnitMismatch, - unread_error_variant::UnreadErrorVariant, unread_none::UnreadNone, - wildcard_local_enum::WildcardLocalEnum, + unnamed_tuple::UnnamedTuple, unread_error_variant::UnreadErrorVariant, + unread_none::UnreadNone, wildcard_local_enum::WildcardLocalEnum, }; dylint_linting::init_config(sess); let config: MordantConfig = dylint_linting::config_or_default(env!("CARGO_PKG_NAME")); @@ -247,6 +248,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto ParallelParams::new(config) }); add(s, true, BoolParams::default); + add(s, true, UnnamedTuple::default); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/src/lock_order.rs b/src/lock_order.rs index c09a7df..655c751 100644 --- a/src/lock_order.rs +++ b/src/lock_order.rs @@ -8,7 +8,7 @@ use rustc_middle::ty; use rustc_span::Span; use crate::baseline::emit; -use crate::hir_shapes::{dotted, field_chain, is_self_path, stmt_expr}; +use crate::hir_shapes::{FieldChain, dotted, field_chain, is_self_path, stmt_expr}; rustc_session::declare_lint! { /// Flags two lock acquisitions the crate performs in both orders: one @@ -79,7 +79,7 @@ fn lock_acquisition(cx: &LateContext<'_>, e: &Expr<'_>) -> Option { /// anything that is not a plain field chain off `self`, since only those /// have a stable identity. fn field_path<'h>(e: &'h Expr<'h>) -> Option<(&'h Expr<'h>, String)> { - let (root, fields) = field_chain(e); + let FieldChain { root, fields } = field_chain(e); is_self_path(root).then(|| (root, dotted(String::new(), &fields))) } diff --git a/src/stale_across_reentry.rs b/src/stale_across_reentry.rs index 1dfd90c..0065388 100644 --- a/src/stale_across_reentry.rs +++ b/src/stale_across_reentry.rs @@ -15,7 +15,8 @@ use crate::MordantConfig; use crate::adt_facts::impl_self_adt; use crate::baseline::emit_with_note; use crate::hir_shapes::{ - def_path_names, dotted, field_chain, is_self_path, stmt_expr, strip_generic_segments, + FieldChain, def_path_names, dotted, field_chain, is_self_path, stmt_expr, + strip_generic_segments, }; rustc_session::declare_lint! { @@ -242,7 +243,7 @@ const ADAPTERS: &[&str] = &[ /// `self.a.b` as `[a, b]`; anything not rooted at `self` through fields /// alone has no identity a re-entrant callee shares with this function. fn self_place(e: &Expr<'_>) -> Option> { - let (root, fields) = field_chain(e); + let FieldChain { root, fields } = field_chain(e); (is_self_path(root) && !fields.is_empty()).then_some(fields) } diff --git a/src/unnamed_tuple.rs b/src/unnamed_tuple.rs new file mode 100644 index 0000000..caef941 --- /dev/null +++ b/src/unnamed_tuple.rs @@ -0,0 +1,492 @@ +use std::collections::{HashMap, HashSet}; +use std::ops::ControlFlow; + +use clippy_utils::visitors::for_each_expr; +use rustc_hir::def::{DefKind, Res}; +use rustc_hir::def_id::DefId; +use rustc_hir::intravisit::FnKind; +use rustc_hir::{ + Body, Expr, ExprKind, FnDecl, LangItem, MatchSource, Node, Pat, PatKind, QPath, StmtKind, +}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::{self, AssocContainer, Ty, TyCtxt}; +use rustc_span::def_id::LocalDefId; +use rustc_span::{Span, Symbol, sym}; + +use crate::baseline::{emit, emit_with_note}; +use crate::hir_shapes::{callee_of, peel_blocks_unsafe}; + +rustc_session::declare_lint! { + /// Flags a crate-private function returning a tuple (bare, or inside an + /// `Option`/`Result`) with two members of one type, when every call site + /// in the crate destructures it on the spot and all of them, across at + /// least two functions, bind the members to the same names. Those names + /// are the members' real names: written at every use, missing only from + /// the type, which is the one place the compiler could keep them attached. + /// As a tuple, `(r, g, b)` and `(r, b, g)` are the same type, so a + /// transposed pattern or return value goes through; when the function's + /// own body already returns the members under those names in another + /// order, the finding says where. + /// + /// Silent when the member types all differ (the compiler already rejects + /// a transposition), when any caller keeps the tuple whole (stores, + /// returns, passes or indexes it with `.0`), binds a member as `_` or + /// under a different name, when fewer than two functions call it, and + /// when the function is exported, referenced as a value, or has its + /// signature fixed by a trait. + pub UNNAMED_TUPLE, + Warn, + "a tuple with same-typed members that every caller destructures under the same names" +} + +/// A `(a, b)` / `Some((a, b))` in return position whose elements are all +/// plain locals or fields, as their names. +struct Returned { + names: Vec, + span: Span, +} + +/// A function returning a tuple with two members of one type. +struct Candidate { + ret_span: Span, + name: Symbol, + /// "`.0` and `.2` are both `f32`". + same: String, + returns: Vec, +} + +#[derive(Default)] +struct Sites { + /// The member names every destructuring site so far agrees on. + names: Option>, + /// The items the destructuring sites sit in. + owners: HashSet, + count: usize, + /// A site kept the tuple whole, left a member unnamed, or disagreed. + opaque: bool, +} + +#[derive(Default)] +pub struct UnnamedTuple { + candidates: HashMap, + sites: HashMap, + /// Referenced other than by a direct call: those callers are invisible. + poisoned: HashSet, +} + +rustc_session::impl_lint_pass!(UnnamedTuple => [UNNAMED_TUPLE]); + +/// What one call site does with the returned tuple. +enum Use { + /// Destructures it, naming every member. + Names(Vec), + /// Never reaches the members: drops the value, tests `is_some()`, takes + /// the `None`/`Err` arm. + Neutral, + /// Anything else: the tuple survives whole or a member goes unnamed. + Opaque, +} + +/// A declared return type that is a tuple of two or more members. +struct TupleReturn<'tcx> { + /// An `Option` or `Result` sits around the tuple. + wrapped: bool, + members: &'tcx [Ty<'tcx>], +} + +/// Read off the signature, not a call's type, so a generic `T` that happens +/// to be a tuple at one call is not one. +fn tuple_return(tcx: TyCtxt<'_>, def: DefId) -> Option> { + let mut out = tcx + .fn_sig(def) + .instantiate_identity() + .skip_normalization() + .output() + .skip_binder(); + let mut wrapped = false; + if let ty::Adt(adt, args) = out.kind() + && (tcx.is_diagnostic_item(sym::Option, adt.did()) + || tcx.is_diagnostic_item(sym::Result, adt.did())) + { + out = args.type_at(0); + wrapped = true; + } + match out.kind() { + ty::Tuple(tys) if tys.len() >= 2 => Some(TupleReturn { + wrapped, + members: tys.as_slice(), + }), + _ => None, + } +} + +/// The first two members of one type, as "`.i` and `.j` are both `T`". +fn same_typed_pair(members: &[Ty<'_>]) -> Option { + members.iter().enumerate().find_map(|(i, a)| { + let j = i + 1 + members[i + 1..].iter().position(|b| a == b)?; + Some(format!("`.{i}` and `.{j}` are both `{a}`")) + }) +} + +fn is_lang_ctor(cx: &LateContext<'_>, variant: Option, items: &[LangItem]) -> bool { + variant + .and_then(|v| cx.tcx.lang_items().from_def_id(v)) + .is_some_and(|item| items.contains(&item)) +} + +/// The expressions a body evaluates to: every `return e` and every tail, +/// through blocks, `if` and `match`. +fn return_values<'tcx>(cx: &LateContext<'tcx>, body: &'tcx Body<'tcx>) -> Vec<&'tcx Expr<'tcx>> { + fn tails<'h>(e: &'h Expr<'h>, out: &mut Vec<&'h Expr<'h>>) { + match e.kind { + ExprKind::Block(b, _) => { + if let Some(t) = b.expr { + tails(t, out); + } + } + ExprKind::DropTemps(inner) => tails(inner, out), + ExprKind::If(_, then, els) => { + tails(then, out); + if let Some(els) = els { + tails(els, out); + } + } + ExprKind::Match(_, arms, _) => { + for arm in arms { + tails(arm.body, out); + } + } + ExprKind::Ret(_) => {} + _ => out.push(e), + } + } + let mut out = Vec::new(); + for_each_expr(cx, body.value, |e| { + if let ExprKind::Ret(Some(v)) = e.kind { + out.push(v); + } + ControlFlow::<()>::Continue(()) + }); + tails(body.value, &mut out); + out +} + +/// `(a, self.b)` or, behind a wrapper, `Some((a, self.b))` / `Ok(..)`: the +/// names the body itself gives the members at this return, when every +/// element is a bare local or field. +fn returned_names(cx: &LateContext<'_>, e: &Expr<'_>, wrapped: bool) -> Option { + let mut e = peel_blocks_unsafe(e); + if wrapped { + let ExprKind::Call(_, [arg]) = e.kind else { + return None; + }; + let variant = crate::enum_facts::ctor_literal_variant(cx, e); + if !is_lang_ctor(cx, variant, &[LangItem::OptionSome, LangItem::ResultOk]) { + return None; + } + e = peel_blocks_unsafe(arg); + } + let ExprKind::Tup(elems) = e.kind else { + return None; + }; + let names = elems + .iter() + .map(|el| match peel_blocks_unsafe(el).kind { + ExprKind::Path(QPath::Resolved(None, p)) if matches!(p.res, Res::Local(_)) => { + Some(p.segments[0].ident.name) + } + ExprKind::Field(_, ident) => Some(ident.name), + _ => None, + }) + .collect::>>()?; + Some(Returned { + names, + span: e.span, + }) +} + +fn pat_use(cx: &LateContext<'_>, pat: &Pat<'_>, wrapped: bool) -> Use { + match pat.kind { + PatKind::Wild => Use::Neutral, + PatKind::Ref(inner, _, _) => pat_use(cx, inner, wrapped), + PatKind::Tuple(pats, dotdot) if !wrapped => { + if dotdot.as_opt_usize().is_some() { + return Use::Opaque; + } + let mut names = Vec::with_capacity(pats.len()); + for p in pats { + let PatKind::Binding(_, _, ident, None) = p.kind else { + return Use::Opaque; + }; + if ident.as_str().starts_with('_') { + return Use::Opaque; + } + names.push(ident.name); + } + Use::Names(names) + } + PatKind::TupleStruct(_, [inner], _) if wrapped => { + let variant = crate::enum_facts::arm_variant(cx, pat); + if is_lang_ctor(cx, variant, &[LangItem::OptionSome, LangItem::ResultOk]) { + pat_use(cx, inner, false) + } else if is_lang_ctor(cx, variant, &[LangItem::ResultErr]) { + Use::Neutral + } else { + Use::Opaque + } + } + PatKind::Expr(_) + if wrapped + && is_lang_ctor( + cx, + crate::enum_facts::arm_variant(cx, pat), + &[LangItem::OptionNone], + ) => + { + Use::Neutral + } + _ => Use::Opaque, + } +} + +/// Folds the uses of several arms over one value: any opaque arm makes the +/// whole opaque, and arms that name the members must agree. +fn merge(uses: impl Iterator) -> Use { + let mut acc = Use::Neutral; + for u in uses { + acc = match (acc, u) { + (Use::Opaque, _) | (_, Use::Opaque) => return Use::Opaque, + (Use::Neutral, u) | (u, Use::Neutral) => u, + (Use::Names(a), Use::Names(b)) if a == b => Use::Names(a), + (Use::Names(_), Use::Names(_)) => return Use::Opaque, + }; + } + acc +} + +/// What the code around `call` does with its value: up through `?`, +/// `.unwrap()`, the `Option`/`Result` adapters that keep the tuple intact and +/// value-carrying blocks, to the pattern that receives it. +fn use_of_call<'tcx>(cx: &LateContext<'tcx>, call: &'tcx Expr<'tcx>, mut wrapped: bool) -> Use { + let mut current = call.hir_id; + for (parent_id, node) in cx.tcx.hir_parent_iter(call.hir_id) { + match node { + Node::LetStmt(l) if l.init.is_some_and(|i| i.hir_id == current) => { + return pat_use(cx, l.pat, wrapped); + } + // `f();`: the value is dropped unread. + Node::Stmt(s) if matches!(s.kind, StmtKind::Semi(e) if e.hir_id == current) => { + return Use::Neutral; + } + Node::Block(b) if b.expr.is_some_and(|e| e.hir_id == current) => {} + Node::Expr(parent) => match parent.kind { + ExprKind::DropTemps(_) | ExprKind::Block(..) => {} + // `call?`: `match Try::branch(call) { Continue(val) => val, .. }`. + ExprKind::Call(_, [arg]) + if wrapped + && arg.hir_id == current + && matches!( + cx.tcx.parent_hir_node(parent_id), + Node::Expr(Expr { + kind: ExprKind::Match(_, _, MatchSource::TryDesugar(_)), + .. + }) + ) => {} + ExprKind::Match(_, _, MatchSource::TryDesugar(_)) => wrapped = false, + ExprKind::MethodCall(seg, recv, _, _) if wrapped && recv.hir_id == current => { + match seg.ident.as_str() { + "unwrap" | "expect" | "unwrap_unchecked" => wrapped = false, + // Still an `Option`/`Result` around the same tuple. + "ok" | "ok_or" | "ok_or_else" | "map_err" => {} + "is_some" | "is_none" | "is_ok" | "is_err" => return Use::Neutral, + _ => return Use::Opaque, + } + } + ExprKind::Match(scrut, arms, MatchSource::Normal | MatchSource::Postfix) + if scrut.hir_id == current => + { + return merge(arms.iter().map(|arm| pat_use(cx, arm.pat, wrapped))); + } + ExprKind::Let(l) if l.init.hir_id == current => return pat_use(cx, l.pat, wrapped), + _ => return Use::Opaque, + }, + _ => return Use::Opaque, + } + current = parent_id; + } + Use::Opaque +} + +fn tuple_of(names: &[Symbol]) -> String { + let names: Vec<&str> = names.iter().map(Symbol::as_str).collect(); + format!("({})", names.join(", ")) +} + +impl UnnamedTuple { + fn record(&mut self, cx: &LateContext<'_>, def: DefId, site: &Expr<'_>, used: Use) { + let sites = self.sites.entry(def).or_default(); + match used { + Use::Neutral => {} + Use::Opaque => sites.opaque = true, + Use::Names(names) => { + sites.count += 1; + sites + .owners + .insert(cx.tcx.hir_get_parent_item(site.hir_id).def_id); + match &sites.names { + None => sites.names = Some(names), + Some(agreed) if *agreed == names => {} + Some(_) => sites.opaque = true, + } + } + } + } +} + +impl<'tcx> LateLintPass<'tcx> for UnnamedTuple { + fn check_fn( + &mut self, + cx: &LateContext<'tcx>, + kind: FnKind<'tcx>, + decl: &'tcx FnDecl<'tcx>, + body: &'tcx Body<'tcx>, + span: Span, + def_id: LocalDefId, + ) { + if matches!(kind, FnKind::Closure) + || span.from_expansion() + || cx.effective_visibilities.is_exported(def_id) + { + return; + } + let def = def_id.to_def_id(); + // A trait method's return type is the trait's to change, not this + // function's. + if let Some(assoc) = cx.tcx.opt_associated_item(def) + && !matches!(assoc.container, AssocContainer::InherentImpl) + { + return; + } + let Some(ret) = tuple_return(cx.tcx, def) else { + return; + }; + let Some(same) = same_typed_pair(ret.members) else { + return; + }; + let returns = return_values(cx, body) + .into_iter() + .filter_map(|e| returned_names(cx, e, ret.wrapped)) + .collect(); + self.candidates.insert( + def, + Candidate { + ret_span: decl.output.span(), + name: cx.tcx.item_name(def), + same, + returns, + }, + ); + } + + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + let Some(callee) = callee_of(cx, expr) else { + // A bare reference to a local fn (fn pointer, higher-order use): + // whatever destructures its result is out of sight. + if let ExprKind::Path(qpath) = &expr.kind + && !matches!( + clippy_utils::get_parent_expr(cx, expr), + Some(Expr { kind: ExprKind::Call(callee, _), .. }) if callee.hir_id == expr.hir_id + ) + && let Res::Def(DefKind::Fn | DefKind::AssocFn, def) = + cx.qpath_res(qpath, expr.hir_id) + && def.is_local() + { + self.poisoned.insert(def); + } + return; + }; + let def = callee.def(); + if !def.is_local() || !matches!(cx.tcx.def_kind(def), DefKind::Fn | DefKind::AssocFn) { + return; + } + let Some(ret) = tuple_return(cx.tcx, def) else { + return; + }; + let used = if expr.span.from_expansion() { + Use::Opaque + } else { + use_of_call(cx, expr, ret.wrapped) + }; + self.record(cx, def, expr, used); + } + + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { + let mut findings: Vec<(Span, String, Option)> = Vec::new(); + for (def, cand) in &self.candidates { + if self.poisoned.contains(def) { + continue; + } + let Some(sites) = self.sites.get(def) else { + continue; + }; + let Some(names) = &sites.names else { + continue; + }; + if sites.opaque || sites.count < 2 || sites.owners.len() < 2 { + continue; + } + let lead = format!( + "all {} call sites of `{}`, in {} functions, destructure this as `{}`", + sites.count, + cand.name, + sites.owners.len(), + tuple_of(names), + ); + // The body spelling the same names in another order is the + // transposition itself, on one side or the other. + let transposed = cand.returns.iter().find(|r| { + r.names != *names && { + let (mut a, mut b) = (r.names.clone(), names.clone()); + a.sort(); + b.sort(); + a == b + } + }); + let (msg, note) = match transposed { + Some(r) => ( + format!( + "{lead}, but the body returns them as `{}`; {}, so both orders type-check and one of them is wrong", + tuple_of(&r.names), + cand.same, + ), + Some(r.span), + ), + None => ( + format!( + "{lead}: the members are named everywhere except in the type; {}, so transposing them still type-checks", + cand.same, + ), + None, + ), + }; + findings.push((cand.ret_span, msg, note)); + } + // `candidates` is a HashMap; report in source order. + findings.sort_by_key(|(span, ..)| span.lo()); + let help = "return a struct with these fields: the names move into the signature, and members of one type can no longer trade places"; + for (span, msg, note) in findings { + match note { + Some(note) => emit_with_note( + cx, + UNNAMED_TUPLE, + span, + msg, + note, + "returned in this order here", + help, + ), + None => emit(cx, UNNAMED_TUPLE, span, msg, help), + } + } + } +} diff --git a/ui/unnamed_tuple.rs b/ui/unnamed_tuple.rs new file mode 100644 index 0000000..51539db --- /dev/null +++ b/ui/unnamed_tuple.rs @@ -0,0 +1,246 @@ +// A private fn returning a tuple with same-typed members that every caller +// destructures under the same names: the names exist everywhere except in +// the type, which is the one place that would stop a transposition. + +struct Node { + depth: u32, + width: u32, +} + +// Flagged: both callers, in two functions, bind the members `(depth, width)`; +// both are `u32`, so nothing but the names keeps them apart. +fn measure(n: &Node) -> (u32, u32) { + (n.depth + 1, n.width * 2) +} + +fn taller(a: &Node, b: &Node) -> bool { + let (depth, width) = measure(a); + depth > width && depth > b.depth +} + +fn wider(a: &Node) -> u32 { + let (depth, width) = measure(a); + width.saturating_sub(depth) +} + +// Flagged, with the evidence: both callers bind `(depth, width)` and the +// tail returns them in that order, but the early return spells +// `(width, depth)`. One of the two orders is a bug and the type accepts both. +fn clamped(n: &Node, max: u32) -> (u32, u32) { + let depth = n.depth.min(max); + let width = n.width.min(max); + if depth == max { + return (width, depth); + } + (depth, width) +} + +fn clamped_area(n: &Node) -> u32 { + let (depth, width) = clamped(n, 10); + depth * width +} + +fn clamped_ratio(n: &Node) -> u32 { + let (depth, width) = clamped(n, 100); + depth / width.max(1) +} + +// Flagged: the `Option` around the tuple is read through `?`, `if let`, +// `match` and `.unwrap()`, and every pattern that reaches the tuple names it +// `(key, value)`; `None` arms and `is_none()` never reach it. +fn split(s: &str) -> Option<(&str, &str)> { + s.split_once('=') +} + +fn key_len(s: &str) -> Option { + if split(s).is_none() { + return None; + } + let (key, value) = split(s)?; + Some(key.len() + value.len()) +} + +fn has_value(s: &str) -> bool { + if let Some((key, value)) = split(s) { + return !key.is_empty() && !value.is_empty(); + } + match split(s) { + Some((key, value)) => key < value, + None => false, + } +} + +fn value_of(s: &str) -> &str { + let (key, value) = split(s).unwrap(); + if key.is_empty() { s } else { value } +} + +struct Table { + rows: Vec<(u8, u8)>, +} + +struct Unbounded; + +impl Table { + // Flagged: an inherent method behind `Result`, read through `map_err`, + // `?` and `let .. else`. + fn bounds(&self) -> Result<(u8, u8), ()> { + self.rows.first().copied().ok_or(()) + } + + fn span(&self) -> Result { + let (low, high) = self.bounds().map_err(|()| Unbounded)?; + Ok(high - low) + } + + fn low(&self) -> u8 { + let Ok((low, high)) = self.bounds() else { + return 0; + }; + low.min(high) + } +} + +// Fine: the members differ in type, so a transposed pattern or return is +// already a type error and the tuple loses nothing a struct would keep. +fn indexed(n: u32) -> (usize, u32) { + (n as usize, n) +} + +fn indexed_sum(n: u32) -> usize { + let (index, value) = indexed(n); + index + value as usize +} + +fn indexed_gap(n: u32) -> usize { + let (index, value) = indexed(n); + index - value as usize +} + +// Fine: the callers disagree (`(lo, hi)` here, `(start, end)` there), so +// neither pair is the members' name. +fn range(n: u32) -> (u32, u32) { + (n, n + 10) +} + +fn range_lo(n: u32) -> u32 { + let (lo, hi) = range(n); + lo.min(hi) +} + +fn range_start(n: u32) -> u32 { + let (start, end) = range(n); + start.max(end) +} + +// Fine: one caller keeps the tuple whole (`.0`), so the type is used as one. +fn halves(n: u32) -> (u32, u32) { + (n / 2, n - n / 2) +} + +fn halves_sum(n: u32) -> u32 { + let (left, right) = halves(n); + left + right +} + +fn halves_left(n: u32) -> u32 { + let (left, right) = halves(n); + left * right + halves(n).0 +} + +// Fine: both destructuring sites sit in one function; one function's habit +// is not the crate's vocabulary. +fn corners(n: u32) -> (u32, u32) { + (n, n * n) +} + +fn corners_twice(n: u32) -> u32 { + let (near, far) = corners(n); + let (near2, far2) = { + let (near, far) = corners(n + 1); + (near, far) + }; + near + far + near2 + far2 +} + +// Fine: a member is bound as `_` at one site, so that site names nothing. +fn parts(n: u32) -> (u32, u32) { + (n, n + 1) +} + +fn parts_head(n: u32) -> u32 { + let (head, _) = parts(n); + head +} + +fn parts_both(n: u32) -> u32 { + let (head, tail) = parts(n); + head + tail +} + +// Fine: the function is also passed as a value, so some destructuring sites +// are out of sight. +fn pair(n: u32) -> (u32, u32) { + (n, n) +} + +fn pair_a(n: u32) -> u32 { + let (first, second) = pair(n); + first + second +} + +fn pair_b(n: u32) -> u32 { + let (first, second) = pair(n); + let f: fn(u32) -> (u32, u32) = pair; + first * second + f(n).1 +} + +// Fine: a trait fixes the return type; the impl cannot change it alone. +trait Cursor { + fn position(&self) -> (usize, usize); +} + +impl Cursor for Table { + fn position(&self) -> (usize, usize) { + (self.rows.len(), 0) + } +} + +fn line_of(t: &Table) -> usize { + let (line, column) = t.position(); + line + column +} + +fn column_of(t: &Table) -> usize { + let (line, column) = t.position(); + column.saturating_sub(line) +} + +// Fine: exported, so callers outside the crate are invisible. +pub mod api { + pub fn extent(n: usize) -> (usize, usize) { + (n, n * 2) + } +} + +fn extent_a(n: usize) -> usize { + let (offset, len) = api::extent(n); + offset + len +} + +fn extent_b(n: usize) -> usize { + let (offset, len) = api::extent(n); + offset * len +} + +fn main() { + let n = Node { depth: 1, width: 2 }; + let t = Table { rows: vec![(1, 2)] }; + let _ = (taller(&n, &n), wider(&n), clamped_area(&n), clamped_ratio(&n)); + let _ = (key_len("a=b"), has_value("a=b"), value_of("a=b")); + let _ = (t.span(), t.low()); + let _ = range_lo(1) + range_start(2) + halves_sum(3) + halves_left(4) + indexed_sum(5) as u32; + let _ = indexed_gap(9); + let _ = corners_twice(5) + parts_head(6) + parts_both(7) + pair_a(8) + pair_b(9); + let _ = line_of(&t) + column_of(&t) + extent_a(1) + extent_b(2); +} diff --git a/ui/unnamed_tuple.stderr b/ui/unnamed_tuple.stderr new file mode 100644 index 0000000..ac4830b --- /dev/null +++ b/ui/unnamed_tuple.stderr @@ -0,0 +1,40 @@ +warning: all 2 call sites of `measure`, in 2 functions, destructure this as `(depth, width)`: the members are named everywhere except in the type; `.0` and `.1` are both `u32`, so transposing them still type-checks + --> $DIR/unnamed_tuple.rs:12:25 + | +LL | fn measure(n: &Node) -> (u32, u32) { + | ^^^^^^^^^^ + | + = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places + = note: `#[warn(unnamed_tuple)]` on by default + +warning: all 2 call sites of `clamped`, in 2 functions, destructure this as `(depth, width)`, but the body returns them as `(width, depth)`; `.0` and `.1` are both `u32`, so both orders type-check and one of them is wrong + --> $DIR/unnamed_tuple.rs:29:35 + | +LL | fn clamped(n: &Node, max: u32) -> (u32, u32) { + | ^^^^^^^^^^ + | +note: returned in this order here + --> $DIR/unnamed_tuple.rs:33:16 + | +LL | return (width, depth); + | ^^^^^^^^^^^^^^ + = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places + +warning: all 4 call sites of `split`, in 3 functions, destructure this as `(key, value)`: the members are named everywhere except in the type; `.0` and `.1` are both `&str`, so transposing them still type-checks + --> $DIR/unnamed_tuple.rs:51:22 + | +LL | fn split(s: &str) -> Option<(&str, &str)> { + | ^^^^^^^^^^^^^^^^^^^^ + | + = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places + +warning: all 2 call sites of `bounds`, in 2 functions, destructure this as `(low, high)`: the members are named everywhere except in the type; `.0` and `.1` are both `u8`, so transposing them still type-checks + --> $DIR/unnamed_tuple.rs:87:25 + | +LL | fn bounds(&self) -> Result<(u8, u8), ()> { + | ^^^^^^^^^^^^^^^^^^^^ + | + = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places + +warning: 4 warnings emitted + From 4defed79fde55c8b52b25b73185d669481b1ab0b Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:33:09 -0700 Subject: [PATCH 18/34] misbound_arg stays quiet when the namesake slot holds a literal --- src/misbound_arg.rs | 50 ++++++++++++++++++++++++++++++++++-------- ui/misbound_arg.rs | 15 +++++++++++-- ui/misbound_arg.stderr | 10 ++++----- 3 files changed, 59 insertions(+), 16 deletions(-) diff --git a/src/misbound_arg.rs b/src/misbound_arg.rs index 947680b..a6e45ed 100644 --- a/src/misbound_arg.rs +++ b/src/misbound_arg.rs @@ -26,11 +26,13 @@ rustc_session::declare_lint! { /// dropping a leading `is_`/`has_`/`_` and a trailing `_`, when the /// parameter the name points at already receives an argument of that /// name, when the bound parameter's name contains the argument's as a - /// word (`from_index` receiving `index`), when the same condition also - /// calls the callee the other way round (`sub(a, b) && sub(b, a)` is a - /// symmetric use), for `self`/`this` on either side (a receiver slot is - /// a grammatical position, not a role), for one-character names, and - /// for calls through closures or fn pointers. + /// word (`from_index` receiving `index`), when the parameter the name + /// points at receives a literal or constant (no second value is there + /// to have been transposed with), when the same condition also calls + /// the callee with the two values the other way round (`sub(a, b) && + /// sub(b, a)` is a symmetric use), for `self`/`this` on either side (a + /// receiver slot is a grammatical position, not a role), for + /// one-character names, and for calls through closures or fn pointers. pub MISBOUND_ARG, Warn, "argument named as a different same-typed parameter of the callee" @@ -78,6 +80,29 @@ fn received(args: &[Expr<'_>], offset: usize, slot: usize) -> Option { role(arg_name(arg)?) } +/// A literal or a named constant: spelled out at the call, so it cannot be +/// the value a neighbouring argument was confused with. +fn is_constant(cx: &LateContext<'_>, mut e: &Expr<'_>) -> bool { + while let ExprKind::Cast(inner, _) + | ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(UnOp::Neg, inner) + | ExprKind::DropTemps(inner) = e.kind + { + e = inner; + } + match &e.kind { + ExprKind::Lit(_) => true, + ExprKind::Path(qpath) => matches!( + cx.qpath_res(qpath, e.hir_id), + Res::Def( + DefKind::Const { .. } | DefKind::AssocConst { .. } | DefKind::ConstParam, + _ + ) + ), + _ => false, + } +} + fn args_of<'tcx>(callee: &Callee<'tcx>) -> (&'tcx [Expr<'tcx>], usize) { match *callee { Callee::Path { args, .. } => (args, 0), @@ -87,8 +112,8 @@ fn args_of<'tcx>(callee: &Callee<'tcx>) -> (&'tcx [Expr<'tcx>], usize) { } /// `f(a, b) && f(b, a)`: the condition `expr` sits in also calls `def` -/// with slots `slot` and `other` receiving their own names, so the crossed -/// call is the deliberate other half of a symmetric test. +/// with the same two names in slots `slot` and `other` the other way round, +/// so the crossed call is the deliberate second half of a symmetric test. fn mirrored_in_condition<'tcx>( cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>, @@ -192,8 +217,15 @@ impl<'tcx> LateLintPass<'tcx> for MisboundArg { }; // `f(name, name)`: the namesake parameter already gets its name, // so nothing is transposed, one value fills two roles. - if received(args, offset, other) == Some(an) - || mirrored_in_condition(cx, expr, def, (slot, bn), (other, an)) + let at_other = received(args, offset, other); + if at_other == Some(an) + || other + .checked_sub(offset) + .and_then(|i| args.get(i)) + .is_some_and(|a| is_constant(cx, a)) + || at_other.is_some_and(|there| { + mirrored_in_condition(cx, expr, def, (slot, there), (other, an)) + }) { continue; } diff --git a/ui/misbound_arg.rs b/ui/misbound_arg.rs index 04c3cc6..14ce69a 100644 --- a/ui/misbound_arg.rs +++ b/ui/misbound_arg.rs @@ -45,6 +45,10 @@ impl Canvas { } } +fn with_alias(from: &str, version: &str) -> usize { + from.len() + version.len() +} + fn under(url: &str, registry: &str) -> bool { url.starts_with(registry) } @@ -123,9 +127,15 @@ fn unnamed_args_are_fine(d: &Daemon) -> bool { spawn(true, d.detached) } -fn symmetric_pair_is_fine(url: &str, registry: &str) -> bool { +fn symmetric_pair_is_fine(url: &str, scope_registry: &str) -> bool { // Fine: both orders in one condition is an equality test, not a slip. - !(under(url, registry) && under(registry, url)) + !(under(url, scope_registry) && under(scope_registry, url)) +} + +fn literal_namesake_is_fine(version: &str) -> usize { + // Fine: `version` fills `from`, but the `version` slot holds a literal, + // so there is no second value it could have been transposed with. + with_alias(version, "latest") } fn pseudo_receiver_is_fine(str: &[u8]) -> bool { @@ -159,5 +169,6 @@ fn main() { let _ = lone_reversal_is_flagged("u", "r"); let _ = symmetric_pair_is_fine("u", "r"); let _ = pseudo_receiver_is_fine(b"s"); + let _ = literal_namesake_is_fine("v"); let _ = Span(1, 2).encloses(&Span(0, 3)); } diff --git a/ui/misbound_arg.stderr b/ui/misbound_arg.stderr index 3ba7afd..62ed582 100644 --- a/ui/misbound_arg.stderr +++ b/ui/misbound_arg.stderr @@ -1,5 +1,5 @@ warning: arguments `height` and `width` are bound to `resize`'s parameters `width` and `height`; all are `u32`, so the transposition type-checks - --> $DIR/misbound_arg.rs:71:5 + --> $DIR/misbound_arg.rs:75:5 | LL | resize(height, width) | ^^^^^^^^^^^^^^^^^^^^^ @@ -8,7 +8,7 @@ LL | resize(height, width) = note: `#[warn(misbound_arg)]` on by default warning: arguments `inherit_stderr` and `inherit_stdout` are bound to `spawn`'s parameters `inherit_stdout` and `inherit_stderr`; all are `bool`, so the transposition type-checks - --> $DIR/misbound_arg.rs:76:5 + --> $DIR/misbound_arg.rs:80:5 | LL | spawn(opts.inherit_stderr, opts.inherit_stdout) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -16,7 +16,7 @@ LL | spawn(opts.inherit_stderr, opts.inherit_stdout) = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: argument `column` is bound to `place`'s parameter `line`, but `place` also takes a parameter `column` of the same type `u32` - --> $DIR/misbound_arg.rs:81:11 + --> $DIR/misbound_arg.rs:85:11 | LL | place(column, extra, 0) | ^^^^^^ @@ -24,7 +24,7 @@ LL | place(column, extra, 0) = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: arguments `dst` and `src` are bound to `blit`'s parameters `src` and `dst`; all are `usize`, so the transposition type-checks - --> $DIR/misbound_arg.rs:86:5 + --> $DIR/misbound_arg.rs:90:5 | LL | c.blit(dst, src) | ^^^^^^^^^^^^^^^^ @@ -32,7 +32,7 @@ LL | c.blit(dst, src) = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: arguments `registry` and `url` are bound to `under`'s parameters `url` and `registry`; all are `&str`, so the transposition type-checks - --> $DIR/misbound_arg.rs:91:5 + --> $DIR/misbound_arg.rs:95:5 | LL | under(registry, url) | ^^^^^^^^^^^^^^^^^^^^ From 5ee8690d51aeab5ea635c4bb64147b86c3187969 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:36:18 -0700 Subject: [PATCH 19/34] parallel_params counts plain data handed between functions --- README.md | 4 +- src/lib.rs | 6 +-- src/parallel_params.rs | 84 +++++++++++++++++++++++++++++---------- ui/parallel_params.rs | 49 ++++++++++++++++++----- ui/parallel_params.stderr | 6 +-- 5 files changed, 111 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 9adc2b4..7f0c837 100644 --- a/README.md +++ b/README.md @@ -107,8 +107,8 @@ stringly-error-include-box-dyn = true # `parallel_params` are surveys to run once over a codebase (most of what they # name is legitimate once the real cases are fixed; for the third, a length the # caller vouches for that the function also uses as some other value's limit; -# for the last, a context and the position it reports at, passed along together -# by design), so they are off until turned on here. +# for the last, a buffer and a cursor into it, passed along together by +# design), so they are off until turned on here. flag-cluster-enabled = true stale-safety-comment-enabled = true unchecked-input-len-enabled = true diff --git a/src/lib.rs b/src/lib.rs index 159e95e..556ceaa 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -162,9 +162,9 @@ pub struct MordantConfig { /// which nothing inside the function tells from a missed check; run it /// once over parsing code and read the list. pub unchecked_input_len_enabled: bool, - /// Opt-in: run `parallel_params`. Off by default because a context and - /// the position it reports at, or a pointer and its length before a slice - /// exists, pass between functions together by design, and nothing in the + /// Opt-in: run `parallel_params`. Off by default because a buffer and a + /// cursor into it, or a precedence level and the flags in force at it, + /// pass between functions together by design, and nothing in the /// signatures tells those from a value nobody declared; run it once and /// read the list. pub parallel_params_enabled: bool, diff --git a/src/parallel_params.rs b/src/parallel_params.rs index 64757e2..be8932b 100644 --- a/src/parallel_params.rs +++ b/src/parallel_params.rs @@ -10,6 +10,7 @@ use rustc_hir::intravisit::FnKind; use rustc_hir::{Body, Expr, ExprKind, FnDecl, HirId, Impl, ItemKind, Node, PatKind, UnOp}; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::print::with_no_trimmed_paths; +use rustc_middle::ty::{self, Mutability, Ty}; use rustc_span::def_id::LocalDefId; use rustc_span::symbol::kw; use rustc_span::{Span, Symbol}; @@ -19,26 +20,31 @@ use crate::baseline::emit; use crate::hir_shapes::{Callee, callee_of}; rustc_session::declare_lint! { - /// Flags two or more parameters that `parallel-params-min-fns` or more - /// crate-private functions declare under the same names and types and - /// pass among themselves: every counted function hands the group, - /// unchanged and in one call, to another of them, or receives it that - /// way. The group arrives together, is checked together and leaves + /// Flags two or more plain-data parameters that `parallel-params-min-fns` + /// or more crate-private functions declare under the same names and + /// types and pass among themselves: every counted function hands the + /// group, unchanged and in one call, to another of them, or receives it + /// that way. The group arrives together, is checked together and leaves /// together: it is one value, and the only place it has no name is the /// type system, so nothing keeps a caller from passing half of it, or two /// halves of different wholes. /// - /// Stays quiet on exported functions, trait methods and their impls, - /// non-Rust ABIs, `#[no_mangle]` items and any function also used as a - /// value (a fn pointer's signature is fixed by its type), on `self`, on - /// `_`-prefixed parameters, on functions that merely declare the same - /// pair without handing it on, and whenever the callee renames or retypes - /// what it receives — that call is a translation, not a hand-off. + /// Plain data is scalars, `&str`, shared slices, and structs and enums + /// made only of those. `&mut` borrows, references and pointers to + /// structs, trait objects, type parameters and owned buffers never count: + /// they are the contexts, sinks and resources functions thread through by + /// design, and bundling those is a different refactor. Also quiet on + /// exported functions, trait methods and their impls, non-Rust ABIs, + /// `#[no_mangle]` items and any function also used as a value (a fn + /// pointer's signature is fixed by its type), on `self`, on `_`-prefixed + /// parameters, on functions that merely declare the same pair without + /// handing it on, and whenever the callee renames or retypes what it + /// receives — that call is a translation, not a hand-off. /// /// Runs only with `parallel-params-enabled = true` in `dylint.toml`: a - /// context and the position it reports at, or a pointer and its length - /// before a slice exists, travel together by design, and nothing in the - /// signatures tells those from an undeclared struct. + /// buffer and a cursor into it, or a level and the flags in force at it, + /// travel together by design, and nothing in the signatures tells those + /// from an undeclared struct. pub PARALLEL_PARAMS, Warn, "parameters that several functions declare and forward as a group" @@ -107,6 +113,32 @@ fn forwarded_local(mut arg: &Expr<'_>) -> Option { arg.res_local_id() } +/// Plain data all the way down: scalars, `&str`, shared slices and arrays +/// of plain data, and structs and enums whose every field is. A reference or +/// pointer to a struct, a `&mut` borrow, a trait object, a type parameter or +/// an owned buffer is a context, sink or resource a function threads through +/// by design; a group of those is not a value nobody declared. +fn plain_data<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>, seen: &mut HashSet>) -> bool { + match ty.kind() { + ty::Bool | ty::Char | ty::Int(_) | ty::Uint(_) | ty::Float(_) | ty::Str => true, + ty::Array(elem, _) | ty::Slice(elem) => plain_data(cx, *elem, seen), + ty::Tuple(tys) => tys.iter().all(|t| plain_data(cx, t, seen)), + ty::Ref(_, inner, Mutability::Not) => { + matches!(inner.kind(), ty::Slice(_) | ty::Str) && plain_data(cx, *inner, seen) + } + ty::Adt(adt, args) => { + // A type reached again through its own fields adds no new field + // kinds; the first visit decides. + if !seen.insert(ty) { + return true; + } + adt.all_fields() + .all(|f| plain_data(cx, f.ty(cx.tcx, args).skip_normalization(), seen)) + } + _ => false, + } +} + /// A signature the crate is free to change: not exported, not extern, not /// dictated by a trait. fn owns_signature(cx: &LateContext<'_>, kind: FnKind<'_>, def_id: LocalDefId) -> bool { @@ -160,6 +192,9 @@ impl<'tcx> LateLintPass<'tcx> for ParallelParams { let ty = cx .tcx .erase_and_anonymize_regions(cx.typeck_results().pat_ty(param.pat)); + if !plain_data(cx, ty, &mut HashSet::new()) { + return None; + } let written = decl .inputs .get(i) @@ -327,18 +362,25 @@ impl<'tcx> LateLintPass<'tcx> for ParallelParams { }; witnesses.sort_by_key(|f| f.span.lo()); let witness = witnesses[0]; - let names: Vec = sig - .iter() - .map(|def| format!("`{}`", cx.tcx.item_name(*def))) - .collect(); + // Two methods of different types often share a bare name; those + // get their path so the list names each function once. + let bare = |def: &DefId| cx.tcx.item_name(*def); + let name = |def: &DefId| { + if sig.iter().filter(|d| bare(d) == bare(def)).count() > 1 { + format!("`{}`", cx.tcx.def_path_str(*def)) + } else { + format!("`{}`", bare(def)) + } + }; + let names: Vec = sig.iter().map(name).collect(); findings.push(( at, format!( - "parameters {} pass unchanged between {} (`{}` hands them to `{}` in one call): one value travelling as {} parameters", + "parameters {} pass unchanged between {} ({} hands them to {} in one call): one value travelling as {} parameters", join(&shown), listed(&names), - cx.tcx.item_name(witness.from), - cx.tcx.item_name(witness.to), + name(&witness.from), + name(&witness.to), slots.len(), ), )); diff --git a/ui/parallel_params.rs b/ui/parallel_params.rs index ffefa86..4b888bb 100644 --- a/ui/parallel_params.rs +++ b/ui/parallel_params.rs @@ -20,26 +20,55 @@ fn checksum(w: u32, h: u32, px: &[u8]) -> u64 { u64::from(w) * u64::from(h) + px.len() as u64 } -// Flagged: three parameters through methods; the receiver is not one of them, -// and `&mut *sink` is still `sink`. +// Flagged: `host`, `port` and `tls` through methods; the receiver is not one +// of them, and neither is the `&mut` sink, a context threaded through by +// design rather than part of the value. +#[derive(Clone, Copy)] +enum Tls { + Off, + On, +} + struct Conn { retries: u8, } impl Conn { - fn send(&self, host: &str, port: u16, sink: &mut Vec) { + fn send(&self, host: &str, port: u16, tls: Tls, sink: &mut Vec) { for _ in 0..self.retries { - self.dial(host, port, &mut *sink); + self.dial(host, port, tls, &mut *sink); } } - fn dial(&self, host: &str, port: u16, sink: &mut Vec) { - log_target(host, port, sink); + fn dial(&self, host: &str, port: u16, tls: Tls, sink: &mut Vec) { + log_target(host, port, tls, sink); } } -fn log_target(host: &str, port: u16, sink: &mut Vec) { - sink.push(format!("{host}:{port}")); +fn log_target(host: &str, port: u16, tls: Tls, sink: &mut Vec) { + sink.push(format!("{host}:{port} {}", tls as u8)); +} + +// Fine: a builder and the log it reports to are both contexts, not data, so +// there is no value here to name however many functions pass them along. +struct Builder { + depth: u32, +} + +fn lower_block(builder: &mut Builder, log: &mut Vec, stmts: &[u8]) { + for s in stmts { + lower_stmt(builder, log, *s); + } +} + +fn lower_stmt(builder: &mut Builder, log: &mut Vec, stmt: u8) { + builder.depth += 1; + lower_expr(builder, log, stmt / 2); + builder.depth -= 1; +} + +fn lower_expr(builder: &mut Builder, log: &mut Vec, expr: u8) { + log.push(format!("{} at depth {}", expr, builder.depth)); } // Fine: only `derive` and `mix` declare `key` and `salt`; the threshold is @@ -144,7 +173,9 @@ pub fn probe(path: &str, mode: u32) -> usize { fn main() { let _ = decode(&[1, 2, 3], 4, 4); let mut log = Vec::new(); - Conn { retries: 2 }.send("h", 80, &mut log); + Conn { retries: 2 }.send("h", 80, Tls::Off, &mut log); + Conn { retries: 2 }.send("h", 443, Tls::On, &mut log); + lower_block(&mut Builder { depth: 0 }, &mut log, b"xy"); let _ = derive(b"k", b"s"); let _ = (clamp(5, 0, 9), within(5, 0, 9), width(0, 9)); let _ = (copy_range(b"abc", 0, 2), check_range(b"abc", 0, 2)); diff --git a/ui/parallel_params.stderr b/ui/parallel_params.stderr index e617ecb..5523845 100644 --- a/ui/parallel_params.stderr +++ b/ui/parallel_params.stderr @@ -7,10 +7,10 @@ LL | fn decode(src: &[u8], w: u32, h: u32) -> Vec { = help: a struct with these fields names the value; each function then takes, checks and forwards one parameter = note: `#[warn(parallel_params)]` on by default -warning: parameters `host: &str`, `port: u16` and `sink: &mut Vec` pass unchanged between `send`, `dial` and `log_target` (`send` hands them to `dial` in one call): one value travelling as 3 parameters - --> $DIR/parallel_params.rs:30:20 +warning: parameters `host: &str`, `port: u16` and `tls: Tls` pass unchanged between `send`, `dial` and `log_target` (`send` hands them to `dial` in one call): one value travelling as 3 parameters + --> $DIR/parallel_params.rs:37:20 | -LL | fn send(&self, host: &str, port: u16, sink: &mut Vec) { +LL | fn send(&self, host: &str, port: u16, tls: Tls, sink: &mut Vec) { | ^^^^^^^^^^ | = help: a struct with these fields names the value; each function then takes, checks and forwards one parameter From aaf0785ff0880a11dab8f6ce2c7b0666132c95be Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:43:34 -0700 Subject: [PATCH 20/34] bypassed_conversion matches the transmute's own types --- src/bypassed_conversion.rs | 356 +++++++++++++++++++++++----------- src/lib.rs | 2 +- ui/bypassed_conversion.rs | 138 +++++++++++++ ui/bypassed_conversion.stderr | 121 +++++++++++- ui/bypassed_validator.rs | 1 - ui/bypassed_validator.stderr | 26 +-- 6 files changed, 503 insertions(+), 141 deletions(-) diff --git a/src/bypassed_conversion.rs b/src/bypassed_conversion.rs index 02b1c08..f7d9d3b 100644 --- a/src/bypassed_conversion.rs +++ b/src/bypassed_conversion.rs @@ -1,9 +1,13 @@ +use std::collections::HashMap; + use crate::adt_facts::in_own_code_of; use crate::baseline::{emit, emit_with_note}; +use crate::ctor_flow; use crate::hir_shapes::callee_of; use rustc_hir::def_id::DefId; use rustc_hir::{Expr, ExprKind}; use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::fast_reject::DeepRejectCtxt; use rustc_middle::ty::{self, Ty, TypeVisitableExt}; use rustc_span::{Symbol, sym}; @@ -14,19 +18,24 @@ rustc_session::declare_lint! { /// `p.cast::()`) between different pointee types -- when a conversion /// from that same source type to that same target type already exists: /// an `impl From for B`, an `impl TryFrom for B`, or a safe - /// receiver-less associated function of `B` taking one `A` and returning - /// `B`, `Option` or `Result`. That function is where the crate - /// decided which `A` values are a `B` and how; the reinterpreting site - /// takes any bit pattern, so whatever the conversion rejects or remaps - /// arrives as a `B` anyway, and for an enum an unlisted discriminant is - /// undefined behaviour on the spot. + /// receiver-less associated function of `B` taking one `A` (or `&A`) and + /// returning `B`, `Option` or `Result`, by value or by + /// reference. That function is where the crate decided which `A` values + /// are a `B` and how; the reinterpreting site takes any bit pattern, so + /// whatever the conversion rejects or remaps arrives as a `B` anyway, and + /// for an enum an unlisted discriminant is undefined behaviour on the + /// spot. /// /// For a transmute of a value, every integer type counts as the same /// source: `transmute::(n as u16)` had to pick the repr width, /// and `E::from_raw(n: u32)` is still the conversion it skipped. For a /// pointer cast the pointee must be exactly the conversion's input type, /// so a byte buffer viewed as a header is not matched against - /// `Header::new(u32)`. + /// `Header::new(u32)`, and a constructor returning a reference + /// (`NameRef::new(&[u8]) -> Option<&NameRef>`) is the checked form of a + /// pointer cast from that pointee only. A conversion for one + /// instantiation of a generic target (`From for Id`) says + /// nothing about another (`Id`). /// /// Silent on: anything in the target type's own module or in any impl of /// it, trait impls included, since that code is the conversion or sits @@ -34,10 +43,13 @@ rustc_session::declare_lint! { /// between the same type; a target that is not an ADT (fn pointers, /// integers, type parameters); a pointer cast into a type with interior /// mutability, which views the pointee in place (`usize` as - /// `AtomicUsize`) where a by-value `From` would make a new cell; a type - /// nothing converts into, which has no check to bypass; `unsafe fn` - /// constructors, which promise no check; and conversions whose input is - /// the target type itself or generic. + /// `AtomicUsize`) where a by-value `From` would make a new cell, unless + /// a constructor returns that view itself; a type nothing converts + /// into, which has no check to bypass; `unsafe fn` and unstable + /// constructors, which promise no check or cannot be called; conversions + /// whose input is the target type itself or generic; and a + /// `mem::transmute` into a struct `bypassed_validator` reports, which + /// names the check the value skipped. pub BYPASSED_CONVERSION, Warn, "bits reinterpreted as a type that has a conversion from the same source, which the site skips" @@ -46,12 +58,17 @@ rustc_session::declare_lint! { /// One way the target type's crate (or a trait impl anywhere) turns `from` /// into the target. struct Conversion<'tcx> { + /// The input as written, references included: what the message names. from: Ty<'tcx>, def: DefId, /// `Level::try_from`, `Code::from_raw`: how the message names it. name: String, /// Returns `Option`/`Result`: it can refuse a value, not just remap one. fallible: bool, + /// Returns a reference to the target: a view of the input in place, the + /// checked form of a pointer cast even into a type with interior + /// mutability. + views: bool, } /// How the site got from one type to the other; decides whether integer @@ -62,7 +79,14 @@ enum Shape { Pointer, } -rustc_session::declare_lint_pass!(BypassedConversion => [BYPASSED_CONVERSION]); +pub struct BypassedConversion { + resource_errors: Vec, + /// Local struct -> whether `bypassed_validator` knows a validating + /// constructor for it, so a `mem::transmute` into it is that lint's. + validated: HashMap, +} + +rustc_session::impl_lint_pass!(BypassedConversion => [BYPASSED_CONVERSION]); const TRANSMUTE: &str = "`mem::transmute`"; const TRANSMUTE_COPY: &str = "`mem::transmute_copy`"; @@ -88,100 +112,174 @@ fn transmuter(cx: &LateContext<'_>, def: DefId) -> Option<&'static str> { /// pointees, `usize -> *const B` compares nothing. fn peel_pointer_pair<'tcx>(mut a: Ty<'tcx>, mut b: Ty<'tcx>) -> (Ty<'tcx>, Ty<'tcx>, Shape) { let mut shape = Shape::Value; - loop { - let pointee = |t: Ty<'tcx>| match *t.kind() { - ty::Ref(_, inner, _) | ty::RawPtr(inner, _) => Some(inner), - _ => None, - }; - match (pointee(a), pointee(b)) { - (Some(pa), Some(pb)) => { - a = pa; - b = pb; - shape = Shape::Pointer; - } - _ => return (a, b, shape), - } + while let (Some(pa), Some(pb)) = (pointee(a), pointee(b)) { + a = pa; + b = pb; + shape = Shape::Pointer; } + (a, b, shape) } -/// Reports `expr`, which turns a `from` into a `to` by reinterpretation -/// (`how` names the means), when a conversion between the same pair exists -/// and the site is not the target type's own code. -fn check_reinterpretation<'tcx>( - cx: &LateContext<'tcx>, - expr: &'tcx Expr<'tcx>, - from: Ty<'tcx>, - to: Ty<'tcx>, - how: &'static str, -) { - if expr.span.in_external_macro(cx.tcx.sess.source_map()) { - return; +fn pointee(t: Ty<'_>) -> Option> { + match *t.kind() { + ty::Ref(_, inner, _) | ty::RawPtr(inner, _) => Some(inner), + _ => None, } - let tcx = cx.tcx; - let (from, to, shape) = peel_pointer_pair( - tcx.erase_and_anonymize_regions(from), - tcx.erase_and_anonymize_regions(to), - ); - if from == to { - return; +} + +impl<'tcx> Conversion<'tcx> { + /// Whether this conversion is the checked form of a site turning `from` + /// into the target by `shape`. A conversion taking `&A` still decides + /// how an `A` becomes the target; one returning a reference is a view of + /// its pointer input in place, which is what a pointer cast from that + /// same pointee does unchecked and nothing a value transmute does; and a + /// by-value conversion into a type with interior mutability makes a new + /// cell where a pointer cast views the old one, so it stands in for + /// value sites and for pointer casts into `Freeze` types only. + fn checks(&self, from: Ty<'tcx>, shape: Shape, target_is_freeze: bool) -> bool { + match (shape, self.views) { + (Shape::Pointer, true) => pointee(self.from) == Some(from), + (Shape::Value, true) => false, + (Shape::Pointer, false) if !target_is_freeze => false, + (_, false) => self.from == from || self.from.peel_refs() == from, + } } - let ty::Adt(adt, _) = *to.kind() else { - return; - }; - // A pointer cast to a type with interior mutability views the pointee - // in place (a `usize` as an `AtomicUsize`); a by-value conversion into - // such a type makes a new cell and is no substitute for the view. - if from.ty_adt_def().is_some_and(|a| a.did() == adt.did()) - || (shape == Shape::Pointer && !to.is_freeze(tcx, cx.typing_env())) - || in_own_code_of(cx, expr.hir_id, adt.did()) - { - return; +} + +impl BypassedConversion { + pub fn new(config: &crate::MordantConfig) -> Self { + Self { + resource_errors: config.validator_resource_errors.clone(), + validated: HashMap::new(), + } } - let conversions = collect_conversions(cx, adt); - let same_integer = - |c: &&Conversion<'tcx>| shape == Shape::Value && c.from.is_integral() && from.is_integral(); - let Some(conv) = conversions - .iter() - .find(|c| c.from == from) - .or_else(|| conversions.iter().find(same_integer)) - else { - return; - }; - let consequence = if conv.fallible { - format!( - "`{}` converts `{}` to `{to}` and can refuse a value; this site accepts any bit pattern", - conv.name, conv.from - ) - } else { - format!( - "`{}` is how `{}` becomes `{to}`; this site goes around it", - conv.name, conv.from - ) - }; - let msg = format!("`{from}` is reinterpreted as `{to}` by {how} here, but {consequence}"); - let help = "convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on"; - if conv.def.is_local() { - emit_with_note( - cx, - BYPASSED_CONVERSION, - expr.span, - msg, - tcx.def_span(conv.def), - "the conversion this site skips", - help, + + /// `bypassed_validator`'s precondition for reporting a conjured value of + /// `adt`: a local struct with a receiver-less inherent fn returning + /// `Option`/`Result` whose body checks a field it stores. + fn has_validator<'tcx>(&mut self, cx: &LateContext<'tcx>, adt: ty::AdtDef<'tcx>) -> bool { + let did = adt.did(); + if !did.is_local() || !adt.is_struct() { + return false; + } + if let Some(&known) = self.validated.get(&did) { + return known; + } + let tcx = cx.tcx; + let found = tcx.inherent_impls(did).iter().any(|&imp| { + tcx.associated_items(imp) + .in_definition_order() + .filter(|item| item.is_fn() && !item.is_method()) + .any(|item| { + let output = tcx + .fn_sig(item.def_id) + .instantiate_identity() + .skip_normalization() + .skip_binder() + .output(); + let ty::Adt(o, args) = *output.kind() else { + return false; + }; + (tcx.is_diagnostic_item(sym::Option, o.did()) + || tcx.is_diagnostic_item(sym::Result, o.did())) + && matches!(*args.type_at(0).kind(), ty::Adt(inner, _) if inner.did() == did) + && item.def_id.as_local().is_some_and(|ctor| { + !ctor_flow::checked_fields(cx, ctor, did, &self.resource_errors) + .is_empty() + }) + }) + }); + self.validated.insert(did, found); + found + } + + /// Reports `expr`, which turns a `from` into a `to` by reinterpretation + /// (`how` names the means), when a conversion between the same pair + /// exists and the site is not the target type's own code. + fn check_reinterpretation<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + expr: &'tcx Expr<'tcx>, + from: Ty<'tcx>, + to: Ty<'tcx>, + how: &'static str, + ) { + if expr.span.in_external_macro(cx.tcx.sess.source_map()) { + return; + } + let tcx = cx.tcx; + let (from, to, shape) = peel_pointer_pair( + tcx.erase_and_anonymize_regions(from), + tcx.erase_and_anonymize_regions(to), ); - } else { - emit(cx, BYPASSED_CONVERSION, expr.span, msg, help); + if from == to { + return; + } + let ty::Adt(adt, _) = *to.kind() else { + return; + }; + if from.ty_adt_def().is_some_and(|a| a.did() == adt.did()) + || in_own_code_of(cx, expr.hir_id, adt.did()) + { + return; + } + let is_freeze = to.is_freeze(tcx, cx.typing_env()); + let conversions = collect_conversions(cx, adt, to); + let exact = |c: &&Conversion<'tcx>| c.checks(from, shape, is_freeze); + let same_integer = |c: &&Conversion<'tcx>| { + shape == Shape::Value + && !c.views + && c.from.peel_refs().is_integral() + && from.is_integral() + }; + let Some(conv) = conversions + .iter() + .find(exact) + .or_else(|| conversions.iter().find(same_integer)) + else { + return; + }; + if how == TRANSMUTE && shape == Shape::Value && self.has_validator(cx, adt) { + return; + } + let consequence = if conv.fallible { + format!( + "`{}` converts `{}` to `{to}` and can refuse a value; this site accepts any bit pattern", + conv.name, conv.from + ) + } else { + format!( + "`{}` is how `{}` becomes `{to}`; this site goes around it", + conv.name, conv.from + ) + }; + let msg = format!("`{from}` is reinterpreted as `{to}` by {how} here, but {consequence}"); + let help = "convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on"; + if conv.def.is_local() { + emit_with_note( + cx, + BYPASSED_CONVERSION, + expr.span, + msg, + tcx.def_span(conv.def), + "the conversion this site skips", + help, + ); + } else { + emit(cx, BYPASSED_CONVERSION, expr.span, msg, help); + } } } -/// Every `From`/`TryFrom` impl for the ADT and every safe receiver-less -/// inherent fn of it that takes one value and returns the ADT, bare or in -/// `Option`/`Result`. Inputs that are the ADT itself or mention a type -/// parameter convert nothing a reinterpreting site could have held. +/// Every `From`/`TryFrom` impl whose self type covers `to` and every safe, +/// stable, receiver-less inherent fn of the ADT that takes one value and +/// returns something covering `to`, bare, behind a reference, or either of +/// those in `Option`/`Result`. Inputs that are the ADT itself or mention a +/// type parameter convert nothing a reinterpreting site could have held. fn collect_conversions<'tcx>( cx: &LateContext<'tcx>, adt: ty::AdtDef<'tcx>, + to: Ty<'tcx>, ) -> Vec> { let tcx = cx.tcx; let target = adt.did(); @@ -189,12 +287,19 @@ fn collect_conversions<'tcx>( .type_of(target) .instantiate_identity() .skip_normalization(); + // `impl From for Id` converts into any `Id`; `for Id` + // only into that one. + let covers = |candidate: Ty<'tcx>| { + DeepRejectCtxt::relate_rigid_infer(tcx) + .types_may_unify(to, tcx.erase_and_anonymize_regions(candidate)) + }; let is_target = |t: Ty<'tcx>| { t.peel_refs() .ty_adt_def() .is_some_and(|a| a.did() == target) }; let usable_input = |t: Ty<'tcx>| !is_target(t) && !t.has_param(); + let callable = |def: DefId| !tcx.lookup_stability(def).is_some_and(|s| s.is_unstable()); let name_of = |f: Symbol| format!("{}::{f}", tcx.item_name(target)); let mut out = Vec::new(); for (trait_sym, method, fallible) in @@ -209,7 +314,7 @@ fn collect_conversions<'tcx>( .instantiate_identity() .skip_normalization() .args; - if !is_target(args.type_at(0)) { + if !covers(args.type_at(0)) { continue; } let from = tcx.erase_and_anonymize_regions(args.type_at(1)); @@ -221,24 +326,28 @@ fn collect_conversions<'tcx>( .in_definition_order() .find(|i| i.is_fn()) .map_or(imp, |i| i.def_id); + if !callable(imp) || !callable(def) { + continue; + } out.push(Conversion { from, def, name: name_of(Symbol::intern(method)), fallible, + views: false, }); } } for &imp in tcx.inherent_impls(target) { for item in tcx.associated_items(imp).in_definition_order() { - if !item.is_fn() || item.is_method() { + if !item.is_fn() || item.is_method() || !callable(item.def_id) { continue; } - let sig = tcx - .fn_sig(item.def_id) - .instantiate_identity() - .skip_normalization() - .skip_binder(); + let sig = tcx.instantiate_bound_regions_with_erased( + tcx.fn_sig(item.def_id) + .instantiate_identity() + .skip_normalization(), + ); if sig.safety().is_unsafe() { continue; } @@ -250,22 +359,31 @@ fn collect_conversions<'tcx>( continue; } let output = sig.output(); - let fallible = match *output.kind() { - ty::Adt(o, _) if o.did() == target => false, + let (payload, fallible) = match *output.kind() { ty::Adt(o, args) - if (tcx.is_diagnostic_item(sym::Option, o.did()) - || tcx.is_diagnostic_item(sym::Result, o.did())) - && matches!(*args.type_at(0).kind(), ty::Adt(inner, _) if inner.did() == target) => + if o.did() != target + && (tcx.is_diagnostic_item(sym::Option, o.did()) + || tcx.is_diagnostic_item(sym::Result, o.did())) => { - true + (args.type_at(0), true) } - _ => continue, + _ => (output, false), }; + // `Option<&Self>` from a `&[u8]`: the checked view of those bytes. + let (returned, views) = match *payload.kind() { + ty::Ref(_, inner, _) => (inner, true), + _ => (payload, false), + }; + if !(matches!(*returned.kind(), ty::Adt(o, _) if o.did() == target) && covers(returned)) + { + continue; + } out.push(Conversion { from, def: item.def_id, name: name_of(item.name()), fallible, + views, }); } } @@ -276,25 +394,27 @@ impl<'tcx> LateLintPass<'tcx> for BypassedConversion { fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { let results = cx.typeck_results(); match expr.kind { - ExprKind::Call(_, [arg]) => { + ExprKind::Call(callee, [_]) => { let Some(def) = callee_of(cx, expr).map(|c| c.def()) else { return; }; let Some(how) = transmuter(cx, def) else { return; }; - // `transmute_copy(&src)` reads through the reference. - let from = match (how, results.expr_ty(arg).kind()) { - (TRANSMUTE_COPY, &ty::Ref(_, inner, _)) => inner, - _ => results.expr_ty(arg), - }; - check_reinterpretation(cx, expr, from, results.expr_ty(expr), how); + // `transmute::` / `transmute_copy::`: the + // types the intrinsic reinterprets between, whatever the + // argument coerced from. + let args = results.node_args(callee.hir_id); + if args.len() < 2 { + return; + } + self.check_reinterpretation(cx, expr, args.type_at(0), args.type_at(1), how); } ExprKind::Cast(inner, _) if results.expr_ty(expr).is_raw_ptr() => { - check_reinterpretation( + self.check_reinterpretation( cx, expr, - results.expr_ty(inner), + results.expr_ty_adjusted(inner), results.expr_ty(expr), POINTER_CAST, ); @@ -308,10 +428,10 @@ impl<'tcx> LateLintPass<'tcx> for BypassedConversion { { return; } - check_reinterpretation( + self.check_reinterpretation( cx, expr, - results.expr_ty(recv), + results.expr_ty_adjusted(recv), results.expr_ty(expr), POINTER_CAST, ); diff --git a/src/lib.rs b/src/lib.rs index 556ceaa..36cee0d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -231,7 +231,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto add(s, true, move || DefaultedFailure::new(config)); add(s, config.unchecked_input_len_enabled, || UncheckedInputLen); add(s, true, || MisboundArg); - add(s, true, || BypassedConversion); + add(s, true, move || BypassedConversion::new(config)); add(s, true, same_match_twice::SameMatchTwice::default); add(s, true, move || { reimplemented_helper::ReimplementedHelper::new(config) diff --git a/ui/bypassed_conversion.rs b/ui/bypassed_conversion.rs index 087f68b..516e5e9 100644 --- a/ui/bypassed_conversion.rs +++ b/ui/bypassed_conversion.rs @@ -107,12 +107,87 @@ mod raw { } } +mod px { + use std::marker::PhantomData; + + pub struct Screen; + pub struct World; + + #[repr(transparent)] + pub struct Px(pub(crate) f32, PhantomData); + + // Only screen pixels are made from a bare float; nothing converts into + // `Px`. + impl From for Px { + fn from(v: f32) -> Px { + Px(v.max(0.0), PhantomData) + } + } +} + +mod hdr { + #[repr(transparent)] + pub struct Hdr(pub(crate) [u8; 4]); + + // A borrow-taking conversion: the usual shape for buffer newtypes. + impl<'a> From<&'a [u8; 4]> for Hdr { + fn from(b: &'a [u8; 4]) -> Hdr { + Hdr([b[0] & 0x7f, b[1], b[2], b[3]]) + } + } +} + +mod name { + // An unsized view type: its checked constructor returns a reference. + #[repr(transparent)] + pub struct NameRef(pub(crate) [u8]); + + impl NameRef { + pub fn new(b: &[u8]) -> Option<&NameRef> { + if b.contains(&0) { + return None; + } + Some(unsafe { &*(b as *const [u8] as *const NameRef) }) + } + } + + // A wrapper over a borrow whose constructor takes an elided lifetime. + #[derive(Clone, Copy)] + #[repr(transparent)] + pub struct Str<'a>(pub(crate) &'a [u8]); + + impl Str<'_> { + pub fn new(b: &[u8]) -> Option> { + std::str::from_utf8(b).ok().map(|_| Str(b)) + } + } +} + +mod gate { + // A struct whose `Option` constructor checks the field it stores: + // `bypassed_validator` names that check, so this lint leaves a + // `mem::transmute` into it alone. + pub struct Gate { + pub(crate) v: u8, + } + + impl Gate { + pub fn new(v: u8) -> Option { + if v < 2 { Some(Gate { v }) } else { None } + } + } +} + use std::convert::TryFrom; use code::Code; use fd::Fd; +use gate::Gate; +use hdr::Hdr; use level::Level; use meters::Meters; +use name::{NameRef, Str}; +use px::{Px, Screen, World}; use raw::{Raw, Slot, View}; // Flagged: `Level::try_from` exists for exactly this pair. @@ -150,6 +225,55 @@ fn meters_ref(n: &u32) -> &Meters { unsafe { core::mem::transmute::<&u32, &Meters>(n) } } +// Flagged: the transmute is `&u32 -> &Meters` whatever the argument coerced from. +fn meters_boxed(v: &Box) -> &Meters { + unsafe { core::mem::transmute::<&u32, &Meters>(v) } +} + +// Flagged: `.cast()` through an auto-deref'd `&*const u32` still casts `*const u32`. +fn meters_each(ptrs: &[*const u32]) -> u32 { + let mut sum = 0; + for p in ptrs.iter() { + sum += unsafe { p.cast::().read() }.0; + } + sum +} + +// Flagged: `From for Px` covers exactly this instantiation. +fn px_screen(v: f32) -> Px { + unsafe { core::mem::transmute::>(v) } +} + +// Fine: nothing converts an f32 into `Px`; the `Px` impl is not it. +fn px_world(v: f32) -> Px { + unsafe { core::mem::transmute::>(v) } +} + +// Flagged: `From<&[u8; 4]> for Hdr` is the conversion, borrow or not. +fn hdr_value(b: &[u8; 4]) -> Hdr { + unsafe { core::mem::transmute::<[u8; 4], Hdr>(*b) } +} + +// Flagged: the same conversion against a pointer view of the same bytes. +fn hdr_view(b: &[u8; 4]) -> &Hdr { + unsafe { &*(b as *const [u8; 4] as *const Hdr) } +} + +// Flagged: `NameRef::new` returns the checked reference this cast makes unchecked. +fn name_view(b: &[u8]) -> &NameRef { + unsafe { &*(b as *const [u8] as *const NameRef) } +} + +// Flagged: `Str::new(&[u8])` takes an elided lifetime and is still the conversion. +fn str_from_bytes(b: &[u8]) -> Str<'_> { + unsafe { core::mem::transmute::<&[u8], Str<'_>>(b) } +} + +// Fine here: `bypassed_validator` reports this one and names the check. +fn gate_from_wire(n: u8) -> Gate { + unsafe { core::mem::transmute::(n) } +} + // Fine: a byte buffer viewed as the type; pointer casts need the exact source. fn meters_from_bytes(bytes: &[u8]) -> Meters { unsafe { bytes.as_ptr().cast::().read_unaligned() } @@ -202,6 +326,15 @@ fn main() { let _ = meters_in_place(&3); let _ = meters_read(&3); let _ = meters_ref(&3); + let _ = meters_boxed(&Box::new(3)).0; + let _ = meters_each(&[&3u32 as *const u32]); + let _ = px_screen(1.0).0; + let _ = px_world(1.0).0; + let _ = hdr_value(&[0; 4]).0; + let _ = hdr_view(&[0; 4]).0; + let _ = name_view(b"x").0.len(); + let _ = str_from_bytes(b"x").0; + let _ = gate_from_wire(0).v; let _ = meters_from_bytes(&[0, 0, 0, 0]); let _ = raw_from_wire(0); let _ = slot_from_wire(0); @@ -217,4 +350,9 @@ fn main() { let _ = Meters::from(1).0; let _ = Level::try_from(9); let _ = unsafe { Slot::from_raw(0) }.0; + let _ = Px::::from(1.0).0; + let _ = Hdr::from(&[0; 4]).0; + let _ = NameRef::new(b"x").map(|n| n.0.len()); + let _ = Str::new(b"x").map(|s| s.0); + let _ = Gate::new(0).map(|g| g.v); } diff --git a/ui/bypassed_conversion.stderr b/ui/bypassed_conversion.stderr index 63ddd76..87bab09 100644 --- a/ui/bypassed_conversion.stderr +++ b/ui/bypassed_conversion.stderr @@ -1,5 +1,5 @@ warning: `u8` is reinterpreted as `level::Level` by `mem::transmute` here, but `Level::try_from` converts `u8` to `level::Level` and can refuse a value; this site accepts any bit pattern - --> $DIR/bypassed_conversion.rs:120:14 + --> $DIR/bypassed_conversion.rs:195:14 | LL | unsafe { core::mem::transmute::(n) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -13,7 +13,7 @@ LL | fn try_from(n: u8) -> Result { = note: `#[warn(bypassed_conversion)]` on by default warning: `u16` is reinterpreted as `code::Code` by `mem::transmute` here, but `Code::from_raw` converts `u32` to `code::Code` and can refuse a value; this site accepts any bit pattern - --> $DIR/bypassed_conversion.rs:125:14 + --> $DIR/bypassed_conversion.rs:200:14 | LL | unsafe { core::mem::transmute::(n as u16) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -26,7 +26,7 @@ LL | pub fn from_raw(n: u32) -> Option { = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on warning: `u16` is reinterpreted as `code::Code` by `mem::transmute_copy` here, but `Code::from_raw` converts `u32` to `code::Code` and can refuse a value; this site accepts any bit pattern - --> $DIR/bypassed_conversion.rs:130:14 + --> $DIR/bypassed_conversion.rs:205:14 | LL | unsafe { core::mem::transmute_copy::(n) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -39,7 +39,7 @@ LL | pub fn from_raw(n: u32) -> Option { = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on warning: `i32` is reinterpreted as `fd::Fd` by `mem::transmute` here, but `Fd::new` is how `i32` becomes `fd::Fd`; this site goes around it - --> $DIR/bypassed_conversion.rs:135:14 + --> $DIR/bypassed_conversion.rs:210:14 | LL | unsafe { core::mem::transmute::(raw) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -52,7 +52,7 @@ LL | pub fn new(raw: i32) -> Fd { = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on warning: `u32` is reinterpreted as `meters::Meters` by a pointer cast here, but `Meters::from` is how `u32` becomes `meters::Meters`; this site goes around it - --> $DIR/bypassed_conversion.rs:140:15 + --> $DIR/bypassed_conversion.rs:215:15 | LL | unsafe { *(n as *const u32 as *const Meters) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -65,7 +65,7 @@ LL | fn from(n: u32) -> Meters { = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on warning: `u32` is reinterpreted as `meters::Meters` by a pointer cast here, but `Meters::from` is how `u32` becomes `meters::Meters`; this site goes around it - --> $DIR/bypassed_conversion.rs:145:14 + --> $DIR/bypassed_conversion.rs:220:14 | LL | unsafe { p.cast::().read() } | ^^^^^^^^^^^^^^^^^^ @@ -78,7 +78,7 @@ LL | fn from(n: u32) -> Meters { = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on warning: `u32` is reinterpreted as `meters::Meters` by `mem::transmute` here, but `Meters::from` is how `u32` becomes `meters::Meters`; this site goes around it - --> $DIR/bypassed_conversion.rs:150:14 + --> $DIR/bypassed_conversion.rs:225:14 | LL | unsafe { core::mem::transmute::<&u32, &Meters>(n) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -90,5 +90,110 @@ LL | fn from(n: u32) -> Meters { | ^^^^^^^^^^^^^^^^^^^^^^^^^ = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on -warning: 7 warnings emitted +warning: `u32` is reinterpreted as `meters::Meters` by `mem::transmute` here, but `Meters::from` is how `u32` becomes `meters::Meters`; this site goes around it + --> $DIR/bypassed_conversion.rs:230:14 + | +LL | unsafe { core::mem::transmute::<&u32, &Meters>(v) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:74:9 + | +LL | fn from(n: u32) -> Meters { + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `u32` is reinterpreted as `meters::Meters` by a pointer cast here, but `Meters::from` is how `u32` becomes `meters::Meters`; this site goes around it + --> $DIR/bypassed_conversion.rs:237:25 + | +LL | sum += unsafe { p.cast::().read() }.0; + | ^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:74:9 + | +LL | fn from(n: u32) -> Meters { + | ^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `f32` is reinterpreted as `px::Px` by `mem::transmute` here, but `Px::from` is how `f32` becomes `px::Px`; this site goes around it + --> $DIR/bypassed_conversion.rs:244:14 + | +LL | unsafe { core::mem::transmute::>(v) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:122:9 + | +LL | fn from(v: f32) -> Px { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `[u8; 4]` is reinterpreted as `hdr::Hdr` by `mem::transmute` here, but `Hdr::from` is how `&[u8; 4]` becomes `hdr::Hdr`; this site goes around it + --> $DIR/bypassed_conversion.rs:254:14 + | +LL | unsafe { core::mem::transmute::<[u8; 4], Hdr>(*b) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:134:9 + | +LL | fn from(b: &'a [u8; 4]) -> Hdr { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `[u8; 4]` is reinterpreted as `hdr::Hdr` by a pointer cast here, but `Hdr::from` is how `&[u8; 4]` becomes `hdr::Hdr`; this site goes around it + --> $DIR/bypassed_conversion.rs:259:16 + | +LL | unsafe { &*(b as *const [u8; 4] as *const Hdr) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:134:9 + | +LL | fn from(b: &'a [u8; 4]) -> Hdr { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `[u8]` is reinterpreted as `name::NameRef` by a pointer cast here, but `NameRef::new` converts `&[u8]` to `name::NameRef` and can refuse a value; this site accepts any bit pattern + --> $DIR/bypassed_conversion.rs:264:16 + | +LL | unsafe { &*(b as *const [u8] as *const NameRef) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:146:9 + | +LL | pub fn new(b: &[u8]) -> Option<&NameRef> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `&[u8]` is reinterpreted as `name::Str<'_>` by `mem::transmute` here, but `Str::new` converts `&[u8]` to `name::Str<'_>` and can refuse a value; this site accepts any bit pattern + --> $DIR/bypassed_conversion.rs:269:14 + | +LL | unsafe { core::mem::transmute::<&[u8], Str<'_>>(b) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the conversion this site skips + --> $DIR/bypassed_conversion.rs:160:9 + | +LL | pub fn new(b: &[u8]) -> Option> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: convert through that function, or put an unchecked constructor beside it so both sit with the layout they depend on + +warning: `gate::Gate` is produced by `mem::transmute` here, but `Gate::new` checks `v` before constructing one + --> $DIR/bypassed_conversion.rs:274:14 + | +LL | unsafe { core::mem::transmute::(n) } + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the check this value never went through + --> $DIR/bypassed_conversion.rs:176:16 + | +LL | if v < 2 { Some(Gate { v }) } else { None } + | ^^^^^ + = help: construct through the validating function + = note: `#[warn(bypassed_validator)]` on by default + +warning: 15 warnings emitted diff --git a/ui/bypassed_validator.rs b/ui/bypassed_validator.rs index cad81fe..6bbea02 100644 --- a/ui/bypassed_validator.rs +++ b/ui/bypassed_validator.rs @@ -331,7 +331,6 @@ mod outside { } // Values that never went through the constructor at all. - #[allow(unknown_lints, bypassed_conversion)] fn conjured() -> (inner::Level, gate::Gate, gate::Gate, Free) { unsafe { ( diff --git a/ui/bypassed_validator.stderr b/ui/bypassed_validator.stderr index 5eccf2d..a92785b 100644 --- a/ui/bypassed_validator.stderr +++ b/ui/bypassed_validator.stderr @@ -78,7 +78,7 @@ LL | if port > 65535 { = help: construct through the validating function, or move this literal into the type's module warning: `inner::Level` is produced by `mem::zeroed` here, but `Level::new` checks `value` before constructing one - --> $DIR/bypassed_validator.rs:338:17 + --> $DIR/bypassed_validator.rs:337:17 | LL | std::mem::zeroed::(), | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -91,78 +91,78 @@ LL | if v <= 10 { Ok(Level { value: v }) } else { Err(()) } = help: construct through the validating function warning: `gate::Gate` is produced by `mem::transmute` here, but `Gate::new` checks `v` before constructing one - --> $DIR/bypassed_validator.rs:339:17 + --> $DIR/bypassed_validator.rs:338:17 | LL | std::mem::transmute::(0), | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | note: the check this value never went through - --> $DIR/bypassed_validator.rs:407:16 + --> $DIR/bypassed_validator.rs:406:16 | LL | if v < 2 { Some(Gate { v }) } else { None } | ^^^^^ = help: construct through the validating function warning: `gate::Gate` is produced by `MaybeUninit::assume_init` here, but `Gate::new` checks `v` before constructing one - --> $DIR/bypassed_validator.rs:340:17 + --> $DIR/bypassed_validator.rs:339:17 | LL | std::mem::MaybeUninit::::zeroed().assume_init(), | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | note: the check this value never went through - --> $DIR/bypassed_validator.rs:407:16 + --> $DIR/bypassed_validator.rs:406:16 | LL | if v < 2 { Some(Gate { v }) } else { None } | ^^^^^ = help: construct through the validating function warning: `widened::Tag::s` is written directly here, but `Tag::new` rejects some values of `s` before storing one - --> $DIR/bypassed_validator.rs:433:13 + --> $DIR/bypassed_validator.rs:432:13 | LL | u.s = ""; | ^^^^^^^^ | note: the check this write never runs - --> $DIR/bypassed_validator.rs:423:16 + --> $DIR/bypassed_validator.rs:422:16 | LL | if s.is_empty() { None } else { Some(Tag { s }) } | ^^^^^^^^^^^^ = help: change the value through the validating function, or make the field private and move this write into the type's module warning: `promoted::Slot` is produced by `mem::transmute` here, but `Slot::promote` checks `n` before constructing one - --> $DIR/bypassed_validator.rs:466:22 + --> $DIR/bypassed_validator.rs:465:22 | LL | unsafe { std::mem::transmute::, Slot>(s) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | note: the check this value never went through - --> $DIR/bypassed_validator.rs:452:16 + --> $DIR/bypassed_validator.rs:451:16 | LL | if n == 0 { None } else { Some(Slot { n, state: PhantomData }) } | ^^^^^^ = help: construct through the validating function warning: `two_validators::Pair` is constructed by literal here, but `Pair::parse` checks `b` before constructing one - --> $DIR/bypassed_validator.rs:493:13 + --> $DIR/bypassed_validator.rs:492:13 | LL | Pair { b: 200, ..base } | ^^^^^^^^^^^^^^^^^^^^^^^ | note: the check this literal never runs - --> $DIR/bypassed_validator.rs:483:16 + --> $DIR/bypassed_validator.rs:482:16 | LL | if b > 99 { None } else { Some(Pair { a: 0, b }) } | ^^^^^^ = help: construct through the validating function, or move this literal into the type's module warning: `tuple::Digit` is constructed by literal here, but `Digit::new` checks `0` before constructing one - --> $DIR/bypassed_validator.rs:515:14 + --> $DIR/bypassed_validator.rs:514:14 | LL | (Digit(10), Plain(10)) | ^^^^^^^^^ | note: the check this literal never runs - --> $DIR/bypassed_validator.rs:503:16 + --> $DIR/bypassed_validator.rs:502:16 | LL | if d > 9 { None } else { Some(Digit(d)) } | ^^^^^ From 9657f5b87b521a2413d375d16248078c19830dbd Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:48:00 -0700 Subject: [PATCH 21/34] uneven_narrowing skips repr structs, round trips and if-let ranges --- src/uneven_narrowing.rs | 62 ++++++++++++++++++++++++++--------------- ui/uneven_narrowing.rs | 35 +++++++++++++++++++++++ 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/src/uneven_narrowing.rs b/src/uneven_narrowing.rs index c6b84e1..1ad8f37 100644 --- a/src/uneven_narrowing.rs +++ b/src/uneven_narrowing.rs @@ -5,11 +5,12 @@ use clippy_utils::res::MaybeResPath; use clippy_utils::source::snippet; use rustc_hir::def::{DefKind, Res}; use rustc_hir::def_id::DefId; -use rustc_hir::{BinOpKind, Expr, ExprKind, HirId, PatKind, UnOp}; +use rustc_hir::{BinOpKind, Expr, ExprKind, HirId, Pat, PatKind, UnOp}; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{self, Ty}; use rustc_span::{Span, Symbol, sym}; +use crate::adt_facts::has_fixed_repr; use crate::baseline::emit_with_note; use crate::hir_shapes::{Callee, callee_of}; @@ -35,8 +36,9 @@ rustc_session::declare_lint! { /// width, so `u64 as usize` on a 64-bit target is not a narrowing. A /// checked site only condemns `as` casts to a type at most as wide as /// its own target: `u8::try_from(c)` inside an arm that already matched - /// a letter says nothing about `c as u32` elsewhere. Sites inside macro - /// expansions are not read. + /// a letter says nothing about `c as u32` elsewhere. A field of a + /// `repr(C)`, packed or transparent struct has its width fixed by that + /// layout and is not read; neither are sites inside macro expansions. pub UNEVEN_NARROWING, Warn, "an integer place range-checked at one narrowing and truncated with `as` at another" @@ -106,8 +108,11 @@ fn lossy(src: IntLayout, dst: IntLayout) -> bool { /// The integer place `e` reads, through `&`, `*`, HIR temporaries and inner /// integer-to-integer casts (which change representation, not which place is -/// read), with its source text to show for it. -fn place_of<'tcx>(cx: &LateContext<'tcx>, mut e: &'tcx Expr<'tcx>) -> Option<(Place, String)> { +/// read), with the place's own layout and its source text to show for it. +fn place_of<'tcx>( + cx: &LateContext<'tcx>, + mut e: &'tcx Expr<'tcx>, +) -> Option<(Place, IntLayout, Ty<'tcx>, String)> { let typeck = cx.typeck_results(); loop { match e.kind { @@ -123,20 +128,31 @@ fn place_of<'tcx>(cx: &LateContext<'tcx>, mut e: &'tcx Expr<'tcx>) -> Option<(Pl _ => break, } } - int_layout(cx, typeck.expr_ty(e).peel_refs())?; + let ty = typeck.expr_ty(e).peel_refs(); + let layout = int_layout(cx, ty)?; let shown = snippet(cx, e.span, "..").into_owned(); match e.kind { ExprKind::Field(base, ident) => { let adt = typeck.expr_ty_adjusted(base).peel_refs().ty_adt_def()?; - Some((Place::Field(adt.did(), ident.name), shown)) + // A layout-fixed struct cannot redeclare the field narrow. + if has_fixed_repr(adt) { + return None; + } + Some((Place::Field(adt.did(), ident.name), layout, ty, shown)) } _ => { let local = e.res_local_id()?; - Some((Place::Local(local), shown)) + Some((Place::Local(local), layout, ty, shown)) } } } +fn has_range_pat(pat: &Pat<'_>) -> bool { + let mut found = false; + pat.walk_always(|p| found |= matches!(p.kind, PatKind::Range(..))); + found +} + /// `T::try_from(x)` / `x.try_into()`: the operand and the `T` it is checked /// into. fn checked_conversion<'tcx>( @@ -216,30 +232,35 @@ impl UnevenNarrowing { dst_ty: Ty<'tcx>, checked: bool, ) { - let src_ty = cx.typeck_results().expr_ty(operand).peel_refs(); - let (Some(src), Some(dst)) = (int_layout(cx, src_ty), int_layout(cx, dst_ty)) else { + let operand_ty = cx.typeck_results().expr_ty(operand).peel_refs(); + let (Some(src), Some(dst)) = (int_layout(cx, operand_ty), int_layout(cx, dst_ty)) else { return; }; // A conversion that cannot fail is neither a truncation nor a check. if !lossy(src, dst) { return; } - let Some((place, shown)) = place_of(cx, operand) else { + let Some((place, place_layout, place_ty, shown)) = place_of(cx, operand) else { return; }; + // `x as usize as u32` on a `u32` is `x` again: what the site does to + // the place is a question about the place's type, not the operand's. + if !lossy(place_layout, dst) { + return; + } self.sites.entry(place).or_default().push(Site { span: site.span, body: enclosing_fn(cx, site.hir_id), dst_bits: dst.bits, checked, shown, - src: src_ty.to_string(), + src: place_ty.to_string(), dst: dst_ty.to_string(), }); } fn mark_range_checked<'tcx>(&mut self, cx: &LateContext<'tcx>, at: HirId, e: &'tcx Expr<'tcx>) { - if let Some((place, _)) = place_of(cx, e) { + if let Some((place, ..)) = place_of(cx, e) { self.range_checked.insert((enclosing_fn(cx, at), place)); } } @@ -275,17 +296,14 @@ impl<'tcx> LateLintPass<'tcx> for UnevenNarrowing { self.mark_range_checked(cx, expr.hir_id, r); } } - // `match x { 0..=9 => .., _ => .. }` tests the range too. - ExprKind::Match(scrut, arms, _) - if arms.iter().any(|arm| { - let mut has_range = false; - arm.pat - .walk_always(|p| has_range |= matches!(p.kind, PatKind::Range(..))); - has_range - }) => - { + // `match x { 0..=9 => .., _ => .. }` and `if let 0..=9 = x` test + // the range too. + ExprKind::Match(scrut, arms, _) if arms.iter().any(|arm| has_range_pat(arm.pat)) => { self.mark_range_checked(cx, expr.hir_id, scrut); } + ExprKind::Let(l) if has_range_pat(l.pat) => { + self.mark_range_checked(cx, expr.hir_id, l.init); + } _ if !expr.span.from_expansion() => { if let Some((operand, dst_ty)) = checked_conversion(cx, expr) { self.record(cx, expr, operand, dst_ty, true); diff --git a/ui/uneven_narrowing.rs b/ui/uneven_narrowing.rs index d54fb20..d37a594 100644 --- a/ui/uneven_narrowing.rs +++ b/ui/uneven_narrowing.rs @@ -65,6 +65,36 @@ fn matched_then_cast(n: u64) -> u8 { } } +// Fine: an `if let` range pattern is the same check. +fn if_let_then_cast(n: u64) -> u8 { + let narrow = u8::try_from(n).ok(); + if let 0..=255 = n { + n as u8 + } else { + narrow.unwrap_or(0) + } +} + +// Fine: out through `usize` and back is `u32` again; the operand's type is not +// the place's. +fn round_trip(n: u32) -> (u32, bool) { + (n as usize as u32, i32::try_from(n).is_err()) +} + +// Fine: `repr(C)` fixes the field's width; it cannot be redeclared narrow. +#[repr(C)] +struct Header { + size: u64, +} + +fn header_size(h: &Header) -> u32 { + h.size as u32 +} + +fn header_size_checked(h: &Header) -> u32 { + u32::try_from(h.size).unwrap_or(u32::MAX) +} + // Fine: widening loses nothing. fn widen(b: &Buf) -> u64 { b.small as u64 + b.len as u64 @@ -146,6 +176,11 @@ fn main() { let _ = local_both_ways(7); let _ = compared_then_cast(&b); let _ = matched_then_cast(8); + let _ = if_let_then_cast(8); + let _ = round_trip(3); + let h = Header { size: 13 }; + let _ = header_size(&h); + let _ = header_size_checked(&h); let _ = widen(&b); let _ = bucket(&b); let _ = bucket_again(&b); From 842e5baade03ad9ac84d5073da67c677a022adff Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:50:04 -0700 Subject: [PATCH 22/34] stringly_state sees ref mut bindings, indexed writes and field copies --- src/stringly_state.rs | 156 ++++++++++++++++++++++++++++++++------- ui/stringly_state.rs | 114 ++++++++++++++++++++++++++-- ui/stringly_state.stderr | 12 ++- 3 files changed, 247 insertions(+), 35 deletions(-) diff --git a/src/stringly_state.rs b/src/stringly_state.rs index 51fb0c3..4632685 100644 --- a/src/stringly_state.rs +++ b/src/stringly_state.rs @@ -6,16 +6,16 @@ use rustc_hir::def::Res; use rustc_hir::def_id::DefId; use rustc_hir::{ Arm, BinOpKind, BindingMode, BorrowKind, ByRef, Expr, ExprKind, HirId, LetStmt, MatchSource, - Mutability, Pat, PatExpr, PatExprKind, PatKind, QPath, StructTailExpr, + Mutability, Node, Pat, PatExpr, PatExprKind, PatKind, QPath, StructTailExpr, UnOp, }; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::adjustment::{Adjust, AutoBorrow, AutoBorrowMutability}; -use rustc_middle::ty::{self, Ty}; +use rustc_middle::ty::{self, Ty, TypeckResults}; use rustc_span::{Span, Symbol, sym}; use crate::adt_facts::{field_ty, has_fixed_repr, has_positional_fields, struct_field}; use crate::baseline::emit; -use crate::hir_shapes::{Callee, assigned_field, callee_of, peel_blocks_unsafe}; +use crate::hir_shapes::{Callee, callee_of, peel_blocks_unsafe}; rustc_session::declare_lint! { /// Flags a string or byte-string field or local whose every value the @@ -28,10 +28,11 @@ rustc_session::declare_lint! { /// Only fires where every store is visible: a local, or a field no other /// crate can name (the struct or the field is private to this one). Every /// store must write a literal (or an `if`/`match` choosing between - /// literals), and at least two distinct literals must occur. A single - /// non-literal store, a `..base` construction, a `&mut` borrow, an - /// explicit `repr`, or a value that is only ever formatted or written out - /// and never compared keeps it silent. + /// literals) or copy the same place from another value, and at least two + /// distinct literals must occur. A single non-literal store, a `..base` + /// construction, a `&mut` borrow or `ref mut` binding, a write into part + /// of the value (`x.f[i] = b`), an explicit `repr`, or a value that is + /// only ever formatted or written out and never compared keeps it silent. pub STRINGLY_STATE, Warn, "a string only ever holding one of a closed set of literals, then compared against them" @@ -211,7 +212,7 @@ fn read_slot<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option } ExprKind::Path(_) => local_of(cx, e).map(Slot::Local), ExprKind::AddrOf(BorrowKind::Ref, Mutability::Not, inner) - | ExprKind::Unary(rustc_hir::UnOp::Deref, inner) + | ExprKind::Unary(UnOp::Deref, inner) | ExprKind::DropTemps(inner) => read_slot(cx, inner), ExprKind::Index(inner, idx, _) if matches!( @@ -233,14 +234,84 @@ fn read_slot<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option } } -/// The place an assignment target or a `&mut` operand names directly. -fn written_slot<'tcx>(cx: &LateContext<'tcx>, place: &'tcx Expr<'tcx>) -> Option { - if let Some((base, ident, _)) = assigned_field(place) { +/// The tracked place a stored value copies its text from unchanged: `x.f`, +/// `x.f.clone()`, `Clone::clone(&x.f)` (what a derived `Clone` writes), +/// `s.to_owned()`. +fn copied_slot<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option { + let e = peel_blocks_unsafe(e); + match e.kind { + ExprKind::MethodCall(seg, recv, [], _) + if matches!( + seg.ident.name.as_str(), + "clone" | "to_owned" | "to_string" | "to_vec" | "into" + ) => + { + read_slot(cx, recv) + } + ExprKind::Call(callee, [arg]) + if matches!(callee.kind, ExprKind::Path(ref qp) + if last_path_segment(qp).ident.name == sym::clone) => + { + read_slot(cx, arg) + } + _ => read_slot(cx, e), + } +} + +/// The place an assignment target or a `&mut` operand names. +struct Written { + slot: Slot, + /// It names the place itself, not an element or range of it (`x.f[i]`): + /// only then does an assignment replace the whole value. + whole: bool, +} + +fn written_slot<'tcx>(cx: &LateContext<'tcx>, place: &'tcx Expr<'tcx>) -> Option { + let mut place = peel_blocks_unsafe(place); + let mut whole = true; + while let ExprKind::Index(inner, ..) + | ExprKind::Unary(UnOp::Deref, inner) + | ExprKind::DropTemps(inner) = place.kind + { + whole &= !matches!(place.kind, ExprKind::Index(..)); + place = inner; + } + let slot = match place.kind { // The adjusted type, so a write through a `Box`, a guard or any // other `Deref` container reaches the struct behind it. - return tracked_field(cx, cx.typeck_results().expr_ty_adjusted(base), ident.name); + ExprKind::Field(base, ident) => { + tracked_field(cx, cx.typeck_results().expr_ty_adjusted(base), ident.name) + } + _ => local_of(cx, place).map(Slot::Local), + }?; + Some(Written { slot, whole }) +} + +fn binds_ref_mut(typeck: &TypeckResults<'_>, id: HirId) -> bool { + typeck + .pat_binding_modes() + .get(id) + .is_some_and(|m| matches!(m.0, ByRef::Yes(_, Mutability::Mut))) +} + +/// The value a top-level binding pattern is matched against: the `let` +/// initializer, the `if let` operand or the `match` scrutinee. +fn bound_value<'tcx>(cx: &LateContext<'tcx>, pat: &Pat<'tcx>) -> Option<&'tcx Expr<'tcx>> { + match cx.tcx.parent_hir_node(pat.hir_id) { + Node::LetStmt(l) => l.init, + Node::Expr(e) => match e.kind { + ExprKind::Let(l) => Some(l.init), + _ => None, + }, + Node::Arm(arm) => match cx.tcx.parent_hir_node(arm.hir_id) { + Node::Expr(&Expr { + kind: ExprKind::Match(scrut, ..), + .. + }) => Some(scrut), + _ => None, + }, + _ => None, } - local_of(cx, peel_blocks_unsafe(place)).map(Slot::Local) } impl StringlyState { @@ -248,7 +319,11 @@ impl StringlyState { self.slots.entry(slot).or_default() } - fn store(&mut self, slot: Slot, value: &Expr<'_>) { + fn store<'tcx>(&mut self, cx: &LateContext<'tcx>, slot: Slot, value: &'tcx Expr<'tcx>) { + // Copying the same place from another value adds nothing to the set. + if copied_slot(cx, value) == Some(slot) { + return; + } let facts = self.facts(slot); if stored_literals(value, &mut facts.values) { facts.stores += 1; @@ -369,7 +444,36 @@ impl<'tcx> LateLintPass<'tcx> for StringlyState { } self.locals.insert(id, (l.pat.span, ident.name)); if let Some(init) = l.init { - self.store(Slot::Local(id), init); + self.store(cx, Slot::Local(id), init); + } + } + + /// A `ref mut` binding, spelt out or implied by matching through a + /// `&mut`, is a write this lint cannot read. + fn check_pat(&mut self, cx: &LateContext<'tcx>, pat: &'tcx Pat<'tcx>) { + let Some(typeck) = cx.maybe_typeck_results() else { + return; + }; + match pat.kind { + PatKind::Struct(_, fields, _) => { + let ty = typeck.pat_ty(pat); + for f in fields { + let mut by_ref_mut = false; + f.pat + .each_binding(|_, id, _, _| by_ref_mut |= binds_ref_mut(typeck, id)); + if by_ref_mut && let Some(slot) = tracked_field(cx, ty, f.ident.name) { + self.facts(slot).open = true; + } + } + } + PatKind::Binding(..) if binds_ref_mut(typeck, pat.hir_id) => { + if let Some(value) = bound_value(cx, pat) + && let Some(slot) = read_slot(cx, value) + { + self.facts(slot).open = true; + } + } + _ => {} } } @@ -382,7 +486,7 @@ impl<'tcx> LateLintPass<'tcx> for StringlyState { }; for f in fields { if let Some(slot) = tracked_field(cx, ty, f.ident.name) { - self.store(slot, f.expr); + self.store(cx, slot, f.expr); } } if matches!(tail, StructTailExpr::None) { @@ -398,20 +502,20 @@ impl<'tcx> LateLintPass<'tcx> for StringlyState { } } } - ExprKind::Assign(place, value, _) => { - if let Some(slot) = written_slot(cx, place) { - self.store(slot, value); - } - } + ExprKind::Assign(place, value, _) => match written_slot(cx, place) { + Some(Written { slot, whole: true }) => self.store(cx, slot, value), + Some(Written { slot, whole: false }) => self.facts(slot).open = true, + None => {} + }, ExprKind::AssignOp(_, place, _) | ExprKind::AddrOf(BorrowKind::Ref | BorrowKind::Raw, Mutability::Mut, place) => { - if let Some(slot) = written_slot(cx, place) { - self.facts(slot).open = true; + if let Some(w) = written_slot(cx, place) { + self.facts(w.slot).open = true; } } // The auto-`&mut` a mutating method call takes: the place is // written through something this lint does not read. - ExprKind::Field(..) | ExprKind::Path(..) => { + ExprKind::Field(..) | ExprKind::Path(..) | ExprKind::Index(..) => { let mutably_borrowed = cx.typeck_results().expr_adjustments(expr).iter().any(|a| { matches!( a.kind, @@ -419,8 +523,8 @@ impl<'tcx> LateLintPass<'tcx> for StringlyState { | Adjust::Borrow(AutoBorrow::RawPtr(Mutability::Mut)) ) }); - if mutably_borrowed && let Some(slot) = written_slot(cx, expr) { - self.facts(slot).open = true; + if mutably_borrowed && let Some(w) = written_slot(cx, expr) { + self.facts(w.slot).open = true; } } ExprKind::Binary(op, l, r) if matches!(op.node, BinOpKind::Eq | BinOpKind::Ne) => { diff --git a/ui/stringly_state.rs b/ui/stringly_state.rs index 0b06d13..aeb90d6 100644 --- a/ui/stringly_state.rs +++ b/ui/stringly_state.rs @@ -111,6 +111,21 @@ fn tier(n: u32) -> bool { matches!(level, "hi") } +// Flagged: a derived `Clone` copies the field, which adds no new value. +#[derive(Clone)] +struct Stage { + step: &'static str, +} + +fn stages() -> [Stage; 3] { + let first = Stage { step: "parse" }; + [first.clone(), Stage { step: "emit" }, first] +} + +fn is_parse(s: &Stage) -> bool { + s.step == "parse" +} + // Fine: the local is only formatted. fn banner(wide: bool) -> String { let rule = if wide { "====" } else { "--" }; @@ -126,6 +141,19 @@ fn from_env(f: fn() -> &'static str) -> bool { mode == "auto" } +// Fine: a `ref mut` binding of the local is a write the lint cannot read. +fn rebound(flip: bool, f: fn() -> &'static str) -> bool { + let mut mode = "x"; + if flip { + mode = "y"; + } + { + let ref mut r = mode; + *r = f(); + } + mode == "x" +} + // Fine: mutated in place through a method taking `&mut self`. fn grown() -> bool { let mut s = String::from("a"); @@ -194,8 +222,77 @@ fn grow(b: &mut Buf) -> bool { b.text == "a!" } +// Fine: part of the value is overwritten in place through an index. +struct Bytes { + buf: Box<[u8]>, +} + +fn bytes() -> [Bytes; 2] { + [ + Bytes { + buf: b"ab".to_vec().into_boxed_slice(), + }, + Bytes { + buf: b"cd".to_vec().into_boxed_slice(), + }, + ] +} + +fn poke(b: &mut Bytes) -> bool { + b.buf[0] = b'z'; + &b.buf[..] == b"zb" +} + +// Fine: destructured through `&mut`, so the binding is a `&mut` to the field. +struct Job { + state: String, + tries: u32, +} + +fn jobs() -> [Job; 2] { + [ + Job { + state: "queued".into(), + tries: 0, + }, + Job { + state: "done".into(), + tries: 0, + }, + ] +} + +fn advance(js: &mut [Job], next: &str) -> bool { + for Job { state, tries } in js.iter_mut() { + *tries += 1; + state.clear(); + state.push_str(next); + } + js[0].state == "done" +} + +// Fine: an explicit `ref mut` in a match arm writes the field. +struct Step { + kind: &'static str, +} + +impl Step { + fn set(&mut self, s: &'static str) { + match *self { + Step { ref mut kind } => *kind = s, + } + } +} + +fn steps() -> [Step; 2] { + [Step { kind: "download" }, Step { kind: "extract" }] +} + +fn is_download(s: &Step) -> bool { + s.kind == "download" +} + // Fine: `..base` fills the field from somewhere this site does not spell. -#[derive(Clone)] struct Opts { level: &'static str, n: u32, @@ -205,15 +302,12 @@ fn opts(base: &Opts) -> [Opts; 3] { [ Opts { level: "hi", n: 0 }, Opts { level: "lo", n: 1 }, - Opts { - n: 2, - ..base.clone() - }, + Opts { n: 2, ..*base }, ] } fn is_hi(o: &Opts) -> bool { - o.level == "hi" + o.level == "hi" && o.n < 5 } // Fine: exported, so other crates may store anything. @@ -248,12 +342,18 @@ fn main() { let _ = phases().iter().any(is_link); let _ = is_fast(&modes(true)); let _ = severity(true, false) + tier(3) as u8; + let _ = stages().iter().any(is_parse); let _ = banner(true); - let _ = from_env(|| "x") || grown(); + let _ = from_env(|| "x") || rebound(true, || "z") || grown(); let _ = named("c").iter().any(is_a); let _ = labels().iter().map(show).count(); let _ = fixed().iter().any(is_x); let _ = bufs().iter_mut().any(grow); + let _ = bytes().iter_mut().any(poke); + let _ = advance(&mut jobs(), "next"); + let mut all = steps(); + all[0].set("verify"); + let _ = all.iter().any(is_download); let base = Opts { level: "mid", n: 9 }; let _ = opts(&base).iter().any(is_hi); let _ = publics().iter().any(is_on); diff --git a/ui/stringly_state.stderr b/ui/stringly_state.stderr index 267e720..3aba631 100644 --- a/ui/stringly_state.stderr +++ b/ui/stringly_state.stderr @@ -47,6 +47,14 @@ LL | let level = if n > 9 { "hi" } else { "lo" }; | = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error +warning: every value stored in `step` is one of `"emit"`, `"parse"` (2 stores across the crate), and it is read by comparing against literals + --> $DIR/stringly_state.rs:117:5 + | +LL | step: &'static str, + | ^^^^^^^^^^^^^^^^^^ + | + = help: these strings are the variants of an enum; store the enum and keep the text in an `as_str` method, so a misspelt state is a compile error + warning: `severity` takes `bool` parameters `major` and `minor`, and 1 of its 1 call sites passes bare `true`/`false` for both: nothing at `severity(true, false)` says which flag each one sets --> $DIR/stringly_state.rs:92:4 | @@ -54,12 +62,12 @@ LL | fn severity(major: bool, minor: bool) -> u8 { | ^^^^^^^^ | note: one such call - --> $DIR/stringly_state.rs:250:13 + --> $DIR/stringly_state.rs:344:13 | LL | let _ = severity(true, false) + tier(3) as u8; | ^^^^^^^^^^^^^^^^^^^^^ = help: a two-variant enum per flag, or one options struct, names every argument at the call site and turns a swapped pair into a type error = note: `#[warn(bool_params)]` on by default -warning: 7 warnings emitted +warning: 8 warnings emitted From 1330be901118f3e98706cf602f866a5b818f019b Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:51:22 -0700 Subject: [PATCH 23/34] dependent_field counts ..base sites and the values assigned --- src/dependent_field.rs | 90 ++++++++++++++++++++++------- ui/dependent_field.rs | 118 +++++++++++++++++++++++++++++++++++++- ui/dependent_field.stderr | 10 +++- 3 files changed, 194 insertions(+), 24 deletions(-) diff --git a/src/dependent_field.rs b/src/dependent_field.rs index 3e5ba5d..d8c5f2b 100644 --- a/src/dependent_field.rs +++ b/src/dependent_field.rs @@ -13,6 +13,7 @@ use rustc_hir::{ }; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty; +use rustc_middle::ty::adjustment::{Adjust, AutoBorrow, AutoBorrowMutability}; use rustc_span::{Span, Symbol, SyntaxContext, sym}; rustc_session::declare_lint! { @@ -33,8 +34,10 @@ rustc_session::declare_lint! { /// written by hand, a test on a copy of the sibling rather than the /// sibling itself — keeps it quiet. So does any write of a real value /// outside that case: a construction that gives the field one beside - /// another value of the sibling, or an assignment to it neither under - /// the test nor in a block that also assigns the sibling that value. A + /// another value of the sibling (a field taken from `..other` counts as + /// real, one from `..Default::default()` as a placeholder), or an + /// assignment to it neither under the test nor in a block that also + /// assigns the sibling that value. A /// sibling never given the tested value by any literal or assignment /// keeps it quiet too (the field is then dead, not dependent). Reads in /// derived impls are not counted. @@ -82,9 +85,9 @@ pub struct DependentField { reads: HashMap<(DefId, Symbol), Vec>>, /// struct -> per literal construction site, what each named field got. sites: HashMap>>, - /// Fields assigned after construction, whose values the sites do not - /// bound. - assigned: HashSet<(DefId, Symbol)>, + /// (struct, field) -> the values assigned to it after construction + /// (`None` for one not spelled out), which the sites do not bound. + assigned: HashMap<(DefId, Symbol), HashSet>>, /// (struct, field) -> per assignment of a real value, the sibling tests /// dominating it plus the sibling values assigned beside it in the same /// block: the cases that write can belong to. @@ -413,6 +416,25 @@ fn assigned_beside(cx: &LateContext<'_>, base: &Expr<'_>, at: HirId, out: &mut H } } +/// `x.g += ..`, `&mut x.g`, or the auto-`&mut` a mutating method call takes: +/// `g` changes to something not spelled out. +fn mutated_in_place(cx: &LateContext<'_>, place: &Expr<'_>, parent: Node<'_>) -> bool { + if let Node::Expr(p) = parent + && let ExprKind::AssignOp(_, lhs, _) + | ExprKind::AddrOf(BorrowKind::Ref | BorrowKind::Raw, Mutability::Mut, lhs) = p.kind + && lhs.hir_id == place.hir_id + { + return true; + } + cx.typeck_results().expr_adjustments(place).iter().any(|a| { + matches!( + a.kind, + Adjust::Borrow(AutoBorrow::Ref(AutoBorrowMutability::Mut { .. })) + | Adjust::Borrow(AutoBorrow::RawPtr(Mutability::Mut)) + ) + }) +} + impl DependentField { fn record_read(&mut self, adt: DefId, field: Symbol, tests: HashSet) { self.reads.entry((adt, field)).or_default().push(tests); @@ -426,10 +448,16 @@ impl<'tcx> LateLintPass<'tcx> for DependentField { let Some(adt) = relevant(cx, cx.typeck_results().expr_ty(expr)) else { return; }; - if !matches!(tail, StructTailExpr::None) { - return; - } - let site = fields + // What `..` fills the unlisted fields with: don't-cares from + // `..Default::default()` or declared defaults, somebody's real + // values from `..other`. + let rest = match tail { + StructTailExpr::None => None, + StructTailExpr::Base(b) => Some(is_placeholder(cx, b)), + StructTailExpr::DefaultFields(_) => Some(true), + StructTailExpr::NoneWithError(_) => return, + }; + let mut site: HashMap = fields .iter() .map(|f| { let init = Init { @@ -439,6 +467,14 @@ impl<'tcx> LateLintPass<'tcx> for DependentField { (f.ident.name, init) }) .collect(); + if let Some(placeholder) = rest { + for f in &adt.non_enum_variant().fields { + site.entry(f.name).or_insert(Init { + value: None, + placeholder, + }); + } + } self.sites.entry(adt.did()).or_default().push(site); } ExprKind::Field(base, ident) => { @@ -451,12 +487,17 @@ impl<'tcx> LateLintPass<'tcx> for DependentField { return; } // `base.f = ..` writes; everything else, `base.f += ..` and - // `&mut base.f` included, reads. - if let Node::Expr(parent) = cx.tcx.parent_hir_node(expr.hir_id) + // `&mut base.f` included, reads (and, through the `&mut`, + // may leave behind a value no site spells out). + let parent = cx.tcx.parent_hir_node(expr.hir_id); + if let Node::Expr(parent) = parent && let ExprKind::Assign(lhs, rhs, _) = parent.kind && lhs.hir_id == expr.hir_id { - self.assigned.insert((adt.did(), ident.name)); + self.assigned + .entry((adt.did(), ident.name)) + .or_default() + .insert(expr_value(cx, rhs)); if !is_placeholder(cx, rhs) { let mut cases = dominating_tests(cx, expr, base); assigned_beside(cx, base, parent.hir_id, &mut cases); @@ -467,6 +508,12 @@ impl<'tcx> LateLintPass<'tcx> for DependentField { } return; } + if mutated_in_place(cx, expr, parent) { + self.assigned + .entry((adt.did(), ident.name)) + .or_default() + .insert(None); + } let tests = dominating_tests(cx, expr, base); self.record_read(adt.did(), ident.name, tests); } @@ -517,14 +564,17 @@ impl<'tcx> LateLintPass<'tcx> for DependentField { let sites = self.sites.get(did).map_or(&[][..], Vec::as_slice); for test in candidates { // The tested case must be one the crate makes: a literal site - // with that value, a site whose value is not spelled out, or - // any later assignment. Otherwise the field is never read at - // all, which is not this lint's claim. - let reached = self.assigned.contains(&(*did, test.sibling)) - || sites.iter().any(|s| { - s.get(&test.sibling) - .is_some_and(|i| i.value.is_none_or(|v| v == test.value)) - }); + // or a later assignment giving the sibling that value or one + // not spelled out. Otherwise the field is never read at all, + // which is not this lint's claim. + let admits = |v: Option| v.is_none_or(|v| v == test.value); + let reached = self + .assigned + .get(&(*did, test.sibling)) + .is_some_and(|vs| vs.iter().copied().any(admits)) + || sites + .iter() + .any(|s| s.get(&test.sibling).is_some_and(|i| admits(i.value))); if !reached { continue; } diff --git a/ui/dependent_field.rs b/ui/dependent_field.rs index 5cfe598..b12ad16 100644 --- a/ui/dependent_field.rs +++ b/ui/dependent_field.rs @@ -122,6 +122,33 @@ fn finished(j: &Job) -> Option { } } +// Flagged: depth 1 is only ever reached through `+= 1`, never spelled out, +// and `href` is set and read only there. +struct Nest { + depth: u32, + href: Option, +} + +fn nest() -> Nest { + Nest { + depth: 0, + href: None, + } +} + +fn open(n: &mut Nest, h: u8) { + n.depth += 1; + if n.depth == 1 { + n.href = Some(h); + } +} + +fn close(n: &mut Nest) -> Option { + let out = if n.depth == 1 { n.href.take() } else { None }; + n.depth -= 1; + out +} + // Fine: `names` is filled in by an assignment that has nothing to do with // `dirty`, so it means something in both cases even though the one reader // happens to check `dirty` first. @@ -301,8 +328,9 @@ fn tally(t: &Tally) -> u32 { } } -// Fine: no `Printer` is ever made or set non-minifying, so `out` is dead -// rather than the payload of a case the crate has. +// Fine: no `Printer` is ever made or set non-minifying (the one assignment +// sets `minify` again), so `out` is dead rather than the payload of a case +// the crate has. struct Printer { minify: bool, out: String, @@ -315,12 +343,86 @@ fn printer() -> Printer { } } +fn reminify(p: &mut Printer) { + p.minify = true; +} + fn newline(p: &mut Printer) { if !p.minify { p.out.push('\n'); } } +// Fine: the `..Default::default()` construction gives `line` a real value +// beside `eof: false`, the case the reader does not test for. +#[derive(Default)] +struct Cursor { + eof: bool, + line: u32, + col: u32, +} + +fn cursors(l: u32) -> [Cursor; 3] { + [ + Cursor { + eof: true, + line: l, + col: 0, + }, + Cursor { + eof: false, + line: l + 1, + ..Default::default() + }, + Cursor { + eof: false, + line: 0, + col: 0, + }, + ] +} + +fn last_line(c: &Cursor) -> u32 { + let col = c.col; + if c.eof { c.line + col } else { col } +} + +// Fine: `..h` hands `raw` on from a `Subproc` handle to a `Cmd` one, a real +// value beside another tag. +struct Handle { + node: u32, + tag: Tag, + raw: usize, +} + +fn cmd_handle(node: u32) -> Handle { + Handle { + node, + tag: Tag::Cmd, + raw: 0, + } +} + +fn sub_handle(raw: usize) -> Handle { + Handle { + node: 0, + tag: Tag::Subproc, + raw, + } +} + +fn demote(h: Handle) -> Handle { + Handle { tag: Tag::Cmd, ..h } +} + +fn handle_raw(h: &Handle) -> usize { + if h.tag == Tag::Subproc { + h.raw + } else { + h.node as usize + } +} + // Fine: exported, so other crates read it where they like. pub struct Public { pub on: bool, @@ -343,6 +445,9 @@ fn main() { let mut j = job(); run(&mut j, 3); let _ = finished(&j); + let mut ne = nest(); + open(&mut ne, 6); + let _ = close(&mut ne); let mut f = folder(); scan(&mut f, vec![1], true); let _ = sweep(&f); @@ -356,7 +461,14 @@ fn main() { let _ = (extra(&m), extra(&n)); let [t, w] = tallies(7); let _ = (tally(&t), tally(&w)); - newline(&mut printer()); + let mut pr = printer(); + reminify(&mut pr); + newline(&mut pr); + let _ = cursors(4).iter().map(last_line).sum::(); + let _ = ( + handle_raw(&cmd_handle(1)), + handle_raw(&demote(sub_handle(8))), + ); let [u, v] = public(1); let _ = (public_data(&u), public_data(&v)); } diff --git a/ui/dependent_field.stderr b/ui/dependent_field.stderr index 2979a33..a6404e4 100644 --- a/ui/dependent_field.stderr +++ b/ui/dependent_field.stderr @@ -23,5 +23,13 @@ LL | result: u64, | = help: the field is the payload of that one case, stored flat; an enum variant carrying it leaves the other cases nothing to fill in or misread -warning: 3 warnings emitted +warning: `href` is only read where `depth == 1` has been tested (1 read), and every `Nest` made with another `depth` fills it with a placeholder (1 site) + --> $DIR/dependent_field.rs:129:5 + | +LL | href: Option, + | ^^^^^^^^^^^^^^^^ + | + = help: the field is the payload of that one case, stored flat; an enum variant carrying it leaves the other cases nothing to fill in or misread + +warning: 4 warnings emitted From d1bd233dab1ebc115135c03d8eca49121ce0c206 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:51:29 -0700 Subject: [PATCH 24/34] compare parameter patterns and shared locals in structural clones --- src/hir_clone.rs | 146 +++++++++++++++++++++++++++------ src/reimplemented_helper.rs | 5 +- ui/reimplemented_helper.rs | 53 ++++++++++++ ui/reimplemented_helper.stderr | 15 +++- ui/same_match_twice.rs | 34 ++++++++ ui/same_match_twice.stderr | 23 +++++- 6 files changed, 247 insertions(+), 29 deletions(-) diff --git a/src/hir_clone.rs b/src/hir_clone.rs index 9b6c122..bf0df8c 100644 --- a/src/hir_clone.rs +++ b/src/hir_clone.rs @@ -2,10 +2,12 @@ //! to confirm them, both blind to spans, `HirId`s and binding names, both //! reading paths by what they resolve to. Thin over //! `clippy_utils::hir_utils`; what lives here is the pairing of local -//! bindings across two bodies, which `SpanlessEq` leaves to its caller (a -//! `Res::Local` on the left equals one on the right only when the two are -//! pre-mapped), and never `deny_side_effects`, since that makes every method -//! call unequal to itself. +//! bindings across two bodies or two expressions, which `SpanlessEq` leaves +//! to its caller (a `Res::Local` on the left equals one on the right when +//! the two are pre-mapped or identical), the comparison of parameter +//! patterns, which `SpanlessEq::eq_body` skips, and never +//! `deny_side_effects`, since that makes every method call unequal to +//! itself. //! //! Hash where typeck results exist (`check_fn`, `check_expr`); the equality //! functions work from `check_crate_post`, where the context has no @@ -15,28 +17,99 @@ use clippy_utils::{SpanlessEq, SpanlessHash}; use rustc_hir::def::Res; use rustc_hir::def_id::LocalDefId; use rustc_hir::intravisit::{Visitor, walk_expr, walk_pat}; -use rustc_hir::{BodyId, Expr, ExprKind, HirId, HirIdSet, Pat, PatKind, QPath}; +use rustc_hir::{BodyId, Expr, ExprKind, HirId, HirIdMap, HirIdSet, Pat, PatKind, QPath}; use rustc_lint::LateContext; use rustc_span::SyntaxContext; /// Structural hash of a fn body: spans, `HirId`s and binding names do not /// contribute; resolved paths, literals, field and method names, operators -/// and shape do. +/// and shape do. Parameter patterns do not contribute either, so two bodies +/// `bodies_equal` tells apart by their parameters can share a hash. pub(crate) fn body_hash(cx: &LateContext<'_>, body: BodyId) -> u64 { let mut h = SpanlessHash::new(cx).paths_by_resolution(); h.hash_body(body); h.finish() } -/// The bindings the body's parameter patterns introduce, in source order. -fn param_bindings(cx: &LateContext<'_>, body: BodyId) -> Vec { - let mut ids = Vec::new(); - for param in cx.tcx.hir_body(body).params { - param - .pat - .each_binding_or_first(&mut |_, id, _, _| ids.push(id)); +/// Two parameter patterns destructure the same way: same shape at every +/// level, same constructor, same fields in the same order, same binding +/// modes. Each binding on the left is paired with the binding at its place +/// on the right in `locals`. `SpanlessEq` compares a body's value but not +/// its parameters, so without this `(a, _)` and `(_, b)` of one tuple type +/// would pair `a` with `b`. Or-patterns, ranges and literals are refutable +/// and cannot be a whole parameter; anything else unlisted is unequal. +fn eq_param_pat( + cx: &LateContext<'_>, + locals: &mut HirIdMap, + l: &Pat<'_>, + r: &Pat<'_>, +) -> bool { + let same_ctor = |lq: &QPath<'_>, rq: &QPath<'_>| { + let res = cx.qpath_res(lq, l.hir_id); + res != Res::Err && res == cx.qpath_res(rq, r.hir_id) + }; + match (&l.kind, &r.kind) { + (PatKind::Wild, PatKind::Wild) => true, + (PatKind::Binding(lm, lid, _, lsub), PatKind::Binding(rm, rid, _, rsub)) => { + let eq = lm == rm && eq_opt_pat(cx, locals, *lsub, *rsub); + if eq { + locals.insert(*lid, *rid); + } + eq + } + (PatKind::Tuple(ls, ld), PatKind::Tuple(rs, rd)) => { + ld == rd && eq_param_pats(cx, locals, ls, rs) + } + (PatKind::TupleStruct(lq, ls, ld), PatKind::TupleStruct(rq, rs, rd)) => { + ld == rd && same_ctor(lq, rq) && eq_param_pats(cx, locals, ls, rs) + } + (PatKind::Struct(lq, lfs, lrest), PatKind::Struct(rq, rfs, rrest)) => { + lrest.is_some() == rrest.is_some() + && same_ctor(lq, rq) + && lfs.len() == rfs.len() + && lfs.iter().zip(*rfs).all(|(lf, rf)| { + lf.ident.name == rf.ident.name && eq_param_pat(cx, locals, lf.pat, rf.pat) + }) + } + (PatKind::Ref(lp, lpin, lm), PatKind::Ref(rp, rpin, rm)) => { + lpin == rpin && lm == rm && eq_param_pat(cx, locals, lp, rp) + } + (PatKind::Box(lp), PatKind::Box(rp)) | (PatKind::Deref(lp), PatKind::Deref(rp)) => { + eq_param_pat(cx, locals, lp, rp) + } + (PatKind::Slice(lb, lm, la), PatKind::Slice(rb, rm, ra)) => { + eq_param_pats(cx, locals, lb, rb) + && eq_opt_pat(cx, locals, *lm, *rm) + && eq_param_pats(cx, locals, la, ra) + } + _ => false, + } +} + +fn eq_param_pats( + cx: &LateContext<'_>, + locals: &mut HirIdMap, + ls: &[Pat<'_>], + rs: &[Pat<'_>], +) -> bool { + ls.len() == rs.len() + && ls + .iter() + .zip(rs) + .all(|(l, r)| eq_param_pat(cx, locals, l, r)) +} + +fn eq_opt_pat( + cx: &LateContext<'_>, + locals: &mut HirIdMap, + l: Option<&Pat<'_>>, + r: Option<&Pat<'_>>, +) -> bool { + match (l, r) { + (None, None) => true, + (Some(l), Some(r)) => eq_param_pat(cx, locals, l, r), + _ => false, } - ids } /// Two fn bodies are the same computation up to renaming of parameters and @@ -45,19 +118,28 @@ fn param_bindings(cx: &LateContext<'_>, body: BodyId) -> Vec { /// different receiver types is the same call to this function. Two bodies /// containing a closure are never equal (`SpanlessEq` refuses closures). pub(crate) fn bodies_equal(cx: &LateContext<'_>, l: BodyId, r: BodyId) -> bool { - let (lp, rp) = (param_bindings(cx, l), param_bindings(cx, r)); - if lp.len() != rp.len() { + let (lp, rp) = (cx.tcx.hir_body(l).params, cx.tcx.hir_body(r).params); + let mut locals = HirIdMap::default(); + if lp.len() != rp.len() + || !lp + .iter() + .zip(rp) + .all(|(l, r)| eq_param_pat(cx, &mut locals, l.pat, r.pat)) + { return false; } let mut eq = SpanlessEq::new(cx).paths_by_resolution(); let mut ie = eq.inter_expr(SyntaxContext::root()); - ie.locals.extend(lp.into_iter().zip(rp)); + ie.locals = locals; ie.eq_body(l, r) } /// Erased signatures equal: same arity, each input and the output the same -/// `Ty` once late-bound regions are erased. For methods input 0 is `Self`, -/// which is what keeps `Foo::is_empty` and `Bar::is_empty` apart. +/// `Ty` once late-bound regions are erased, and the same where-clauses. For +/// methods input 0 is `Self`, which is what keeps `Foo::is_empty` and +/// `Bar::is_empty` apart. The where-clauses matter because a method call in +/// the body compares by name: `t.go()` under `T: X` and under `T: Y` are two +/// different functions spelled alike. pub(crate) fn fn_sigs_equal(cx: &LateContext<'_>, l: LocalDefId, r: LocalDefId) -> bool { let sig = |d: LocalDefId| { cx.tcx.instantiate_bound_regions_with_erased( @@ -67,7 +149,13 @@ pub(crate) fn fn_sigs_equal(cx: &LateContext<'_>, l: LocalDefId, r: LocalDefId) .skip_normalization(), ) }; - sig(l).inputs_and_output == sig(r).inputs_and_output + let bounds = |d: LocalDefId| { + cx.tcx + .predicates_of(d.to_def_id()) + .instantiate_identity(cx.tcx) + .predicates + }; + sig(l).inputs_and_output == sig(r).inputs_and_output && bounds(l) == bounds(r) } /// Structural hash of one expression, for bucketing across bodies. Every @@ -111,17 +199,25 @@ fn free_locals(e: &Expr<'_>) -> Vec { v.free } -/// Two expressions from two bodies (each given with its body owner) are the -/// same computation up to renaming: the locals each reads from outside -/// itself are paired in order of first use and must have the same type in -/// their own body, and under that pairing the two are structurally equal. +/// Two expressions (each given with its body owner, which may be the same) +/// are the same computation up to renaming: the locals each reads from +/// outside itself are paired in order of first use and must have the same +/// type in their own body, and under that pairing the two are structurally +/// equal. When both come from one body a local read by both sides can only +/// pair with itself: `SpanlessEq` accepts an identical local regardless of +/// the pairing, so any other pairing of it would not be a renaming. pub(crate) fn exprs_equal( cx: &LateContext<'_>, (l_owner, l): (LocalDefId, &Expr<'_>), (r_owner, r): (LocalDefId, &Expr<'_>), ) -> bool { let (lf, rf) = (free_locals(l), free_locals(r)); - if lf.len() != rf.len() { + if lf.len() != rf.len() + || lf + .iter() + .zip(&rf) + .any(|(a, b)| a != b && (rf.contains(a) || lf.contains(b))) + { return false; } let (lt, rt) = (cx.tcx.typeck(l_owner), cx.tcx.typeck(r_owner)); diff --git a/src/reimplemented_helper.rs b/src/reimplemented_helper.rs index d4b0e1b..3641bab 100644 --- a/src/reimplemented_helper.rs +++ b/src/reimplemented_helper.rs @@ -15,8 +15,9 @@ use crate::hir_clone::{bodies_equal, body_hash, fn_sigs_equal}; rustc_session::declare_lint! { /// Flags a function whose signature and body are the same as another - /// function's in the crate: the same parameter and return types, and the - /// same computation once parameters and locals are renamed. One helper + /// function's in the crate: the same parameter and return types and + /// bounds, parameters destructured the same way, and the same + /// computation once parameters and locals are renamed. One helper /// exists twice under two names, nothing ties the copies together, and a /// fix made to one is silently missing from the other. /// diff --git a/ui/reimplemented_helper.rs b/ui/reimplemented_helper.rs index 838ec1b..fc33cb5 100644 --- a/ui/reimplemented_helper.rs +++ b/ui/reimplemented_helper.rs @@ -89,6 +89,59 @@ impl Resize for Row { } } +pub fn low_third((lo, _): (u32, u32), bias: u32) -> u32 { + let v = lo.saturating_mul(3).saturating_add(bias); + if v > 255 { 255 } else { v } +} + +// Fine: the same body over the other half of the pair. The parameter +// patterns differ, so `hi` is not `lo` renamed. +pub fn high_third((_, hi): (u32, u32), bias: u32) -> u32 { + let v = hi.saturating_mul(3).saturating_add(bias); + if v > 255 { 255 } else { v } +} + +// Flagged: destructures the same half as `low_third`. +pub fn first_third((first, _): (u32, u32), k: u32) -> u32 { + let n = first.saturating_mul(3).saturating_add(k); + if n > 255 { 255 } else { n } +} + +pub struct Extent { + pub start: u32, + pub end: u32, +} + +pub fn lead(Extent { start, .. }: Extent, pad: u32) -> u32 { + start.saturating_sub(pad).saturating_mul(2).min(4096) / 8 + pad * 2 +} + +// Fine: reads the other field of `Extent` through a pattern of the same +// shape, so `end` is not `start` renamed. +pub fn trail(Extent { end, .. }: Extent, pad: u32) -> u32 { + end.saturating_sub(pad).saturating_mul(2).min(4096) / 8 + pad * 2 +} + +pub trait Near { + fn step(&self) -> u32; +} + +pub trait Far { + fn step(&self) -> u32; +} + +pub fn walk_near(t: &T, from: u32, budget: u32) -> u32 { + let hop = t.step().max(1); + from.saturating_add(hop.saturating_mul(budget)) / hop + 1 +} + +// Fine: `t.step()` is `Far::step` here and `Near::step` above; the bound is +// part of the signature, so the two are different functions spelled alike. +pub fn walk_far(t: &T, from: u32, budget: u32) -> u32 { + let hop = t.step().max(1); + from.saturating_add(hop.saturating_mul(budget)) / hop + 1 +} + // Quiet: closures are never compared structurally, so two bodies built // around one never pair up even when they are copies. pub fn doubled_evens(xs: &[u32]) -> Vec { diff --git a/ui/reimplemented_helper.stderr b/ui/reimplemented_helper.stderr index c2d1dd4..8ab71e7 100644 --- a/ui/reimplemented_helper.stderr +++ b/ui/reimplemented_helper.stderr @@ -25,5 +25,18 @@ LL | pub fn extent(&self) -> u32 { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ = help: one helper written twice drifts apart at the first fix; keep one and call it from the other's callers -warning: 2 warnings emitted +warning: `first_third` has the same signature and body as `low_third` + --> $DIR/reimplemented_helper.rs:105:1 + | +LL | pub fn first_third((first, _): (u32, u32), k: u32) -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | +note: the same body is here + --> $DIR/reimplemented_helper.rs:92:1 + | +LL | pub fn low_third((lo, _): (u32, u32), bias: u32) -> u32 { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: one helper written twice drifts apart at the first fix; keep one and call it from the other's callers + +warning: 3 warnings emitted diff --git a/ui/same_match_twice.rs b/ui/same_match_twice.rs index 53b35b2..d49629c 100644 --- a/ui/same_match_twice.rs +++ b/ui/same_match_twice.rs @@ -203,6 +203,40 @@ pub fn area_scaled(s: &Shape, k: u32) -> u32 { } } +#[derive(Clone, Copy)] +pub enum Axis { + Across, + Down, +} + +// Flagged: the same match a second time in one body, over the same locals. +pub fn reach(axis: Axis, w: u32, h: u32) -> (u32, u32) { + let near = match axis { + Axis::Across => w, + Axis::Down => h + 1, + }; + let far = match axis { + Axis::Across => w, + Axis::Down => h + 1, + }; + (near, far * 2) +} + +// Fine: two matches in one body reading the same locals in different +// places. `w` stands where `h` does in the first arm and where `w` itself +// does in the second, so no renaming of locals turns one into the other. +pub fn corners(axis: Axis, w: u32, h: u32, scale: u32) -> ((u32, u32), (u32, u32)) { + let p = match axis { + Axis::Across => (w, scale), + Axis::Down => (scale, w), + }; + let q = match axis { + Axis::Across => (h, w), + Axis::Down => (w, w), + }; + (p, q) +} + // Fine: a macro that expands its argument twice makes two matches out of // one piece of source, which is still one copy. macro_rules! both_ways { diff --git a/ui/same_match_twice.stderr b/ui/same_match_twice.stderr index e67c614..3816fce 100644 --- a/ui/same_match_twice.stderr +++ b/ui/same_match_twice.stderr @@ -69,5 +69,26 @@ LL | | } | |_____^ = help: the mapping lives in two places kept in step by hand; a method on the enum states it once -warning: 3 warnings emitted +warning: this `match` on `Axis` is written out arm for arm a second time + --> $DIR/same_match_twice.rs:218:15 + | +LL | let far = match axis { + | _______________^ +LL | | Axis::Across => w, +LL | | Axis::Down => h + 1, +LL | | }; + | |_____^ + | +note: the same match is here + --> $DIR/same_match_twice.rs:214:16 + | +LL | let near = match axis { + | ________________^ +LL | | Axis::Across => w, +LL | | Axis::Down => h + 1, +LL | | }; + | |_____^ + = help: the mapping lives in two places kept in step by hand; a method on the enum states it once + +warning: 4 warnings emitted From fda3a81665205ea1682865c389f87b3332498f08 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:51:36 -0700 Subject: [PATCH 25/34] sentinel_int reads asserts, match arms and sums, skips keyed lookups --- src/sentinel_int.rs | 326 +++++++++++++++++++++++++++++++---------- ui/sentinel_int.rs | 58 ++++++++ ui/sentinel_int.stderr | 30 +++- 3 files changed, 331 insertions(+), 83 deletions(-) diff --git a/src/sentinel_int.rs b/src/sentinel_int.rs index 508bdc7..c1821ba 100644 --- a/src/sentinel_int.rs +++ b/src/sentinel_int.rs @@ -1,15 +1,19 @@ use std::collections::{HashMap, HashSet}; use clippy_utils::higher::Range; +use clippy_utils::macros::{find_assert_eq_args, root_macro_call_first_node}; use clippy_utils::res::MaybeResPath; use rustc_ast::LitKind; use rustc_hir::def::{DefKind, Res}; use rustc_hir::def_id::DefId; -use rustc_hir::{BinOpKind, Expr, ExprKind, HirId, LetStmt, PatKind, QPath, UnOp}; +use rustc_hir::{ + BinOpKind, Expr, ExprKind, HirId, LetStmt, Pat, PatExpr, PatExprKind, PatKind, QPath, UnOp, +}; use rustc_lint::{LateContext, LateLintPass}; -use rustc_span::{Span, Symbol}; +use rustc_middle::ty::{self, Ty}; +use rustc_span::{Span, Symbol, sym}; -use crate::adt_facts::{field_ty, struct_field}; +use crate::adt_facts::{field_ty, is_option_ty, struct_field}; use crate::baseline::emit; use crate::hir_shapes::{assigned_field, callee_of, peel_blocks_unsafe}; @@ -26,15 +30,19 @@ rustc_session::declare_lint! { /// /// Reported on the unchecked reader. A function counts as checking the /// field if it compares the field, or a local read off it, against - /// anything (`==`, `!=`, an ordering test against a length), clamps it - /// (`min`, `checked_add`, ..), or directly calls a predicate (a - /// `bool`-returning function) that does; a function all of whose visible - /// callers check is their unchecked half and stays quiet too. `.get(i)` - /// already answers for an index that is not there and is not counted. - /// Plain arithmetic on the field is not a use: positions and lengths are - /// summed everywhere and the sum is only wrong where it meets memory. A - /// field only ever *assigned* `MAX` and never compared to it is a bound, - /// not a missing value, and is left alone. + /// anything (`==`, `!=`, `assert_ne!`, an ordering test against a length, + /// a `match` arm or `matches!` with a literal or constant pattern), clamps + /// it (`min`, `checked_add`, ..), looks it up with `.get(i)`, or directly + /// calls a predicate (a `bool`-returning function) that does; a function + /// all of whose visible callers check is their unchecked half and stays + /// quiet too. `.get(i)` and keyed lookups (`map.remove(&x.f)`, `map[&x.f]`) + /// already answer for a value that is not there and are not uses. + /// `wrapping_*` arithmetic opts out of any range decision and is neither + /// a check nor, on an integer, a use. Plain arithmetic on the field is not + /// a use: positions and lengths are summed everywhere and the sum is only + /// wrong where it meets memory. A field only ever *assigned* `MAX` and + /// never compared to it is a bound, not a missing value, and is left + /// alone. pub SENTINEL_INT, Warn, "an integer field compared to a sentinel by one reader and indexed with unchecked by another" @@ -68,8 +76,8 @@ pub struct SentinelInt { reads: HashMap>, /// The function tests the field, or a value read off it, somewhere. checked: HashSet<(DefId, Field)>, - /// `let i = x.f as usize`: locals that carry a field's value. - locals: HashMap, + /// `let i = x.f as usize + y.g`: locals that carry fields' values. + locals: HashMap>, /// Function -> local `bool`-returning functions it calls directly. calls: HashMap>, /// Local function -> functions that call it directly. @@ -94,6 +102,26 @@ fn names_absence(name: &str) -> bool { .any(|w| matches!(w, "INVALID" | "NONE" | "SENTINEL")) } +fn is_minus_one(lit: LitKind) -> bool { + matches!(lit, LitKind::Int(v, _) if v.get() == 1) +} + +/// A constant that spells a sentinel: `core`'s `MAX`, or one named for +/// absence. +fn sentinel_const(cx: &LateContext<'_>, res: Res) -> Option { + let Res::Def(DefKind::Const { .. } | DefKind::AssocConst { .. }, did) = res else { + return None; + }; + let name = cx.tcx.item_name(did); + if name.as_str() == "MAX" && cx.tcx.crate_name(did.krate).as_str() == "core" { + Some(Sentinel::Max) + } else if names_absence(name.as_str()) { + Some(Sentinel::Named(did)) + } else { + None + } +} + /// The sentinel an expression spells, if it is one of the three forms. fn sentinel_of<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option { let e = peel_blocks_unsafe(e); @@ -102,31 +130,46 @@ fn sentinel_of<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option match peel_blocks_unsafe(inner).kind { - ExprKind::Lit(lit) if matches!(lit.node, LitKind::Int(v, _) if v.get() == 1) => { - Some(Sentinel::MinusOne) - } - _ => None, - }, - ExprKind::Path(ref qpath) => match cx.qpath_res(qpath, e.hir_id) { - Res::Def(DefKind::Const { .. } | DefKind::AssocConst { .. }, did) => { - let name = cx.tcx.item_name(did); - if name.as_str() == "MAX" && cx.tcx.crate_name(did.krate).as_str() == "core" { - Some(Sentinel::Max) - } else if names_absence(name.as_str()) { - Some(Sentinel::Named(did)) - } else { - None - } - } + ExprKind::Lit(lit) if is_minus_one(lit.node) => Some(Sentinel::MinusOne), _ => None, }, + ExprKind::Path(ref qpath) => sentinel_const(cx, cx.qpath_res(qpath, e.hir_id)), _ => None, } } -fn spelling(cx: &LateContext<'_>, s: &Sentinel, e: &Expr<'_>) -> String { +/// The sentinel a `match` arm pattern spells: `-1` or a constant path. +fn pat_sentinel(cx: &LateContext<'_>, pe: &PatExpr<'_>) -> Option { + match pe.kind { + PatExprKind::Lit { lit, negated: true } if is_minus_one(lit.node) => { + Some(Sentinel::MinusOne) + } + PatExprKind::Path(ref qpath) => sentinel_const(cx, cx.qpath_res(qpath, pe.hir_id)), + PatExprKind::Lit { .. } => None, + } +} + +/// The arm patterns that test the scrutinee's value — a literal, constant or +/// range — through `|`, `&` and guards. +fn value_pats<'a>(pat: &'a Pat<'a>, out: &mut Vec<&'a Pat<'a>>) { + match pat.kind { + PatKind::Expr(_) | PatKind::Range(..) => out.push(pat), + PatKind::Or(alts) => { + for alt in alts { + value_pats(alt, out); + } + } + PatKind::Ref(inner, _, _) + | PatKind::Deref(inner) + | PatKind::Box(inner) + | PatKind::Guard(inner, _) => value_pats(inner, out), + _ => {} + } +} + +fn spelling<'tcx>(cx: &LateContext<'tcx>, s: &Sentinel, ty: Ty<'tcx>) -> String { match s { - Sentinel::Max => format!("{}::MAX", cx.typeck_results().expr_ty(e)), + Sentinel::Max => format!("{ty}::MAX"), Sentinel::MinusOne => "-1".to_owned(), Sentinel::Named(did) => cx.tcx.item_name(*did).to_string(), } @@ -156,6 +199,35 @@ fn owner_fn(cx: &LateContext<'_>, hir_id: HirId) -> DefId { did } +/// Memory a `usize` positions into, where the INDEXERS calls panic or are UB +/// past the end. +fn is_contiguous<'tcx>(cx: &LateContext<'tcx>, ty: Ty<'tcx>) -> bool { + match ty.kind() { + ty::Slice(_) | ty::Array(..) | ty::Str => true, + ty::Adt(adt, _) => { + cx.tcx.is_diagnostic_item(sym::Vec, adt.did()) + || cx.tcx.is_diagnostic_item(sym::String, adt.did()) + } + _ => false, + } +} + +/// An INDEXERS call that does not answer for an index that is not there: +/// on contiguous memory, or on anything else unless its result is the +/// `Option`/`bool` a keyed collection (`map.remove`, `deque.remove`) hands +/// back for an absent key. +fn indexes_positionally<'tcx>( + cx: &LateContext<'tcx>, + call: &'tcx Expr<'tcx>, + recv: &'tcx Expr<'tcx>, +) -> bool { + if is_contiguous(cx, cx.typeck_results().expr_ty_adjusted(recv).peel_refs()) { + return true; + } + let out = cx.typeck_results().expr_ty(call); + !(out.is_bool() || out.is_unit() || is_option_ty(cx, out)) +} + /// Value-preserving wrappers a field read is still visible through. const ADAPTERS: &[&str] = &[ "clone", @@ -167,8 +239,8 @@ const ADAPTERS: &[&str] = &[ "cast_unsigned", ]; -/// Calls that index their receiver by their one argument and do not answer -/// for an index that is not there. +/// Calls that index contiguous memory by their one argument and do not +/// answer for an index that is not there. const INDEXERS: &[&str] = &[ "get_unchecked", "get_unchecked_mut", @@ -186,12 +258,30 @@ const OFFSETS: &[&str] = &[ "byte_add", "byte_sub", "byte_offset", + "wrapping_add", + "wrapping_sub", + "wrapping_offset", + "wrapping_byte_add", + "wrapping_byte_sub", + "wrapping_byte_offset", ]; impl SentinelInt { - /// The field whose value `e` carries: the field itself through casts, - /// borrows, derefs and value-preserving adapters, or a local bound to one. - fn read_of<'tcx>(&self, cx: &LateContext<'tcx>, mut e: &'tcx Expr<'tcx>) -> Option { + /// Every field whose value `e` carries: the field itself through casts, + /// borrows, derefs and value-preserving adapters, a local bound to one, + /// or both operands of a sum. + fn reads_of<'tcx>(&self, cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Vec { + let mut out = Vec::new(); + self.collect_reads(cx, e, &mut out); + out + } + + fn collect_reads<'tcx>( + &self, + cx: &LateContext<'tcx>, + mut e: &'tcx Expr<'tcx>, + out: &mut Vec, + ) { loop { e = peel_blocks_unsafe(e); match e.kind { @@ -210,31 +300,95 @@ impl SentinelInt { { e = arg; } - // `off + len`, `idx - 1`: the sum still carries the sentinel. + // `off + len`, `idx - 1`: the sum still carries the sentinel + // of either side. ExprKind::Binary(op, l, r) if matches!(op.node, BinOpKind::Add | BinOpKind::Sub | BinOpKind::Mul) => { - return self.read_of(cx, l).or_else(|| self.read_of(cx, r)); + self.collect_reads(cx, l, out); + e = r; + } + ExprKind::Field(base, ident) => { + out.extend(field_key(cx, base, ident.name)); + return; } - ExprKind::Field(base, ident) => return field_key(cx, base, ident.name), ExprKind::Path(_) => { - return e - .res_local_id() - .and_then(|id| self.locals.get(&id).copied()); + if let Some(id) = e.res_local_id() + && let Some(fields) = self.locals.get(&id) + { + out.extend_from_slice(fields); + } + return; } - _ => return None, + _ => return, } } } - fn compared(&mut self, cx: &LateContext<'_>, field: Field, s: &Sentinel, at: &Expr<'_>) { + fn compared<'tcx>(&mut self, cx: &LateContext<'tcx>, field: Field, s: &Sentinel, ty: Ty<'tcx>) { let ev = self.evidence.entry(field).or_default(); if ev.compared == 0 { - ev.spelling = spelling(cx, s, at); + ev.spelling = spelling(cx, s, ty); } ev.compared += 1; } + /// The enclosing function tests every field `e` carries; those fields. + fn tested<'tcx>(&mut self, cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Vec { + let fields = self.reads_of(cx, e); + if !fields.is_empty() { + let body = owner_fn(cx, e.hir_id); + for f in &fields { + self.checked.insert((body, *f)); + } + } + fields + } + + /// `l == r` / `l != r`, spelled as an operator or an `assert_eq!`. + fn equated<'tcx>(&mut self, cx: &LateContext<'tcx>, l: &'tcx Expr<'tcx>, r: &'tcx Expr<'tcx>) { + for (a, b) in [(l, r), (r, l)] { + let fields = self.tested(cx, a); + if let Some(s) = sentinel_of(cx, b) { + let ty = cx.typeck_results().expr_ty(peel_blocks_unsafe(b)); + for f in fields { + self.compared(cx, f, &s, ty); + } + } + } + } + + /// `match scrut { CONST => .., 0..=9 => .. }`, `matches!(scrut, -1)`, + /// `if let CONST = scrut`: an arm that names a value tests the scrutinee. + fn matched<'tcx>( + &mut self, + cx: &LateContext<'tcx>, + scrut: &'tcx Expr<'tcx>, + pats: impl Iterator>, + ) { + let mut leaves = Vec::new(); + for pat in pats { + value_pats(pat, &mut leaves); + } + if leaves.is_empty() { + return; + } + let fields = self.tested(cx, scrut); + if fields.is_empty() { + return; + } + for leaf in leaves { + if let PatKind::Expr(pe) = leaf.kind + && let Some(s) = pat_sentinel(cx, pe) + { + let ty = cx.typeck_results().node_type(leaf.hir_id); + for f in &fields { + self.compared(cx, *f, &s, ty); + } + } + } + } + fn read<'tcx>( &mut self, cx: &LateContext<'tcx>, @@ -242,8 +396,12 @@ impl SentinelInt { span: Span, how: Use, ) { - if let Some(field) = self.read_of(cx, operand) { - let body = owner_fn(cx, operand.hir_id); + // A position is an integer by value; `&x.f` is a key being looked up. + if !cx.typeck_results().expr_ty(operand).is_integral() { + return; + } + let body = owner_fn(cx, operand.hir_id); + for field in self.reads_of(cx, operand) { self.reads .entry(field) .or_default() @@ -325,20 +483,46 @@ impl SentinelInt { } } } + + fn assert_eq_args<'tcx>( + cx: &LateContext<'tcx>, + expr: &'tcx Expr<'tcx>, + ) -> Option<(&'tcx Expr<'tcx>, &'tcx Expr<'tcx>)> { + let mac = root_macro_call_first_node(cx, expr)?; + if !matches!( + cx.tcx.get_diagnostic_name(mac.def_id), + Some( + sym::assert_eq_macro + | sym::assert_ne_macro + | sym::debug_assert_eq_macro + | sym::debug_assert_ne_macro + ) + ) { + return None; + } + find_assert_eq_args(cx, expr, mac.expn).map(|(l, r, _)| (l, r)) + } } impl<'tcx> LateLintPass<'tcx> for SentinelInt { fn check_local(&mut self, cx: &LateContext<'tcx>, local: &'tcx LetStmt<'tcx>) { if let Some(init) = local.init && let PatKind::Binding(_, id, _, None) = local.pat.kind - && let Some(field) = self.read_of(cx, init) { - self.locals.insert(id, field); + let fields = self.reads_of(cx, init); + if !fields.is_empty() { + self.locals.insert(id, fields); + } } } fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { self.record_call(cx, expr); + // `assert_eq!(a, b)` compares through match-arm bindings the operator + // arm below cannot see back through; read its arguments directly. + if let Some((l, r)) = Self::assert_eq_args(cx, expr) { + self.equated(cx, l, r); + } match expr.kind { ExprKind::Struct(_, fields, _) => { let Some(adt) = cx.typeck_results().expr_ty(expr).ty_adt_def() else { @@ -359,7 +543,7 @@ impl<'tcx> LateLintPass<'tcx> for SentinelInt { .entry((adt.did(), init.ident.name)) .or_default(); if ev.spelling.is_empty() { - ev.spelling = spelling(cx, &s, init.expr); + ev.spelling = spelling(cx, &s, cx.typeck_results().expr_ty(init.expr)); } } } @@ -371,50 +555,40 @@ impl<'tcx> LateLintPass<'tcx> for SentinelInt { { let ev = self.evidence.entry(field).or_default(); if ev.spelling.is_empty() { - ev.spelling = spelling(cx, &s, val); + ev.spelling = spelling(cx, &s, cx.typeck_results().expr_ty(val)); } } } ExprKind::Binary(op, l, r) => match op.node { - BinOpKind::Eq | BinOpKind::Ne => { - let body = owner_fn(cx, expr.hir_id); - for (a, b) in [(l, r), (r, l)] { - if let Some(field) = self.read_of(cx, a) { - self.checked.insert((body, field)); - if let Some(s) = sentinel_of(cx, b) { - self.compared(cx, field, &s, b); - } - } - } - } + BinOpKind::Eq | BinOpKind::Ne => self.equated(cx, l, r), BinOpKind::Lt | BinOpKind::Le | BinOpKind::Gt | BinOpKind::Ge => { - let body = owner_fn(cx, expr.hir_id); - for a in [l, r] { - if let Some(field) = self.read_of(cx, a) { - self.checked.insert((body, field)); - } - } + self.tested(cx, l); + self.tested(cx, r); } _ => {} }, + ExprKind::Match(scrut, arms, _) => self.matched(cx, scrut, arms.iter().map(|a| a.pat)), + ExprKind::Let(l) => self.matched(cx, l.init, std::iter::once(l.pat)), ExprKind::Index(_, idx, _) => self.indexed(cx, idx, expr.span), ExprKind::MethodCall(seg, recv, args, _) => { let name = seg.ident.as_str(); // The author deciding what an out-of-range value does: - // overflow-aware arithmetic on it, or a clamp of it. - let bounded = ["checked_", "saturating_", "wrapping_", "overflowing_"] + // overflow-aware arithmetic on it, a clamp of it, or a lookup + // that answers for absence. `wrapping_*` decides nothing. + let bounded = ["checked_", "saturating_", "overflowing_"] .iter() .any(|p| name.starts_with(p)) - || matches!(name, "min" | "max" | "clamp"); + || matches!( + name, + "min" | "max" | "clamp" | "get" | "get_mut" | "contains" | "contains_key" + ); if bounded { for operand in std::iter::once(recv).chain(args.iter()) { - if let Some(field) = self.read_of(cx, operand) { - self.checked.insert((owner_fn(cx, expr.hir_id), field)); - } + self.tested(cx, operand); } } if let [arg] = args { - if INDEXERS.contains(&name) { + if INDEXERS.contains(&name) && indexes_positionally(cx, expr, recv) { self.indexed(cx, arg, expr.span); } else if OFFSETS.contains(&name) && cx.typeck_results().expr_ty_adjusted(recv).is_raw_ptr() diff --git a/ui/sentinel_int.rs b/ui/sentinel_int.rs index 4fcc422..d1b3cd9 100644 --- a/ui/sentinel_int.rs +++ b/ui/sentinel_int.rs @@ -1,6 +1,8 @@ // An integer field one function tests against a sentinel and another indexes // with or offsets by unchecked: the sentinel reaches that use. +use std::collections::HashMap; + const INVALID_SLOT: u32 = u32::MAX; struct Entry { @@ -14,6 +16,7 @@ struct Entry { struct Table { names: Vec<&'static str>, entries: Vec, + by_slot: HashMap, } impl Table { @@ -40,6 +43,31 @@ impl Table { self.names[i] } + // Fine: `assert_ne!` is the comparison, spelled as a macro. + fn name_asserted(&self, e: &Entry) -> &'static str { + assert_ne!(e.slot, INVALID_SLOT); + self.names[e.slot as usize] + } + + // Fine: `.get` followed by the early return is the bounds test. + fn name_probed(&self, e: &Entry) -> &'static str { + if self.names.get(e.slot as usize).is_none() { + return ""; + } + self.names[e.slot as usize] + } + + // Fine: a keyed lookup answers for a key that is not there. + fn forget(&mut self, e: &Entry) -> Option { + let _ = self.by_slot[&e.slot]; + self.by_slot.remove(&e.slot) + } + + // Flagged: the sum carries `slot`, whichever side it is on. + fn name_after(&self, e: &Entry) -> &'static str { + self.names[e.width as usize + e.slot as usize] + } + fn detach(&mut self, i: usize) { self.entries[i].parent = u32::MAX; } @@ -54,6 +82,11 @@ impl Table { unsafe { self.entries.as_ptr().add(e.parent as usize) } } + // Flagged: wrapping arithmetic decides nothing about the sentinel. + fn parent_ptr_wrapping(&self, e: &Entry) -> *const Entry { + self.entries.as_ptr().wrapping_add(e.parent as usize) + } + // Fine: calls the helper that tests `parent`. fn parent_of(&self, e: &Entry) -> Option<&Entry> { if self.is_root(e) { @@ -62,6 +95,14 @@ impl Table { Some(&self.entries[e.parent as usize]) } + // Fine: the arm that indexes is the one the sentinel cannot reach. + fn parent_matched(&self, e: &Entry) -> Option<&Entry> { + match e.parent { + u32::MAX => None, + _ => Some(&self.entries[e.parent as usize]), + } + } + // Fine: only ever called from a function that tested `parent` first. fn parent_unchecked(&self, e: &Entry) -> &Entry { &self.entries[e.parent as usize] @@ -92,6 +133,14 @@ impl Table { &self.entries[(e.depth + 1) as usize] } + // Fine: `matches!` against the sentinel is the test. + fn below_matched(&self, e: &Entry) -> Option<&Entry> { + if matches!(e.depth, -1) { + return None; + } + Some(&self.entries[(e.depth + 1) as usize]) + } + // Fine: arithmetic alone meets no memory. fn deeper(&self, e: &Entry) -> i32 { e.depth + 1 @@ -136,6 +185,7 @@ fn main() { width: 1, rank: 0, }], + by_slot: HashMap::new(), }; let e = Entry { slot: 1, @@ -147,13 +197,21 @@ fn main() { let _ = t.name(&e); t.rename(&e, "c"); let _ = t.name_or_empty(&e); + let _ = t.name_asserted(&e); + let _ = t.name_probed(&e); + t.by_slot.insert(1, 0); + let _ = t.forget(&e); + let _ = t.name_after(&t.entries[0]); t.detach(0); let _ = t.parent_ptr(&e); + let _ = t.parent_ptr_wrapping(&e); let _ = t.parent_of(&e).is_some(); + let _ = t.parent_matched(&e).is_some(); let _ = t.grandparent(&e).is_some(); t.forget_depth(0); let _ = t.depth_or_zero(&e); let _ = t.below(&t.entries[0]).slot + t.below_clamped(&e).slot; + let _ = t.below_matched(&e).is_some(); let _ = t.deeper(&e); t.unbound(0); let _ = t.slack(&e, 0); diff --git a/ui/sentinel_int.stderr b/ui/sentinel_int.stderr index c8c3d99..7166c30 100644 --- a/ui/sentinel_int.stderr +++ b/ui/sentinel_int.stderr @@ -1,5 +1,5 @@ -warning: sentinel `INVALID_SLOT` can reach this use: `rename` indexes with `Entry.slot` and nothing in it tests the field, which the crate compares against that sentinel at 1 other site - --> $DIR/sentinel_int.rs:31:9 +warning: sentinel `INVALID_SLOT` can reach this use: `rename` indexes with `Entry.slot` and nothing in it tests the field, which the crate compares against that sentinel at 2 other sites + --> $DIR/sentinel_int.rs:34:9 | LL | self.names[e.slot as usize] = to; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -7,21 +7,37 @@ LL | self.names[e.slot as usize] = to; = help: the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case = note: `#[warn(sentinel_int)]` on by default -warning: sentinel `u32::MAX` can reach this use: `parent_ptr` offsets a pointer by `Entry.parent` and nothing in it tests the field, which the crate compares against that sentinel at 2 other sites - --> $DIR/sentinel_int.rs:54:18 +warning: sentinel `INVALID_SLOT` can reach this use: `name_after` indexes with `Entry.slot` and nothing in it tests the field, which the crate compares against that sentinel at 2 other sites + --> $DIR/sentinel_int.rs:68:9 + | +LL | self.names[e.width as usize + e.slot as usize] + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case + +warning: sentinel `u32::MAX` can reach this use: `parent_ptr` offsets a pointer by `Entry.parent` and nothing in it tests the field, which the crate compares against that sentinel at 3 other sites + --> $DIR/sentinel_int.rs:82:18 | LL | unsafe { self.entries.as_ptr().add(e.parent as usize) } | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = help: the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case -warning: sentinel `-1` can reach this use: `below` indexes with `Entry.depth` and nothing in it tests the field, which the crate compares against that sentinel at 1 other site - --> $DIR/sentinel_int.rs:92:10 +warning: sentinel `u32::MAX` can reach this use: `parent_ptr_wrapping` offsets a pointer by `Entry.parent` and nothing in it tests the field, which the crate compares against that sentinel at 3 other sites + --> $DIR/sentinel_int.rs:87:9 + | +LL | self.entries.as_ptr().wrapping_add(e.parent as usize) + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | + = help: the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case + +warning: sentinel `-1` can reach this use: `below` indexes with `Entry.depth` and nothing in it tests the field, which the crate compares against that sentinel at 2 other sites + --> $DIR/sentinel_int.rs:133:10 | LL | &self.entries[(e.depth + 1) as usize] | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = help: the field's value set is `Option`; store that (a `NonZero`/`NonMax` niche keeps the size) and no reader can index without deciding the empty case -warning: 3 warnings emitted +warning: 5 warnings emitted From a07881a39d751e1354011f683583623141dacebb Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:51:38 -0700 Subject: [PATCH 26/34] bool_beside_option sees ref mut bindings, bare arms, exits and derives --- src/bool_beside_option.rs | 217 +++++++++++++++++++++++----- ui/bool_beside_option.rs | 270 +++++++++++++++++++++++++++++++++++ ui/bool_beside_option.stderr | 10 +- 3 files changed, 463 insertions(+), 34 deletions(-) diff --git a/src/bool_beside_option.rs b/src/bool_beside_option.rs index 525eb89..170dd69 100644 --- a/src/bool_beside_option.rs +++ b/src/bool_beside_option.rs @@ -1,32 +1,46 @@ use std::collections::{HashMap, HashSet}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::ops::ControlFlow; use crate::adt_facts::{field_ty, has_fixed_repr, is_option_ty, struct_field}; use crate::baseline::emit; -use crate::hir_shapes::{assigned_field, peel_blocks_unsafe}; -use clippy_utils::{as_some_expr, get_enclosing_block, get_parent_expr, is_none_expr}; +use crate::hir_shapes::{assigned_field, field_chain, peel_blocks_unsafe}; +use clippy_utils::visitors::for_each_expr_without_closures; +use clippy_utils::{as_some_expr, get_parent_expr, hash_expr, is_default_equivalent, is_none_expr}; use rustc_ast::LitKind; +use rustc_hir::def::Res; use rustc_hir::def_id::DefId; -use rustc_hir::{BorrowKind, Expr, ExprKind, HirId, Mutability}; +use rustc_hir::{ + BindingMode, BorrowKind, ByRef, Expr, ExprKind, HirId, Mutability, Node, Pat, PatKind, QPath, + Stmt, +}; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::adjustment::{Adjust, AutoBorrow, AutoBorrowMutability}; use rustc_middle::ty::{self, AdtDef}; -use rustc_span::{Span, Symbol}; +use rustc_span::hygiene::{ExpnKind, MacroKind}; +use rustc_span::{Span, Symbol, sym}; rustc_session::declare_lint! { /// Flags a bool field that stores whether a sibling `Option` field is /// `Some`: every write to either of them, struct literal or field /// assignment, sits beside a write to the other in the same struct - /// expression or block, `true` always with `Some(..)` and `false` always - /// with `None` (or always the reverse). The flag is the `Option`'s - /// discriminant kept a second time, and nothing but that habit keeps the - /// two agreeing. + /// expression or straight-line block, `true` always with `Some(..)` and + /// `false` always with `None` (or always the reverse). The flag is the + /// `Option`'s discriminant kept a second time, and nothing but that habit + /// keeps the two agreeing. /// - /// Only fires on fields nothing outside the crate can name, when every - /// write to the pair is a literal `true`/`false` and `Some(..)`/`None` and - /// both polarities occur. A lone write to either field, a computed value, - /// a compound assignment, or a `&mut` borrow of either (`.take()`, - /// `mem::replace`, `&mut s.opt`) is unprovable and silences it; `as_mut` - /// and `as_deref_mut` cannot change the discriminant and do not. + /// Only fires on named fields nothing outside the crate can name, when + /// every write to the pair is a literal `true`/`false` and + /// `Some(..)`/`None` (or `Default::default()`, which is `false`/`None`) + /// and both polarities occur. A lone write to either field, one in a + /// match arm or closure without a block of its own, a block that writes + /// both polarities, a computed value, a compound assignment, or a `&mut` + /// to either field -- `.take()`, `mem::replace`, `&mut s.opt`, a + /// `ref mut` pattern binding, spelled out or through a `&mut` scrutinee + /// -- is unprovable and silences it; `as_mut` and `as_deref_mut` cannot + /// change the discriminant and do not. Tuple structs are built through + /// their constructor fn and are not followed; a derived `Clone` copies + /// the pair as it stands and is not a write of its own. pub BOOL_BESIDE_OPTION, Warn, "bool field that repeats whether a sibling Option field is Some" @@ -38,14 +52,27 @@ enum Kind { Opt, } +/// Where a write happens: the struct expression, or for a field assignment +/// the innermost block around it, the stretch of that block no early exit +/// splits, and the place it assigns through, so `a.opt = ..` and +/// `b.flag = ..` in one block, or two writes a `?` can part, are not beside +/// each other. +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +struct Site { + region: HirId, + stretch: usize, + place: u64, +} + struct FieldWrites { kind: Kind, - /// Some write to the field is not a literal of its kind, or the field is - /// mutably borrowed: its value is no longer a function of the sites. + /// Some write to the field is not a literal of its kind, one site writes + /// both polarities, or the field is mutably borrowed: its value is no + /// longer a function of the sites. unprovable: bool, - /// (site, set): the struct expression or enclosing block that writes the - /// field, and whether it writes `true` / `Some(..)` there. - sites: HashSet<(HirId, bool)>, + /// (site, set): where the field is written, and whether it is written + /// `true` / `Some(..)` there. + sites: HashSet<(Site, bool)>, } #[derive(Default)] @@ -55,8 +82,9 @@ pub struct BoolBesideOption { rustc_session::impl_lint_pass!(BoolBesideOption => [BOOL_BESIDE_OPTION]); -/// A bool or `Option` field of a local struct that nothing outside the crate -/// can write: the struct with the field's kind, or None for anything else. +/// A bool or `Option` named field of a local struct that nothing outside the +/// crate can write: the struct with the field's kind, or None for anything +/// else. fn tracked_field<'tcx>( cx: &LateContext<'tcx>, ty: ty::Ty<'tcx>, @@ -65,7 +93,11 @@ fn tracked_field<'tcx>( let ty::Adt(adt, _) = ty.peel_refs().kind() else { return None; }; - if !adt.is_struct() || !adt.did().is_local() || has_fixed_repr(*adt) { + if !adt.is_struct() + || !adt.did().is_local() + || has_fixed_repr(*adt) + || adt.non_enum_variant().ctor.is_some() + { return None; } let f = struct_field(*adt, name)?; @@ -84,10 +116,11 @@ fn tracked_field<'tcx>( } /// Whether `value` writes `true`/`Some(..)` (Some(true)) or `false`/`None` -/// (Some(false)); None for anything computed. +/// (Some(false)); None for anything computed. `Default::default()` of either +/// kind is `false`/`None`, which is what a derived `Default` writes. fn polarity(cx: &LateContext<'_>, kind: Kind, value: &Expr<'_>) -> Option { let value = peel_blocks_unsafe(value); - match kind { + let set = match kind { Kind::Bool => match value.kind { ExprKind::Lit(lit) => match lit.node { LitKind::Bool(b) => Some(b), @@ -102,7 +135,75 @@ fn polarity(cx: &LateContext<'_>, kind: Kind, value: &Expr<'_>) -> Option is_none_expr(cx, value).then_some(false) } } + }; + set.or_else(|| is_default_equivalent(cx, value).then_some(false)) +} + +/// The straight-line region a field assignment runs in: the innermost block +/// around it, and which stretch of that block, counting the statements +/// before the assignment that can leave early (`return`, `?`, `break`, +/// `continue`), since a write after one of those need not follow a write +/// before it. None when a match arm, a closure or the body itself holds the +/// assignment bare, where nothing else runs beside it. +fn assignment_region<'tcx>(cx: &LateContext<'tcx>, id: HirId) -> Option<(HirId, usize)> { + let mut from = id; + for (parent_id, node) in cx.tcx.hir_parent_iter(id) { + match node { + Node::Block(block) => { + let leaves_early = |s: &'tcx Stmt<'tcx>| { + // Loops opened inside the statement: a `break` or + // `continue` aimed at one of them stays inside it. + let mut own_loops = HashSet::new(); + for_each_expr_without_closures(s, |e: &'tcx Expr<'tcx>| { + match e.kind { + ExprKind::Ret(_) => return ControlFlow::Break(()), + ExprKind::Loop(..) => { + own_loops.insert(e.hir_id); + } + ExprKind::Break(dest, _) | ExprKind::Continue(dest) + if !dest.target_id.is_ok_and(|l| own_loops.contains(&l)) => + { + return ControlFlow::Break(()); + } + _ => {} + } + ControlFlow::Continue(()) + }) + .is_some() + }; + let stretch = block + .stmts + .iter() + .take_while(|s| s.hir_id != from) + .filter(|&s| leaves_early(s)) + .count(); + return Some((parent_id, stretch)); + } + Node::Expr(e) if matches!(e.kind, ExprKind::Closure(_)) => return None, + Node::Expr(_) | Node::ExprField(_) | Node::Stmt(_) | Node::LetStmt(_) => { + from = parent_id; + } + _ => return None, + } } + None +} + +/// The place an assignment writes through, `base` of `base.field = ..`: the +/// local at its root and the fields walked from it. `SpanlessHash` hashes +/// every local alike, which would put `a.f` beside `b.g`. +fn place_key(cx: &LateContext<'_>, base: &Expr<'_>) -> u64 { + let chain = field_chain(base); + let mut h = DefaultHasher::new(); + if let ExprKind::Path(QPath::Resolved(None, path)) = chain.root.kind + && let Res::Local(local) = path.res + { + local.hash(&mut h); + } else { + hash_expr(cx, chain.root).hash(&mut h); + } + chain.fields.hash(&mut h); + h.finish() } impl BoolBesideOption { @@ -121,7 +222,7 @@ impl BoolBesideOption { cx: &LateContext<'tcx>, base_ty: ty::Ty<'tcx>, name: Symbol, - site: HirId, + site: Site, value: &Expr<'_>, ) { let Some((adt, kind)) = tracked_field(cx, base_ty, name) else { @@ -129,6 +230,9 @@ impl BoolBesideOption { }; let facts = self.facts(adt, name, kind); match polarity(cx, kind, value) { + // A region that writes both polarities no longer says which of + // its sibling writes each one sits beside. + Some(set) if facts.sites.contains(&(site, !set)) => facts.unprovable = true, Some(set) => { facts.sites.insert((site, set)); } @@ -151,15 +255,24 @@ impl<'tcx> LateLintPass<'tcx> for BoolBesideOption { fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { match expr.kind { ExprKind::Struct(_, fields, _) => { - // A derived `Clone`/`Default` writes every field from the same - // field or the same default: it preserves whatever the - // hand-written sites establish and proves nothing itself. - if expr.span.in_derive_expansion() { + // A derived `Clone` writes every field from the same field of + // the value it copies: it keeps whatever the other sites + // establish and proves nothing itself. Every other derive + // builds a value of its own and counts like a hand-written one. + if let ExpnKind::Macro(MacroKind::Derive, name) = + expr.span.ctxt().outer_expn_data().kind + && name == sym::Clone + { return; } let ty = cx.typeck_results().expr_ty(expr); + let site = Site { + region: expr.hir_id, + stretch: 0, + place: 0, + }; for field in fields { - self.record(cx, ty, field.ident.name, expr.hir_id, field.expr); + self.record(cx, ty, field.ident.name, site, field.expr); } } ExprKind::Assign(place, value, _) => { @@ -167,8 +280,18 @@ impl<'tcx> LateLintPass<'tcx> for BoolBesideOption { return; }; let base_ty = cx.typeck_results().expr_ty_adjusted(base); - match get_enclosing_block(cx, expr.hir_id) { - Some(block) => self.record(cx, base_ty, ident.name, block.hir_id, value), + if tracked_field(cx, base_ty, ident.name).is_none() { + return; + } + match assignment_region(cx, expr.hir_id) { + Some((region, stretch)) => { + let site = Site { + region, + stretch, + place: place_key(cx, base), + }; + self.record(cx, base_ty, ident.name, site, value); + } None => self.poison(cx, base_ty, ident.name), } } @@ -207,6 +330,34 @@ impl<'tcx> LateLintPass<'tcx> for BoolBesideOption { } } + // `let S { opt, .. } = self` on `&mut self`, or `S { ref mut opt, .. }`: + // the binding is a `&mut` to the field, through which it is rewritten + // alone. + fn check_pat(&mut self, cx: &LateContext<'tcx>, pat: &'tcx Pat<'tcx>) { + let PatKind::Struct(_, fields, _) = pat.kind else { + return; + }; + let Some(typeck) = cx.maybe_typeck_results() else { + return; + }; + let ty = typeck.pat_ty(pat); + for field in fields { + let mut by_mut_ref = false; + field.pat.walk(|p| { + if let PatKind::Binding(..) = p.kind + && let Some(BindingMode(ByRef::Yes(_, Mutability::Mut), _)) = + typeck.pat_binding_modes().get(p.hir_id) + { + by_mut_ref = true; + } + !by_mut_ref + }); + if by_mut_ref { + self.poison(cx, ty, field.ident.name); + } + } + } + fn check_crate_post(&mut self, cx: &LateContext<'tcx>) { let mut per_struct: HashMap> = HashMap::new(); for ((did, name), facts) in self.writes.drain() { @@ -227,7 +378,7 @@ impl<'tcx> LateLintPass<'tcx> for BoolBesideOption { continue; } for (opt, opt_facts) in fields.iter().filter(|(_, f)| f.kind == Kind::Opt) { - let inverted: HashSet<(HirId, bool)> = + let inverted: HashSet<(Site, bool)> = opt_facts.sites.iter().map(|&(s, set)| (s, !set)).collect(); let reading = if flag_facts.sites == opt_facts.sites { "is_some" diff --git a/ui/bool_beside_option.rs b/ui/bool_beside_option.rs index d0208c2..b407e7e 100644 --- a/ui/bool_beside_option.rs +++ b/ui/bool_beside_option.rs @@ -64,6 +64,54 @@ impl Cache { } } +// Flagged: a derived `Default` builds `None`/`false`, one more site that +// agrees with `is_some()`; a derived `Clone` copies the pair as it is; a loop +// that only breaks out of itself does not part the two writes around it. +#[derive(Clone, Default)] +struct Link { + up: Option, + linked: bool, +} + +impl Link { + fn connect(&mut self, v: u32) { + self.up = Some(v); + for _ in 0..v { + if self.up.is_some() { + break; + } + } + self.linked = true; + } + + fn drop_link(&mut self) { + self.up = None; + self.linked = false; + } +} + +// Fine: the derived `Default` builds `fresh: None, stale: false`, which is +// not `fresh.is_none()`. +#[derive(Default)] +struct Memo { + fresh: Option, + stale: bool, +} + +impl Memo { + fn filled(v: u32) -> Self { + Memo { + fresh: Some(v), + stale: false, + } + } + + fn invalidate(&mut self) { + self.fresh = None; + self.stale = true; + } +} + // Fine: `done` is also set on its own, so it is not `error.is_some()`. struct Scan { error: Option, @@ -112,6 +160,184 @@ impl Exit { } } +// Fine: the Option is drained alone through a `&mut` binding split off `self`. +struct Split { + slot: Option, + held: bool, +} + +impl Split { + fn new() -> Self { + Split { + slot: None, + held: false, + } + } + + fn hold(&mut self, v: i32) { + self.slot = Some(v); + self.held = true; + } + + fn drain(&mut self) -> Option { + let Split { slot, .. } = self; + slot.take() + } +} + +// Fine: the same, the binding spelled `ref mut`. +struct Drain { + item: Option, + full: bool, +} + +impl Drain { + fn new() -> Self { + Drain { + item: None, + full: false, + } + } + + fn fill(&mut self, v: i32) { + self.item = Some(v); + self.full = true; + } + + fn empty(&mut self) { + let Drain { ref mut item, .. } = *self; + *item = None; + } +} + +// Fine: positional fields are built through the constructor fn, whose calls +// are not followed. +struct Pair(Option, bool); + +impl Pair { + fn new() -> Self { + Pair(Some(1), false) + } + + fn set(&mut self, v: u32) { + self.0 = Some(v); + self.1 = true; + } + + fn clear(&mut self) { + self.0 = None; + self.1 = false; + } +} + +// Fine: the match arms without braces are exclusive, not beside each other. +enum Ev { + Up(u32), + Flag, + Down, +} + +struct Peer { + addr: Option, + known: bool, +} + +impl Peer { + fn new() -> Self { + Peer { + addr: None, + known: false, + } + } + + fn on(&mut self, e: Ev) { + match e { + Ev::Up(a) => self.addr = Some(a), + Ev::Flag => self.known = true, + Ev::Down => { + self.addr = None; + self.known = false; + } + } + } +} + +// Fine: one block writes both polarities of each, `false` beside `None` and +// `true` beside `Some`, while construction is `Some` beside `false`. +struct Swap { + cur: Option, + empty: bool, +} + +impl Swap { + fn new(v: u32) -> Self { + Swap { + cur: Some(v), + empty: false, + } + } + + fn cycle(&mut self, v: u32) { + self.cur = None; + self.empty = false; + let _ = v.leading_zeros(); + self.cur = Some(v); + self.empty = true; + } +} + +// Fine: the two writes in one block go to different instances. +struct Slot { + task: Option, + taken: bool, +} + +impl Slot { + fn new() -> Self { + Slot { + task: None, + taken: false, + } + } + + fn park(&mut self) { + self.task = None; + self.taken = false; + } +} + +fn hand_off(from: &mut Slot, to: &mut Slot, v: u32) { + to.task = Some(v); + from.taken = true; +} + +// Fine: the `?` between the two writes can leave with only the flag set. +struct Fetch { + body: Option, + started: bool, +} + +impl Fetch { + fn new() -> Self { + Fetch { + body: None, + started: false, + } + } + + fn run(&mut self, v: u32) -> Option<()> { + self.started = true; + let got = v.checked_sub(1)?; + self.body = Some(got); + Some(()) + } + + fn reset(&mut self) { + self.body = None; + self.started = false; + } +} + // Fine: the flag is written from a computed value once. struct Probe { addr: Option, @@ -206,6 +432,16 @@ fn main() { k.invalidate(); let _ = k.stale; + let mut n = Link::default().clone(); + n.connect(1); + n.drop_link(); + let _ = (n.linked, n.up); + + let mut m = Memo::default(); + let _ = Memo::filled(1); + m.invalidate(); + let _ = (m.stale, m.fresh); + let mut s = Scan::new(); s.fail("e".to_owned()); s.finish(); @@ -215,6 +451,40 @@ fn main() { e.on_exit(0); let _ = (e.exited, e.reap()); + let mut t = Split::new(); + t.hold(1); + let _ = (t.held, t.drain()); + + let mut d = Drain::new(); + d.fill(1); + d.empty(); + let _ = (d.full, d.item); + + let mut two = Pair::new(); + two.set(2); + two.clear(); + let _ = (two.1, two.0); + + let mut r = Peer::new(); + r.on(Ev::Up(1)); + r.on(Ev::Flag); + r.on(Ev::Down); + let _ = (r.known, r.addr); + + let mut w = Swap::new(1); + w.cycle(2); + let _ = (w.empty, w.cur); + + let (mut a, mut b) = (Slot::new(), Slot::new()); + hand_off(&mut a, &mut b, 1); + a.park(); + let _ = (a.taken, b.task); + + let mut f = Fetch::new(); + let _ = f.run(1); + f.reset(); + let _ = (f.started, f.body); + let mut p = Probe::new(); p.resolve(1, true); let _ = (p.reachable, p.addr); diff --git a/ui/bool_beside_option.stderr b/ui/bool_beside_option.stderr index 3b7e577..73fe184 100644 --- a/ui/bool_beside_option.stderr +++ b/ui/bool_beside_option.stderr @@ -15,5 +15,13 @@ LL | stale: bool, | = help: the `Option` already carries this state; drop the flag and ask the `Option`, so the two cannot disagree -warning: 2 warnings emitted +warning: `linked` is only ever written beside `up` of `Link`, `true` with `Some(..)` and `false` with `None` (3 sites): it stores `up.is_some()` a second time + --> $DIR/bool_beside_option.rs:73:5 + | +LL | linked: bool, + | ^^^^^^^^^^^^ + | + = help: the `Option` already carries this state; drop the flag and ask the `Option`, so the two cannot disagree + +warning: 3 warnings emitted From 35cb90218b14edfd07f017d59ebd8d7cf6dfabb3 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:53:13 -0700 Subject: [PATCH 27/34] unnamed_tuple reads destructuring assignments and skips closure returns --- src/unnamed_tuple.rs | 122 ++++++++++++++++++++++++++++++++-------- ui/unnamed_tuple.rs | 62 +++++++++++++++++++- ui/unnamed_tuple.stderr | 22 +++++++- 3 files changed, 177 insertions(+), 29 deletions(-) diff --git a/src/unnamed_tuple.rs b/src/unnamed_tuple.rs index caef941..78a53b1 100644 --- a/src/unnamed_tuple.rs +++ b/src/unnamed_tuple.rs @@ -1,17 +1,18 @@ use std::collections::{HashMap, HashSet}; use std::ops::ControlFlow; -use clippy_utils::visitors::for_each_expr; +use clippy_utils::visitors::for_each_expr_without_closures; use rustc_hir::def::{DefKind, Res}; use rustc_hir::def_id::DefId; use rustc_hir::intravisit::FnKind; use rustc_hir::{ - Body, Expr, ExprKind, FnDecl, LangItem, MatchSource, Node, Pat, PatKind, QPath, StmtKind, + Body, Expr, ExprKind, FnDecl, HirId, LangItem, LetStmt, LocalSource, MatchSource, Node, Pat, + PatKind, QPath, StmtKind, }; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{self, AssocContainer, Ty, TyCtxt}; use rustc_span::def_id::LocalDefId; -use rustc_span::{Span, Symbol, sym}; +use rustc_span::{Ident, Span, Symbol, sym}; use crate::baseline::{emit, emit_with_note}; use crate::hir_shapes::{callee_of, peel_blocks_unsafe}; @@ -19,8 +20,9 @@ use crate::hir_shapes::{callee_of, peel_blocks_unsafe}; rustc_session::declare_lint! { /// Flags a crate-private function returning a tuple (bare, or inside an /// `Option`/`Result`) with two members of one type, when every call site - /// in the crate destructures it on the spot and all of them, across at - /// least two functions, bind the members to the same names. Those names + /// in the crate destructures it on the spot (`let (a, b) = f()`, an + /// `if let`/`match` arm, or `(a, self.b) = f()`) and all of them, across + /// at least two functions, give the members the same names. Those names /// are the members' real names: written at every use, missing only from /// the type, which is the one place the compiler could keep them attached. /// As a tuple, `(r, g, b)` and `(r, b, g)` are the same type, so a @@ -135,8 +137,9 @@ fn is_lang_ctor(cx: &LateContext<'_>, variant: Option, items: &[LangItem] } /// The expressions a body evaluates to: every `return e` and every tail, -/// through blocks, `if` and `match`. -fn return_values<'tcx>(cx: &LateContext<'tcx>, body: &'tcx Body<'tcx>) -> Vec<&'tcx Expr<'tcx>> { +/// through blocks, `if` and `match`. A closure or `async` block's `return` +/// is that closure's value, not the body's. +fn return_values<'tcx>(body: &'tcx Body<'tcx>) -> Vec<&'tcx Expr<'tcx>> { fn tails<'h>(e: &'h Expr<'h>, out: &mut Vec<&'h Expr<'h>>) { match e.kind { ExprKind::Block(b, _) => { @@ -161,7 +164,7 @@ fn return_values<'tcx>(cx: &LateContext<'tcx>, body: &'tcx Body<'tcx>) -> Vec<&' } } let mut out = Vec::new(); - for_each_expr(cx, body.value, |e| { + for_each_expr_without_closures(body.value, |e| { if let ExprKind::Ret(Some(v)) = e.kind { out.push(v); } @@ -191,13 +194,7 @@ fn returned_names(cx: &LateContext<'_>, e: &Expr<'_>, wrapped: bool) -> Option { - Some(p.segments[0].ident.name) - } - ExprKind::Field(_, ident) => Some(ident.name), - _ => None, - }) + .map(|el| place_name(peel_blocks_unsafe(el))) .collect::>>()?; Some(Returned { names, @@ -205,30 +202,93 @@ fn returned_names(cx: &LateContext<'_>, e: &Expr<'_>, wrapped: bool) -> Option, pat: &Pat<'_>, wrapped: bool) -> Use { +/// A bare local `a` or a field `self.b`, as that name. +fn place_name(e: &Expr<'_>) -> Option { + match e.kind { + ExprKind::Path(QPath::Resolved(None, p)) if matches!(p.res, Res::Local(_)) => { + Some(p.segments[0].ident.name) + } + ExprKind::Field(_, ident) => Some(ident.name), + _ => None, + } +} + +/// Where a pattern's bindings get their names. rustc lowers +/// `(a, self.b) = f()` to `let (lhs, lhs) = f(); a = lhs; self.b = lhs;`: +/// there each binding is called `lhs` and its name is what it is assigned +/// on to. +#[derive(Clone, Copy)] +enum Naming<'a> { + Bound, + Assigned(&'a HashMap), +} + +impl Naming<'_> { + fn name(self, binding: HirId, ident: Ident) -> Option { + match self { + Naming::Bound => Some(ident.name), + Naming::Assigned(targets) => targets.get(&binding).copied(), + } + } +} + +/// The left-hand sides of a lowered `(a, self.b) = f()`, keyed by the `lhs` +/// binding each is assigned from; `None` when one of them is more than a +/// local or a field. +fn assign_targets(cx: &LateContext<'_>, desugared: &LetStmt<'_>) -> Option> { + let block = cx + .tcx + .hir_parent_iter(desugared.hir_id) + .find_map(|(_, node)| match node { + Node::Block(b) => Some(b), + _ => None, + })?; + let mut targets = HashMap::new(); + for stmt in block.stmts { + let (StmtKind::Expr(e) | StmtKind::Semi(e)) = stmt.kind else { + continue; + }; + let ExprKind::Assign(target, value, _) = e.kind else { + continue; + }; + let ExprKind::Path(QPath::Resolved(None, p)) = value.kind else { + continue; + }; + let Res::Local(binding) = p.res else { + continue; + }; + targets.insert(binding, place_name(target)?); + } + Some(targets) +} + +fn pat_use(cx: &LateContext<'_>, pat: &Pat<'_>, wrapped: bool, naming: Naming<'_>) -> Use { match pat.kind { PatKind::Wild => Use::Neutral, - PatKind::Ref(inner, _, _) => pat_use(cx, inner, wrapped), + PatKind::Ref(inner, _, _) => pat_use(cx, inner, wrapped, naming), PatKind::Tuple(pats, dotdot) if !wrapped => { if dotdot.as_opt_usize().is_some() { return Use::Opaque; } let mut names = Vec::with_capacity(pats.len()); for p in pats { - let PatKind::Binding(_, _, ident, None) = p.kind else { + let PatKind::Binding(_, id, ident, None) = p.kind else { + return Use::Opaque; + }; + let Some(name) = naming.name(id, ident) else { return Use::Opaque; }; - if ident.as_str().starts_with('_') { + if name.as_str().starts_with('_') { return Use::Opaque; } - names.push(ident.name); + names.push(name); } Use::Names(names) } PatKind::TupleStruct(_, [inner], _) if wrapped => { let variant = crate::enum_facts::arm_variant(cx, pat); if is_lang_ctor(cx, variant, &[LangItem::OptionSome, LangItem::ResultOk]) { - pat_use(cx, inner, false) + pat_use(cx, inner, false, naming) } else if is_lang_ctor(cx, variant, &[LangItem::ResultErr]) { Use::Neutral } else { @@ -272,7 +332,14 @@ fn use_of_call<'tcx>(cx: &LateContext<'tcx>, call: &'tcx Expr<'tcx>, mut wrapped for (parent_id, node) in cx.tcx.hir_parent_iter(call.hir_id) { match node { Node::LetStmt(l) if l.init.is_some_and(|i| i.hir_id == current) => { - return pat_use(cx, l.pat, wrapped); + return match l.source { + LocalSource::Normal => pat_use(cx, l.pat, wrapped, Naming::Bound), + LocalSource::AssignDesugar => match assign_targets(cx, l) { + Some(targets) => pat_use(cx, l.pat, wrapped, Naming::Assigned(&targets)), + None => Use::Opaque, + }, + _ => Use::Opaque, + }; } // `f();`: the value is dropped unread. Node::Stmt(s) if matches!(s.kind, StmtKind::Semi(e) if e.hir_id == current) => { @@ -305,9 +372,14 @@ fn use_of_call<'tcx>(cx: &LateContext<'tcx>, call: &'tcx Expr<'tcx>, mut wrapped ExprKind::Match(scrut, arms, MatchSource::Normal | MatchSource::Postfix) if scrut.hir_id == current => { - return merge(arms.iter().map(|arm| pat_use(cx, arm.pat, wrapped))); + return merge( + arms.iter() + .map(|arm| pat_use(cx, arm.pat, wrapped, Naming::Bound)), + ); + } + ExprKind::Let(l) if l.init.hir_id == current => { + return pat_use(cx, l.pat, wrapped, Naming::Bound); } - ExprKind::Let(l) if l.init.hir_id == current => return pat_use(cx, l.pat, wrapped), _ => return Use::Opaque, }, _ => return Use::Opaque, @@ -373,7 +445,7 @@ impl<'tcx> LateLintPass<'tcx> for UnnamedTuple { let Some(same) = same_typed_pair(ret.members) else { return; }; - let returns = return_values(cx, body) + let returns = return_values(body) .into_iter() .filter_map(|e| returned_names(cx, e, ret.wrapped)) .collect(); diff --git a/ui/unnamed_tuple.rs b/ui/unnamed_tuple.rs index 51539db..b1e780f 100644 --- a/ui/unnamed_tuple.rs +++ b/ui/unnamed_tuple.rs @@ -45,6 +45,44 @@ fn clamped_ratio(n: &Node) -> u32 { depth / width.max(1) } +// Flagged, without the evidence: the only `(width, depth)` is a closure's +// return, which is the closure's value and not this function's. +fn folded(n: &Node) -> (u32, u32) { + let clamp = |width: u32, depth: u32| { + if width > depth { + return (width, depth); + } + (depth, depth) + }; + let depth = clamp(n.width, n.depth).1; + (depth, n.width) +} + +fn folded_area(n: &Node) -> u32 { + let (depth, width) = folded(n); + depth * width +} + +fn folded_ratio(n: &Node) -> u32 { + let (depth, width) = folded(n); + depth / width.max(1) +} + +// Flagged: a destructuring assignment names the members by what it assigns +// them to, `(depth, width)` like the `let` in the other caller. +fn grown(n: &Node) -> (u32, u32) { + (n.depth + 2, n.width + 2) +} + +fn grow(n: &mut Node) { + (n.depth, n.width) = grown(n); +} + +fn grown_area(n: &Node) -> u32 { + let (depth, width) = grown(n); + depth * width +} + // Flagged: the `Option` around the tuple is read through `?`, `if let`, // `match` and `.unwrap()`, and every pattern that reaches the tuple names it // `(key, value)`; `None` arms and `is_none()` never reach it. @@ -133,6 +171,25 @@ fn range_start(n: u32) -> u32 { start.max(end) } +// Fine: the callers disagree through destructuring assignments, `(near, far)` +// here and `(low, high)` there; the `lhs` rustc binds them to on the way is +// nobody's name for them. +fn assigned(n: u32) -> (u32, u32) { + (n, n + 2) +} + +fn assigned_near(n: u32) -> u32 { + let (near, far); + (near, far) = assigned(n); + far - near +} + +fn assigned_low(n: u32) -> u32 { + let (low, high); + (low, high) = assigned(n); + low * high +} + // Fine: one caller keeps the tuple whole (`.0`), so the type is used as one. fn halves(n: u32) -> (u32, u32) { (n / 2, n - n / 2) @@ -234,12 +291,15 @@ fn extent_b(n: usize) -> usize { } fn main() { - let n = Node { depth: 1, width: 2 }; + let mut n = Node { depth: 1, width: 2 }; + grow(&mut n); let t = Table { rows: vec![(1, 2)] }; let _ = (taller(&n, &n), wider(&n), clamped_area(&n), clamped_ratio(&n)); + let _ = (folded_area(&n), folded_ratio(&n), grown_area(&n)); let _ = (key_len("a=b"), has_value("a=b"), value_of("a=b")); let _ = (t.span(), t.low()); let _ = range_lo(1) + range_start(2) + halves_sum(3) + halves_left(4) + indexed_sum(5) as u32; + let _ = assigned_near(3) + assigned_low(4); let _ = indexed_gap(9); let _ = corners_twice(5) + parts_head(6) + parts_both(7) + pair_a(8) + pair_b(9); let _ = line_of(&t) + column_of(&t) + extent_a(1) + extent_b(2); diff --git a/ui/unnamed_tuple.stderr b/ui/unnamed_tuple.stderr index ac4830b..3f7ba36 100644 --- a/ui/unnamed_tuple.stderr +++ b/ui/unnamed_tuple.stderr @@ -20,8 +20,24 @@ LL | return (width, depth); | ^^^^^^^^^^^^^^ = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places +warning: all 2 call sites of `folded`, in 2 functions, destructure this as `(depth, width)`: the members are named everywhere except in the type; `.0` and `.1` are both `u32`, so transposing them still type-checks + --> $DIR/unnamed_tuple.rs:50:24 + | +LL | fn folded(n: &Node) -> (u32, u32) { + | ^^^^^^^^^^ + | + = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places + +warning: all 2 call sites of `grown`, in 2 functions, destructure this as `(depth, width)`: the members are named everywhere except in the type; `.0` and `.1` are both `u32`, so transposing them still type-checks + --> $DIR/unnamed_tuple.rs:73:23 + | +LL | fn grown(n: &Node) -> (u32, u32) { + | ^^^^^^^^^^ + | + = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places + warning: all 4 call sites of `split`, in 3 functions, destructure this as `(key, value)`: the members are named everywhere except in the type; `.0` and `.1` are both `&str`, so transposing them still type-checks - --> $DIR/unnamed_tuple.rs:51:22 + --> $DIR/unnamed_tuple.rs:89:22 | LL | fn split(s: &str) -> Option<(&str, &str)> { | ^^^^^^^^^^^^^^^^^^^^ @@ -29,12 +45,12 @@ LL | fn split(s: &str) -> Option<(&str, &str)> { = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places warning: all 2 call sites of `bounds`, in 2 functions, destructure this as `(low, high)`: the members are named everywhere except in the type; `.0` and `.1` are both `u8`, so transposing them still type-checks - --> $DIR/unnamed_tuple.rs:87:25 + --> $DIR/unnamed_tuple.rs:125:25 | LL | fn bounds(&self) -> Result<(u8, u8), ()> { | ^^^^^^^^^^^^^^^^^^^^ | = help: return a struct with these fields: the names move into the signature, and members of one type can no longer trade places -warning: 4 warnings emitted +warning: 6 warnings emitted From 44d1581f18587f8d0177f9cda98b02aafbdb0151 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:53:45 -0700 Subject: [PATCH 28/34] collapsed_error skips kind-naming arms, sees loop bodies and &str errors --- src/collapsed_error.rs | 60 ++++++++++++++++++++++--------- ui/collapsed_error.rs | 74 +++++++++++++++++++++++++++++++++++++++ ui/collapsed_error.stderr | 28 ++++++++++++++- 3 files changed, 144 insertions(+), 18 deletions(-) diff --git a/src/collapsed_error.rs b/src/collapsed_error.rs index 704c4f2..89cccb1 100644 --- a/src/collapsed_error.rs +++ b/src/collapsed_error.rs @@ -2,13 +2,15 @@ use std::collections::{HashMap, HashSet}; use std::ops::ControlFlow; use clippy_utils::visitors::for_each_expr_without_closures; -use clippy_utils::{get_expr_use_or_unification_node, is_def_id_trait_method, is_in_test}; +use clippy_utils::{ + get_expr_use_or_unification_node, is_def_id_trait_method, is_in_test, is_refutable, +}; use rustc_abi::ExternAbi; use rustc_hir::def::DefKind; use rustc_hir::def_id::{DefId, LocalDefId}; use rustc_hir::intravisit::FnKind; use rustc_hir::{ - Block, Body, Expr, ExprKind, FnDecl, HirId, LangItem, LetStmt, MatchSource, Node, PatKind, + Block, Body, Expr, ExprKind, FnDecl, HirId, LangItem, LetStmt, MatchSource, Node, Pat, PatKind, StmtKind, }; use rustc_lint::{LateContext, LateLintPass}; @@ -35,10 +37,12 @@ rustc_session::declare_lint! { /// A `Result` return would have made this caller decide. /// /// Silent when the `Err` arm or `else` block does anything besides exit - /// (logs, stores or converts the error: it was looked at); when the error - /// type had no kind to lose -- zero-sized (`()`, `AllocError`, + /// (logs, stores or converts the error: it was looked at), or an `Err` + /// pattern of that `match`/`if let` names a kind (`Err(Errno::NOENT) => + /// false` answers a question, and the sibling arm saw the rest); when the + /// error type had no kind to lose -- zero-sized (`()`, `AllocError`, /// `TryFromIntError`, a lone unit variant), where `false` says as much as - /// the error did, or a bare number (`binary_search`'s `Err(idx)` is an + /// the error did, or a bare primitive (`binary_search`'s `Err(idx)` is an /// answer, not a failure); on trait methods and non-Rust-ABI functions /// (the signature is not the function's to choose); on collapses inside /// closures; on callees in other crates; on calls in tests or produced @@ -96,16 +100,28 @@ rustc_session::impl_lint_pass!(CollapsedError => [COLLAPSED_ERROR]); /// The error type of `e`'s `Result`, when it has a kind that `false` loses: /// not a bare primitive, and not zero-sized (`()`, `!`, a unit struct, a /// lone unit variant), which distinguishes nothing a `bool` does not. A type -/// whose layout is unknown here (generic) is given the benefit of the doubt. +/// whose layout is unknown here (generic, unsized) is given the benefit of +/// the doubt. fn err_of<'tcx>(cx: &LateContext<'tcx>, e: &Expr<'tcx>) -> Option> { let ty = cx.typeck_results().expr_ty(e).peel_refs(); - let err = result_err_ty(cx.tcx, ty)?.peel_refs(); - if err.is_primitive() || err.is_str() || cx.layout_of(err).is_ok_and(|l| l.is_zst()) { + let err = result_err_ty(cx.tcx, ty)?; + let bare = err.peel_refs(); + if bare.is_primitive() || cx.layout_of(bare).is_ok_and(|l| l.is_zst()) { return None; } Some(err) } +/// `pat` takes its variant's payload whole (`Err(_)`, `Err(e)`, `Err(..)`) +/// rather than naming a kind of it (`Err(Errno::NOENT)`). +fn takes_whole(cx: &LateContext<'_>, pat: &Pat<'_>) -> bool { + match pat.kind { + PatKind::TupleStruct(_, subs, _) => !subs.iter().any(|p| is_refutable(cx, p)), + PatKind::Struct(_, fields, _) => !fields.iter().any(|f| is_refutable(cx, f.pat)), + _ => false, + } +} + /// `v` is the bare exit value: the literal `false`, or the path `None`. fn is_exit(cx: &LateContext<'_>, v: &Expr<'_>, exit: Exit) -> bool { let v = peel_blocks_unsafe(v); @@ -216,15 +232,24 @@ fn collapses<'tcx>( for_each_expr_without_closures(body.value, |e: &'tcx Expr<'tcx>| { let value_position = tails.branches.contains(&e.hir_id); match e.kind { + // One `Err` arm that names a kind, is guarded, or does more than + // exit means the error was looked at in this `match`. ExprKind::Match(scrut, arms, MatchSource::Normal) => { if let Some(err) = err_of(cx, scrut) { - for arm in arms { - if arm.guard.is_none() - && arm_variant(cx, arm.pat).is_some_and(|v| Some(v) == err_variant) - && pure_exit(cx, arm.body, exit, value_position).is_some() - { - sites.push((arm.span, err)); - } + let err_arms: Vec<_> = arms + .iter() + .filter(|arm| { + arm_variant(cx, arm.pat).is_some_and(|v| Some(v) == err_variant) + }) + .collect(); + if !err_arms.is_empty() + && err_arms.iter().all(|arm| { + arm.guard.is_none() + && takes_whole(cx, arm.pat) + && pure_exit(cx, arm.body, exit, value_position).is_some() + }) + { + sites.extend(err_arms.iter().map(|arm| (arm.span, err))); } } } @@ -253,7 +278,7 @@ fn collapses<'tcx>( ExprKind::Let(l) => err_of(cx, l.init).and_then(|err| { let head = arm_variant(cx, l.pat)?; let on_err = if Some(head) == err_variant { - Some(then) + takes_whole(cx, l.pat).then_some(then) } else if Some(head) == ok_variant { els } else { @@ -269,7 +294,8 @@ fn collapses<'tcx>( sites.push((leave.span, err)); } } - ExprKind::Block(block, _) => { + // A `loop` body is the one block that is not also an expression. + ExprKind::Block(block, _) | ExprKind::Loop(block, ..) => { let_else_collapses(cx, block, exit, ok_variant, &mut sites) } _ => {} diff --git a/ui/collapsed_error.rs b/ui/collapsed_error.rs index e55a460..ec06086 100644 --- a/ui/collapsed_error.rs +++ b/ui/collapsed_error.rs @@ -127,6 +127,76 @@ fn logged_dropped(buf: &mut Vec, log: &mut Vec) { write_logged(buf, log); } +// Fine: an `Err` arm that names a kind asked the error a question, and its +// sibling saw the rest. +fn write_unless_full(buf: &mut Vec, log: &mut Vec) -> bool { + match sys_write(buf, b"k") { + Ok(_) => true, + Err(SysError(28)) => false, + Err(e) => { + log.push(e.0); + false + } + } +} + +fn unless_full_dropped(buf: &mut Vec, log: &mut Vec) { + write_unless_full(buf, log); +} + +// Fine: the same, spelt as two `if let`s. +fn write_unless_full_if(buf: &mut Vec, log: &mut Vec) -> bool { + let r = sys_write(buf, b"k"); + if let Err(SysError(28)) = r { + return false; + } + if let Err(e) = r { + log.push(e.0); + return false; + } + true +} + +fn unless_full_if_dropped(buf: &mut Vec, log: &mut Vec) { + write_unless_full_if(buf, log); +} + +// Collapses in a `let .. else` directly inside a `loop` body. +fn fill(buf: &mut Vec) -> bool { + loop { + let Ok(n) = sys_write(buf, b"f") else { + return false; + }; + if n > 0 { + break; + } + } + true +} + +// Flagged. +fn fill_dropped(buf: &mut Vec) { + fill(buf); +} + +// Collapses a `&str` error, which carried a message. +fn parse_digit(s: &str) -> Result { + match s.as_bytes() { + [b @ b'0'..=b'9'] => Ok(b - b'0'), + [] => Err("empty"), + _ => Err("not a digit"), + } +} + +fn is_digit(s: &str) -> bool { + parse_digit(s).is_ok() +} + +// Flagged. +fn digit_discarded() { + let _ = is_digit("x"); +} + // Fine: every call reads the `bool`. fn try_mark(buf: &mut Vec) -> bool { sys_write(buf, b"c").is_ok() @@ -239,6 +309,10 @@ fn main() { chained_dropped(&mut buf); probe_dropped(&mut buf); logged_dropped(&mut buf, &mut log); + unless_full_dropped(&mut buf, &mut log); + unless_full_if_dropped(&mut buf, &mut log); + fill_dropped(&mut buf); + digit_discarded(); let _ = mark_checked(&mut buf); sorted_discarded(&[1, 2, 3]); ping_discarded(); diff --git a/ui/collapsed_error.stderr b/ui/collapsed_error.stderr index 33b81fb..03a0616 100644 --- a/ui/collapsed_error.stderr +++ b/ui/collapsed_error.stderr @@ -90,5 +90,31 @@ LL | None | ^^^^ = help: return the `Result` and let this caller decide; failing that, `#[must_use]` -warning: 7 warnings emitted +warning: `fill` reports `SysError` only as `false`, and this call drops the `false`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:179:5 + | +LL | fill(buf); + | ^^^^^^^^^ + | +note: the error becomes `false` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:168:13 + | +LL | return false; + | ^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: `is_digit` reports `&str` only as `false`, and this call drops the `false`: the failure can no longer be observed anywhere + --> $DIR/collapsed_error.rs:197:13 + | +LL | let _ = is_digit("x"); + | ^^^^^^^^^^^^^ + | +note: the error becomes `false` here, and nothing else is done with it + --> $DIR/collapsed_error.rs:192:5 + | +LL | parse_digit(s).is_ok() + | ^^^^^^^^^^^^^^^^^^^^^^ + = help: return the `Result` and let this caller decide; failing that, `#[must_use]` + +warning: 9 warnings emitted From 088d6af1305c7d432c8475af208dee1ee4e449f5 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:54:12 -0700 Subject: [PATCH 29/34] crossed_index reads place names first, skips typed indices --- src/crossed_index.rs | 153 ++++++++++++++++++++++++++++------------ ui/crossed_index.rs | 92 ++++++++++++++++++++++++ ui/crossed_index.stderr | 41 ++++++++++- 3 files changed, 240 insertions(+), 46 deletions(-) diff --git a/src/crossed_index.rs b/src/crossed_index.rs index 47bc9af..35ed696 100644 --- a/src/crossed_index.rs +++ b/src/crossed_index.rs @@ -18,12 +18,18 @@ rustc_session::declare_lint! { /// named after it: `sources[source_index]`, `parts[part_index]` twice, /// then `parts[source_index]`. Both indices are plain integers, so /// `parts` accepts a source index and returns whichever element sits at - /// that offset. `[]`, `.get`, `.get_mut` and `get_unchecked*` all count - /// as indexing; a place is the binding or item at the root plus the + /// that offset. `[]`, `.get`, `.get_mut` and `get_unchecked*` with an + /// integer operand all count as indexing (a newtyped index is already + /// told apart by the compiler); a place is the binding or item at the + /// root plus the /// fields, zero-argument accessors and earlier indices on the way /// (`self.graph.parts`, `lockfile.packages.names()`, `parts[..]`), so a /// two-level table is a different place at each level and two locals - /// both called `resolutions` are two places. + /// both called `resolutions` are two places. The place's own kind is the + /// one it is named after, else the one indexing it more often, else the + /// one with no table of its own in the function; when nothing tells them + /// apart the crossing is still there and is reported at the kind used + /// second. /// /// The claim is read off names only, never types: silent when either /// name carries no kind suffix (`i`, `n`, `at`), when the two prefixes @@ -86,11 +92,10 @@ fn singular(word: &str) -> &str { /// abbreviates, a word of the other, plurals aside: `unresolved_dep` and /// `dependencies`, `other_chunk` and `chunk`, `pkg` and `package_names`. fn same_kind(a: &str, b: &str) -> bool { - a.split('_').map(singular).any(|x| { - b.split('_') - .map(singular) - .any(|y| abbreviates(x, y) || abbreviates(y, x)) - }) + fn words(s: &str) -> impl Iterator { + s.split('_').filter(|w| !w.is_empty()).map(singular) + } + words(a).any(|x| words(b).any(|y| abbreviates(x, y) || abbreviates(y, x))) } /// Zero-argument methods that expose the receiver's own elements rather @@ -205,6 +210,27 @@ struct Site { span: Span, } +/// One index kind's sites on one place, names that share or abbreviate a +/// word counted as one kind (`pkg_id` with `package_id`). +struct KindHere<'a> { + kinds: Vec, + first: &'a Site, + sites: Vec<&'a Site>, +} + +impl KindHere<'_> { + fn has(&self, kind: Symbol) -> bool { + self.kinds + .iter() + .any(|k| same_kind(k.as_str(), kind.as_str())) + } + + /// Some name of this kind indexes `place` somewhere in the body. + fn reaches(&self, reach: &HashMap>, place: usize) -> bool { + self.kinds.iter().any(|k| reach[k].contains(&place)) + } +} + /// One body's index sites and the places they land on. struct Sites { places: Vec, @@ -219,6 +245,7 @@ impl Sites { for_each_expr_without_closures(body.value, |e: &'tcx Expr<'tcx>| { if !e.span.from_expansion() && let Some((base, idx)) = index_parts(e) + && cx.typeck_results().expr_ty(idx).peel_refs().is_integral() && let Some(name) = value_name(idx) && let Some(kind) = claimed_kind(name.name.as_str()) && let Some(place) = place_of(cx, base) @@ -240,51 +267,80 @@ impl Sites { Self { places, sites } } - /// For each place indexed by two kinds: every site of the less-used kind - /// (ties: the later-introduced one) that is not at home on the place and - /// does have a home table in this body the other kind never touches, - /// with the other kind's first site, its count, and that home table. - fn crossings(&self) -> Vec<(&Site, &Site, usize, usize)> { + /// For each place indexed by two kinds, every site of the visiting kind, + /// with the place's own kind's first site and count and the visiting + /// kind's first site on a table of its own. A kind is visiting when the + /// place is not named after it, the body indexes a table named after it + /// that the other kind never touches, and the other kind is not the + /// lesser claim: named on the place, else used there more often, else + /// (one use each) without such a table of its own, else merely used + /// first, which decides only where the one report of a symmetric + /// crossing goes, not whether there is one. + fn crossings(&self) -> Vec<(&Site, &Site, usize, &Site)> { let mut reach: HashMap> = HashMap::new(); - let mut by_place: HashMap> = HashMap::new(); + let mut by_place: HashMap>> = HashMap::new(); for site in &self.sites { reach.entry(site.kind).or_default().insert(site.place); - by_place.entry(site.place).or_default().push(site); - } - let mut out = Vec::new(); - for (&place, place_sites) in &by_place { - let mut kinds: Vec<(Symbol, usize, &Site)> = Vec::new(); - for &site in place_sites { - match kinds.iter_mut().find(|(k, ..)| *k == site.kind) { - Some(entry) => entry.1 += 1, - None => kinds.push((site.kind, 1, site)), + let kinds = by_place.entry(site.place).or_default(); + match kinds.iter_mut().find(|k| k.has(site.kind)) { + Some(k) => { + if !k.kinds.contains(&site.kind) { + k.kinds.push(site.kind); + } + if site.span.lo() < k.first.span.lo() { + k.first = site; + } + k.sites.push(site); } + None => kinds.push(KindHere { + kinds: vec![site.kind], + first: site, + sites: vec![site], + }), } - kinds.sort_by_key(|&(_, n, first)| (std::cmp::Reverse(n), first.span.lo())); - for (i, &(kind, ..)) in kinds.iter().enumerate() { - if self.places[place].named_after(kind.as_str()) { + } + // `k`'s first site on a table named after it that `other` never + // indexes. + let home_of = |k: &KindHere<'_>, other: &KindHere<'_>| { + self.sites + .iter() + .filter(|s| { + k.kinds.contains(&s.kind) + && !other.reaches(&reach, s.place) + && self.places[s.place].named_after(s.kind.as_str()) + }) + .min_by_key(|s| (s.place, s.span.lo())) + }; + let mut out = Vec::new(); + for (&place, kinds) in &by_place { + let named = |k: &KindHere<'_>| { + k.kinds + .iter() + .any(|s| self.places[place].named_after(s.as_str())) + }; + let rank = |k: &KindHere<'_>| (named(k), k.sites.len()); + for (i, k) in kinds.iter().enumerate() { + if named(k) { continue; } - let home_of_kind_outside = |major: Symbol| { - reach[&kind] - .iter() - .copied() - .filter(|p| { - !reach[&major].contains(p) && self.places[*p].named_after(kind.as_str()) - }) - .min() - }; - let Some((&(_, major_n, major_first), home)) = kinds[..i] + let mut majors: Vec<&KindHere<'_>> = kinds .iter() - .filter(|(major, ..)| !same_kind(major.as_str(), kind.as_str())) - .find_map(|m| Some((m, home_of_kind_outside(m.0)?))) - else { + .enumerate() + .filter(|&(j, m)| j != i && rank(m) >= rank(k)) + .map(|(_, m)| m) + .collect(); + majors.sort_by_key(|m| (std::cmp::Reverse(rank(m)), m.first.span.lo())); + let Some((major, home)) = majors.into_iter().find_map(|m| { + let h = home_of(k, m)?; + (rank(m) > rank(k) + || home_of(m, k).is_none() + || m.first.span.lo() < k.first.span.lo()) + .then_some((m, h)) + }) else { continue; }; - for &site in place_sites { - if site.kind == kind { - out.push((site, major_first, major_n, home)); - } + for &site in &k.sites { + out.push((site, major.first, major.sites.len(), home)); } } } @@ -297,20 +353,27 @@ impl<'tcx> LateLintPass<'tcx> for CrossedIndex { fn check_body(&mut self, cx: &LateContext<'tcx>, body: &Body<'tcx>) { let sites = Sites::collect(cx, body); for (site, major_first, major_n, home) in sites.crossings() { + // The table of the visiting kind may be indexed under another + // name of that kind (`dep_id` there, `dep_idx` here). + let by = if home.name == site.name { + format!("`{}`", site.name) + } else { + format!("`{}` (the same kind as `{}`)", home.name, site.name) + }; emit_with_note( cx, CROSSED_INDEX, site.span, format!( "`{place}` is indexed by `{name}` here but by `{major}` elsewhere in this \ - function ({major_n} site{s}), while `{name}` is what indexes `{home}`: \ + function ({major_n} site{s}), while {by} is what indexes `{home}`: \ the names claim different index kinds and both are plain integers, so \ the crossing compiles", place = sites.places[site.place].shown, name = site.name, major = major_first.name, s = if major_n == 1 { "" } else { "s" }, - home = sites.places[home].shown, + home = sites.places[home.place].shown, ), major_first.span, "indexed by the other kind here", diff --git a/ui/crossed_index.rs b/ui/crossed_index.rs index 7941c0f..54b2efc 100644 --- a/ui/crossed_index.rs +++ b/ui/crossed_index.rs @@ -60,6 +60,86 @@ impl Graph { } } +struct Dep { + entry_id: u32, + dep_id: u32, +} + +// Flagged, naming the index the home table does use: `dep_idx` and `d.dep_id` are one kind. +fn renamed_crossing_is_flagged( + states: &[u32], + dependencies: &[u32], + d: &Dep, + root_id: usize, +) -> u32 { + let unvisited = states[root_id]; + let name = dependencies[d.dep_id as usize]; + let dep_idx = d.entry_id as usize; + unvisited + name + states[dep_idx] +} + +// Flagged though the crossing comes first: `parts` is named after `part_index`. +fn crossing_first_is_flagged( + sources: &[u32], + parts: &[u32], + source_index: usize, + part_index: usize, +) -> u32 { + let crossed = parts[source_index]; + crossed + parts[part_index] + sources[source_index] +} + +// Flagged once: `dep_id` on the table `pkg_id` indexes; `package_id` spells `pkg_id` out and stays quiet. +fn third_kind_is_flagged( + table: &[u32], + deps: &[u32], + packages: &[u32], + pkg_id: usize, + package_id: usize, + dep_id: usize, +) -> u32 { + table[pkg_id] + + table[pkg_id] + + table[dep_id] + + table[package_id] + + deps[dep_id] + + packages[package_id] +} + +#[derive(Clone, Copy)] +struct FileId(usize); +#[derive(Clone, Copy)] +struct FnId(usize); +struct Db { + files: Vec, + fns: Vec, +} +struct Files(Vec); + +impl std::ops::Index for Db { + type Output = u32; + fn index(&self, i: FileId) -> &u32 { + &self.files[i.0] + } +} +impl std::ops::Index for Db { + type Output = u32; + fn index(&self, i: FnId) -> &u32 { + &self.fns[i.0] + } +} +impl std::ops::Index for Files { + type Output = u32; + fn index(&self, i: FileId) -> &u32 { + &self.0[i.0] + } +} + +// Fine: typed indices already tell the kinds apart. +fn typed_indices_are_fine(db: &Db, files: &Files, file_id: FileId, fn_id: FnId) -> u32 { + db[fn_id] + db[fn_id] + files[file_id] + db[file_id] +} + // Fine: a role name for the same kind has no table of its own name. fn role_name_is_fine(nodes: &[u32], depths: &[u32], node_idx: usize, parent_idx: usize) -> u32 { nodes[node_idx] + depths[node_idx] + nodes[parent_idx] + depths[parent_idx] @@ -109,6 +189,18 @@ fn main() { g.crossed_field_is_flagged(0, 0); let _ = g.nested_table_is_fine(0, 0); let _ = g.parallel_columns_are_fine(0); + let d = Dep { + entry_id: 0, + dep_id: 0, + }; + let _ = renamed_crossing_is_flagged(&[0], &[0], &d, 0); + let _ = crossing_first_is_flagged(&[0], &[0], 0, 0); + let _ = third_kind_is_flagged(&[0], &[0], &[0], 0, 0, 0); + let db = Db { + files: vec![0], + fns: vec![0], + }; + let _ = typed_indices_are_fine(&db, &Files(vec![0]), FileId(0), FnId(0)); let _ = role_name_is_fine(&[0], &[0], 0, 0); abbreviation_is_fine(&l, 0, 0, 0, 0); let _ = shadowed_local_is_fine(&l, &[0], 0, 0); diff --git a/ui/crossed_index.stderr b/ui/crossed_index.stderr index b30c7da..476c0d1 100644 --- a/ui/crossed_index.stderr +++ b/ui/crossed_index.stderr @@ -38,5 +38,44 @@ LL | let _ = self.files[source_index as usize]; | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ = help: an index newtype per table, with `Index` implemented only for its own, turns the crossing into a type error -warning: 3 warnings emitted +warning: `states` is indexed by `dep_idx` here but by `root_id` elsewhere in this function (1 site), while `dep_id` (the same kind as `dep_idx`) is what indexes `dependencies`: the names claim different index kinds and both are plain integers, so the crossing compiles + --> $DIR/crossed_index.rs:78:24 + | +LL | unvisited + name + states[dep_idx] + | ^^^^^^^^^^^^^^^ + | +note: indexed by the other kind here + --> $DIR/crossed_index.rs:75:21 + | +LL | let unvisited = states[root_id]; + | ^^^^^^^^^^^^^^^ + = help: an index newtype per table, with `Index` implemented only for its own, turns the crossing into a type error + +warning: `parts` is indexed by `source_index` here but by `part_index` elsewhere in this function (1 site), while `source_index` is what indexes `sources`: the names claim different index kinds and both are plain integers, so the crossing compiles + --> $DIR/crossed_index.rs:88:19 + | +LL | let crossed = parts[source_index]; + | ^^^^^^^^^^^^^^^^^^^ + | +note: indexed by the other kind here + --> $DIR/crossed_index.rs:89:15 + | +LL | crossed + parts[part_index] + sources[source_index] + | ^^^^^^^^^^^^^^^^^ + = help: an index newtype per table, with `Index` implemented only for its own, turns the crossing into a type error + +warning: `table` is indexed by `dep_id` here but by `pkg_id` elsewhere in this function (3 sites), while `dep_id` is what indexes `deps`: the names claim different index kinds and both are plain integers, so the crossing compiles + --> $DIR/crossed_index.rs:103:11 + | +LL | + table[dep_id] + | ^^^^^^^^^^^^^ + | +note: indexed by the other kind here + --> $DIR/crossed_index.rs:101:5 + | +LL | table[pkg_id] + | ^^^^^^^^^^^^^ + = help: an index newtype per table, with `Index` implemented only for its own, turns the crossing into a type error + +warning: 6 warnings emitted From 4cb3876041d153f2ab2be283485e4e4bd7855a6f Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 18:54:29 -0700 Subject: [PATCH 30/34] parallel_vecs counts any mutable access as a length change --- src/parallel_vecs.rs | 246 ++++++++++++++++++++++++++++++---------- ui/parallel_vecs.rs | 189 ++++++++++++++++++++++++++++++ ui/parallel_vecs.stderr | 4 +- 3 files changed, 377 insertions(+), 62 deletions(-) diff --git a/src/parallel_vecs.rs b/src/parallel_vecs.rs index 14f9775..55f59d8 100644 --- a/src/parallel_vecs.rs +++ b/src/parallel_vecs.rs @@ -1,12 +1,15 @@ use std::collections::{HashMap, HashSet}; use clippy_utils::res::MaybeResPath; -use clippy_utils::{SpanlessEq, get_parent_expr, hash_expr}; +use clippy_utils::{SpanlessEq, get_parent_expr, hash_expr, is_default_equivalent}; use rustc_hir::def_id::{DefId, LocalDefId}; -use rustc_hir::{BorrowKind, Expr, ExprKind, HirId, Mutability, Node, UnOp}; +use rustc_hir::{ + BindingMode, ByRef, Expr, ExprKind, HirId, Mutability, Node, Pat, PatKind, QPath, UnOp, +}; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{self, Ty}; -use rustc_span::{DesugaringKind, Ident, Symbol}; +use rustc_span::hygiene::{ExpnKind, MacroKind}; +use rustc_span::{DesugaringKind, Ident, Symbol, sym}; use crate::adt_facts::field_ty; use crate::baseline::emit; @@ -16,9 +19,10 @@ rustc_session::declare_lint! { /// Flags two or more growable-sequence fields of one struct (`Vec`, /// `VecDeque`, or any type with `push`/`append`, `len` and indexing) /// whose lengths the crate only ever changes side by side -- every - /// `push`, `pop`, `clear`, `truncate`, reassignment or `&mut` borrow of - /// one sits in the same block as one of the other, on the same value -- - /// and that some function reads at one index (`s.a[i]` with `s.b[i]`, + /// method call that takes one as `&mut`, reassignment, `&mut` borrow or + /// `ref mut` destructuring of one sits in the same block as one of the + /// other, on the same value, and every struct literal starts both empty + /// -- and that some function reads at one index (`s.a[i]` with `s.b[i]`, /// `s.a.get(i)` with `s.b.get(i)`) or zips together. Element `i` of each /// is one record kept in several places by hand: the type admits /// sequences of different lengths, and only the discipline of every @@ -26,48 +30,62 @@ rustc_session::declare_lint! { /// a struct with those fields holds the pairing in the type. /// /// Only fields nothing outside the crate can write are considered: the - /// struct is private to the crate, or the field is. One length change of - /// either field without the other beside it disproves the pairing and - /// the lint stays quiet; so does a pair grown together but never read in - /// step, and pushes to two different values of the type. + /// struct is private to the crate, or the field is. One mutable access + /// to either field without the other beside it disproves the pairing + /// and the lint stays quiet, whatever the method is called, short of a + /// few std methods that cannot change a length (`reserve`, `sort`, + /// `iter_mut`, ..); so does a field built from anything but an empty + /// constructor, a pair grown together but never read in step, and + /// pushes to two different values of the type. pub PARALLEL_VECS, Warn, "sequence fields only ever grown together and read at one index" } -/// Methods that change a sequence's length. -const LEN_OPS: &[&str] = &[ - "push", - "push_back", - "push_front", - "append", - "insert", - "extend", - "extend_from_slice", - "extend_from_within", - "resize", - "resize_with", - "pop", - "pop_back", - "pop_front", - "clear", - "truncate", - "remove", - "swap_remove", - "swap_remove_back", - "swap_remove_front", - "drain", - "retain", - "retain_mut", - "dedup", - "dedup_by", - "dedup_by_key", - "split_off", - "set_len", - "splice", - "append_assume_capacity", +/// Methods that take a sequence as `&mut` yet cannot change its length. +/// Anything else handed the field mutably may. +const KEEPS_LEN: &[&str] = &[ + "as_mut", + "as_mut_ptr", + "as_mut_slice", + "as_mut_slices", + "back_mut", + "borrow_mut", + "deref_mut", + "fill", + "fill_with", + "first_mut", + "front_mut", + "get_mut", + "get_unchecked_mut", + "index_mut", + "iter_mut", + "last_mut", + "make_contiguous", + "range_mut", + "reserve", + "reserve_exact", + "reverse", + "rotate_left", + "rotate_right", + "shrink_to", + "shrink_to_fit", + "sort", + "sort_by", + "sort_by_key", + "sort_unstable", + "sort_unstable_by", + "sort_unstable_by_key", + "spare_capacity_mut", + "swap", + "try_reserve", + "try_reserve_exact", ]; +/// Constructors that yield an empty sequence whatever their arguments; a +/// bare `new()` or `default()` only with none. +const EMPTY_CTORS: &[&str] = &["new_in", "with_capacity", "with_capacity_in"]; + /// Positional reads with one index argument. const GET_OPS: &[&str] = &["get", "get_mut", "get_unchecked", "get_unchecked_mut"]; @@ -82,13 +100,16 @@ const ITER_ADAPTERS: &[&str] = &[ "drain", ]; -/// The value a sequence field is read off, `s` or `self.tape`: by shape, -/// and by identity too when it is a local binding or a projection of one, -/// since every local hashes alike. +/// The value a sequence field belongs to. #[derive(Clone, Copy, PartialEq, Eq, Hash)] -struct Place { - local: Option, - shape: u64, +enum Place { + /// Read off an expression, `s` or `self.tape`: by shape, and by + /// identity too when it is a local binding or a projection of one, + /// since every local hashes alike. + Expr { local: Option, shape: u64 }, + /// Taken apart by this struct pattern, or one field's initialiser in a + /// struct literal, which sets that length alone. + Whole(HirId), } /// Where a length change happens: the body, the nearest block or match arm, @@ -200,13 +221,29 @@ impl ParallelVecs { if !fields.contains(&field.name) { return None; } - let place = Place { + let place = Place::Expr { local: local_root(base), shape: hash_expr(cx, base), }; Some((adt, field.name, place)) } + /// A length change of `field` at `at`, on `place`. + fn note_write( + &mut self, + cx: &LateContext<'_>, + adt: DefId, + field: Symbol, + at: HirId, + place: Place, + ) { + let body = cx.tcx.hir_enclosing_body_owner(at).to_def_id(); + self.writes + .entry((adt, field)) + .or_default() + .insert((body, step_scope(cx, at), place)); + } + fn record_write<'tcx>( &mut self, cx: &LateContext<'tcx>, @@ -214,15 +251,35 @@ impl ParallelVecs { base: &'tcx Expr<'tcx>, field: Ident, ) { - let Some((adt, field, place)) = self.sequence_field(cx, base, field) else { + if let Some((adt, field, place)) = self.sequence_field(cx, base, field) { + self.note_write(cx, adt, field, at.hir_id, place); + } + } + + /// A struct literal sets each sequence field's length on its own: every + /// field not built empty is written there, alone. + fn record_literal<'tcx>(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + let ExprKind::Struct(_, inits, _) = expr.kind else { return; }; - let body = cx.tcx.hir_enclosing_body_owner(at.hir_id).to_def_id(); - self.writes.entry((adt, field)).or_default().insert(( - body, - step_scope(cx, at.hir_id), - place, - )); + // A derived `Clone` copies both lengths off one value: it keeps + // whatever the other sites establish and proves nothing itself. + if let ExpnKind::Macro(MacroKind::Derive, name) = expr.span.ctxt().outer_expn_data().kind + && name == sym::Clone + { + return; + } + let Some((adt, fields)) = self.candidates(cx, cx.typeck_results().expr_ty(expr)) else { + return; + }; + let written: Vec<(Symbol, HirId)> = inits + .iter() + .filter(|i| fields.contains(&i.ident.name) && !is_empty_ctor(cx, i.expr)) + .map(|i| (i.ident.name, i.expr.hir_id)) + .collect(); + for (field, init) in written { + self.note_write(cx, adt, field, expr.hir_id, Place::Whole(init)); + } } fn record_index<'tcx>( @@ -342,6 +399,39 @@ fn is_for_loop_head(cx: &LateContext<'_>, e: &Expr<'_>) -> bool { }) } +/// The method call auto-borrows `recv` as `&mut` (or `*mut`) to its own +/// type: the callee is handed the sequence itself mutably, not the slice +/// it derefs to, and so may change its length. +fn borrows_receiver_mut(cx: &LateContext<'_>, recv: &Expr<'_>) -> bool { + let typeck = cx.typeck_results(); + let ty = typeck.expr_ty(recv); + match *typeck.expr_ty_adjusted(recv).kind() { + ty::Ref(_, inner, Mutability::Mut) | ty::RawPtr(inner, Mutability::Mut) => inner == ty, + _ => false, + } +} + +/// An initialiser that yields an empty sequence: whatever `Default` gives, +/// or a constructor call named for building one. +fn is_empty_ctor(cx: &LateContext<'_>, e: &Expr<'_>) -> bool { + if is_default_equivalent(cx, e) { + return true; + } + let ExprKind::Call(callee, args) = e.kind else { + return false; + }; + let name = match callee.kind { + ExprKind::Path(QPath::TypeRelative(_, seg)) => seg.ident.name, + ExprKind::Path(QPath::Resolved(_, path)) => match path.segments.last() { + Some(seg) => seg.ident.name, + None => return false, + }, + _ => return false, + }; + let name = name.as_str(); + EMPTY_CTORS.contains(&name) || (args.is_empty() && matches!(name, "new" | "default")) +} + impl<'tcx> LateLintPass<'tcx> for ParallelVecs { fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { match expr.kind { @@ -356,17 +446,17 @@ impl<'tcx> LateLintPass<'tcx> for ParallelVecs { { self.record_zip(cx, l, r); } - ExprKind::MethodCall(..) => { + ExprKind::MethodCall(_, recv, ..) => { let Some(call) = field_method_call(expr) else { return; }; let name = call.method.name.as_str(); - if LEN_OPS.contains(&name) { - self.record_write(cx, expr, call.base, call.field); - } else if GET_OPS.contains(&name) + if GET_OPS.contains(&name) && let [index] = call.args { self.record_index(cx, expr, call.base, call.field, index); + } else if !KEEPS_LEN.contains(&name) && borrows_receiver_mut(cx, recv) { + self.record_write(cx, expr, call.base, call.field); } } ExprKind::Index(..) => { @@ -379,17 +469,53 @@ impl<'tcx> LateLintPass<'tcx> for ParallelVecs { self.record_write(cx, expr, base, field); } } - ExprKind::AddrOf(BorrowKind::Ref, Mutability::Mut, inner) => { + ExprKind::AddrOf(_, Mutability::Mut, inner) => { if let Some((base, field, _)) = assigned_field(inner) && !is_for_loop_head(cx, expr) { self.record_write(cx, expr, base, field); } } + ExprKind::Struct(..) => self.record_literal(cx, expr), _ => {} } } + // `let S { a, .. } = self` on `&mut self`, or `S { ref mut a, .. }`: the + // binding is a `&mut` to the field, through which its length changes. + fn check_pat(&mut self, cx: &LateContext<'tcx>, pat: &'tcx Pat<'tcx>) { + let PatKind::Struct(_, bindings, _) = pat.kind else { + return; + }; + let Some(typeck) = cx.maybe_typeck_results() else { + return; + }; + let Some((adt, fields)) = self.candidates(cx, typeck.pat_ty(pat)) else { + return; + }; + let written: Vec = bindings + .iter() + .filter(|b| fields.contains(&b.ident.name)) + .filter(|b| { + let mut by_mut_ref = false; + b.pat.walk(|p| { + if let PatKind::Binding(..) = p.kind + && let Some(BindingMode(ByRef::Yes(_, Mutability::Mut), _)) = + typeck.pat_binding_modes().get(p.hir_id) + { + by_mut_ref = true; + } + !by_mut_ref + }); + by_mut_ref + }) + .map(|b| b.ident.name) + .collect(); + for field in written { + self.note_write(cx, adt, field, pat.hir_id, Place::Whole(pat.hir_id)); + } + } + fn check_body_post(&mut self, cx: &LateContext<'tcx>, body: &rustc_hir::Body<'tcx>) { let owner = cx.tcx.hir_body_owner_def_id(body.id()); self.reads.retain(|r| r.owner != owner); diff --git a/ui/parallel_vecs.rs b/ui/parallel_vecs.rs index 764732e..27cf859 100644 --- a/ui/parallel_vecs.rs +++ b/ui/parallel_vecs.rs @@ -11,6 +11,22 @@ struct People { } impl People { + // Both start empty, whatever the constructor is called. + fn new() -> Self { + Self { + names: Vec::new(), + ages: Vec::with_capacity(4), + seen: 0, + } + } + + // `&mut` methods that cannot change a length are not writes. + fn tidy(&mut self) { + self.ages.reserve(8); + self.names.sort(); + self.names.as_mut_slice().reverse(); + } + fn add(&mut self, name: String, age: u32) { self.names.push(name); self.ages.push(age); @@ -187,4 +203,177 @@ pub fn open(o: &mut Open, i: usize) -> u8 { o.firsts[i] + o.seconds[i] } +// Fine: `bytes` also grows alone through `io::Write`, a trait method the lint has no name for. +struct Log { + bytes: Vec, + marks: Vec, +} + +impl Log { + fn mark(&mut self, b: u8) { + self.bytes.push(b); + self.marks.push(b); + } + + fn raw(&mut self, data: &[u8]) { + use std::io::Write; + self.bytes.write_all(data).unwrap(); + } + + fn at(&self, i: usize) -> u8 { + self.bytes[i] + self.marks[i] + } +} + +// Fine: `text` is a `String` grown alone by `push_str`. +struct Masked { + text: String, + mask: Vec, +} + +impl Masked { + fn put(&mut self, c: char, m: bool) { + self.text.push(c); + self.mask.push(m); + } + + fn word(&mut self, w: &str) { + self.text.push_str(w); + } + + fn at(&self, i: usize) -> bool { + self.text.get(i..).is_some() && self.mask.get(i..).is_some() + } +} + +// Fine: `xs` shrinks alone through `pop_if`. +struct Popped { + xs: Vec, + ys: Vec, +} + +impl Popped { + fn add(&mut self, x: u8) { + self.xs.push(x); + self.ys.push(x); + } + + fn trim(&mut self) { + self.xs.pop_if(|x| *x == 0); + } + + fn at(&self, i: usize) -> u8 { + self.xs[i] + self.ys[i] + } +} + +// Fine: a custom sequence type grown alone through its own method. +struct List(Vec); + +impl List { + fn push(&mut self, t: T) { + self.0.push(t); + } + + fn append_slice(&mut self, s: &[T]) { + self.0.extend_from_slice(s); + } + + fn len(&self) -> usize { + self.0.len() + } + + fn get(&self, i: usize) -> Option<&T> { + self.0.get(i) + } +} + +struct Lists { + a: List, + b: List, +} + +impl Lists { + fn add(&mut self, x: u8) { + self.a.push(x); + self.b.push(x); + } + + fn bulk(&mut self, s: &[u8]) { + self.a.append_slice(s); + } + + fn at(&self, i: usize) -> u8 { + *self.a.get(i).unwrap() + *self.b.get(i).unwrap() + } +} + +// Fine: `names` is cleared alone through a `&mut` binding split off `self`. +struct Destr { + names: Vec, + ages: Vec, +} + +impl Destr { + fn add(&mut self, x: u8) { + self.names.push(x); + self.ages.push(x); + } + + fn only_names(&mut self) { + let Self { names, .. } = self; + names.clear(); + } + + fn at(&self, i: usize) -> u8 { + self.names[i] + self.ages[i] + } +} + +// Fine: `xs` grows alone through a raw pointer to it. +struct Raw { + xs: Vec, + ys: Vec, +} + +impl Raw { + fn add(&mut self, x: u8) { + self.xs.push(x); + self.ys.push(x); + } + + fn poke(&mut self) { + let p = &raw mut self.xs; + unsafe { (*p).push(0) }; + } + + fn at(&self, i: usize) -> u8 { + self.xs[i] + self.ys[i] + } +} + +// Fine: born with `a` already longer than `b`. +struct Birth { + a: Vec, + b: Vec, +} + +impl Birth { + fn new(seed: Vec) -> Self { + Birth { + a: seed, + b: Vec::new(), + } + } + + fn add(&mut self, x: u8) { + self.a.push(x); + self.b.push(x); + } + + fn at(&self, i: usize) -> u8 { + self.a[i] + self.b[i] + } +} + fn main() {} diff --git a/ui/parallel_vecs.stderr b/ui/parallel_vecs.stderr index 4803774..86dbd2e 100644 --- a/ui/parallel_vecs.stderr +++ b/ui/parallel_vecs.stderr @@ -8,7 +8,7 @@ LL | struct People { = note: `#[warn(parallel_vecs)]` on by default warning: parallel vecs: the fields `keys`, `phases`, `values` of `Table` only change length together (1 block) and are read at one index, so element `i` of each is one record kept in 3 places - --> $DIR/parallel_vecs.rs:38:1 + --> $DIR/parallel_vecs.rs:54:1 | LL | struct Table { | ^^^^^^^^^^^^ @@ -16,7 +16,7 @@ LL | struct Table { = help: one `Vec` of a struct with these fields holds the pairing in the type and cannot let the lengths differ warning: parallel vecs: the fields `items`, `locs` of `Tape` only change length together (2 blocks) and are read at one index, so element `i` of each is one record kept in 2 places - --> $DIR/parallel_vecs.rs:57:1 + --> $DIR/parallel_vecs.rs:73:1 | LL | struct Tape { | ^^^^^^^^^^^ From 9b04fcb7e4106b93a2014a822ff6b4453b16875a Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 19:21:32 -0700 Subject: [PATCH 31/34] misbound_arg drops the literal carve-out --- src/misbound_arg.rs | 39 ++++++--------------------------------- ui/misbound_arg.rs | 19 ++++++++----------- ui/misbound_arg.stderr | 28 ++++++++++++++++++++++------ 3 files changed, 36 insertions(+), 50 deletions(-) diff --git a/src/misbound_arg.rs b/src/misbound_arg.rs index a6e45ed..c60ed84 100644 --- a/src/misbound_arg.rs +++ b/src/misbound_arg.rs @@ -26,13 +26,13 @@ rustc_session::declare_lint! { /// dropping a leading `is_`/`has_`/`_` and a trailing `_`, when the /// parameter the name points at already receives an argument of that /// name, when the bound parameter's name contains the argument's as a - /// word (`from_index` receiving `index`), when the parameter the name - /// points at receives a literal or constant (no second value is there - /// to have been transposed with), when the same condition also calls - /// the callee with the two values the other way round (`sub(a, b) && - /// sub(b, a)` is a symmetric use), for `self`/`this` on either side (a - /// receiver slot is a grammatical position, not a role), for + /// word (`from_index` receiving `index`), when the same condition also + /// calls the callee with the two values the other way round (`sub(a, b) + /// && sub(b, a)` is a symmetric use), for `self`/`this` on either side + /// (a receiver slot is a grammatical position, not a role), for /// one-character names, and for calls through closures or fn pointers. + /// A literal or constant in the namesake slot does not excuse the call: + /// `resize(height, 0)` is the transposition with one side spelled out. pub MISBOUND_ARG, Warn, "argument named as a different same-typed parameter of the callee" @@ -80,29 +80,6 @@ fn received(args: &[Expr<'_>], offset: usize, slot: usize) -> Option { role(arg_name(arg)?) } -/// A literal or a named constant: spelled out at the call, so it cannot be -/// the value a neighbouring argument was confused with. -fn is_constant(cx: &LateContext<'_>, mut e: &Expr<'_>) -> bool { - while let ExprKind::Cast(inner, _) - | ExprKind::AddrOf(_, _, inner) - | ExprKind::Unary(UnOp::Neg, inner) - | ExprKind::DropTemps(inner) = e.kind - { - e = inner; - } - match &e.kind { - ExprKind::Lit(_) => true, - ExprKind::Path(qpath) => matches!( - cx.qpath_res(qpath, e.hir_id), - Res::Def( - DefKind::Const { .. } | DefKind::AssocConst { .. } | DefKind::ConstParam, - _ - ) - ), - _ => false, - } -} - fn args_of<'tcx>(callee: &Callee<'tcx>) -> (&'tcx [Expr<'tcx>], usize) { match *callee { Callee::Path { args, .. } => (args, 0), @@ -219,10 +196,6 @@ impl<'tcx> LateLintPass<'tcx> for MisboundArg { // so nothing is transposed, one value fills two roles. let at_other = received(args, offset, other); if at_other == Some(an) - || other - .checked_sub(offset) - .and_then(|i| args.get(i)) - .is_some_and(|a| is_constant(cx, a)) || at_other.is_some_and(|there| { mirrored_in_condition(cx, expr, def, (slot, there), (other, an)) }) diff --git a/ui/misbound_arg.rs b/ui/misbound_arg.rs index 14ce69a..1dc9eba 100644 --- a/ui/misbound_arg.rs +++ b/ui/misbound_arg.rs @@ -45,10 +45,6 @@ impl Canvas { } } -fn with_alias(from: &str, version: &str) -> usize { - from.len() + version.len() -} - fn under(url: &str, registry: &str) -> bool { url.starts_with(registry) } @@ -95,6 +91,13 @@ fn lone_reversal_is_flagged(url: &str, registry: &str) -> bool { under(registry, url) } +fn literal_partner_is_flagged(opts: &SpawnOptions, height: u32) -> u32 { + // Flagged: a literal in the namesake slot is still the other half of a + // transposition; `spawn(opts.inherit_stdout, true)` compiles just as well. + let _ = spawn(true, opts.inherit_stdout); + resize(height, 0) +} + fn correct_order_is_fine(width: u32, height: u32, opts: &SpawnOptions) -> u32 { // Fine: every name sits in its own slot. let _ = spawn(opts.inherit_stdout, opts.inherit_stderr); @@ -132,12 +135,6 @@ fn symmetric_pair_is_fine(url: &str, scope_registry: &str) -> bool { !(under(url, scope_registry) && under(scope_registry, url)) } -fn literal_namesake_is_fine(version: &str) -> usize { - // Fine: `version` fills `from`, but the `version` slot holds a literal, - // so there is no second value it could have been transposed with. - with_alias(version, "latest") -} - fn pseudo_receiver_is_fine(str: &[u8]) -> bool { // Fine: `self_` is a receiver slot; whatever fills it is the subject. has_prefix(str, b"./") @@ -167,8 +164,8 @@ fn main() { let _ = unnamed_args_are_fine(&Daemon { detached: false }); let _ = closures_are_fine(1, 2); let _ = lone_reversal_is_flagged("u", "r"); + let _ = literal_partner_is_flagged(&opts, 2); let _ = symmetric_pair_is_fine("u", "r"); let _ = pseudo_receiver_is_fine(b"s"); - let _ = literal_namesake_is_fine("v"); let _ = Span(1, 2).encloses(&Span(0, 3)); } diff --git a/ui/misbound_arg.stderr b/ui/misbound_arg.stderr index 62ed582..f0f1bf4 100644 --- a/ui/misbound_arg.stderr +++ b/ui/misbound_arg.stderr @@ -1,5 +1,5 @@ warning: arguments `height` and `width` are bound to `resize`'s parameters `width` and `height`; all are `u32`, so the transposition type-checks - --> $DIR/misbound_arg.rs:75:5 + --> $DIR/misbound_arg.rs:71:5 | LL | resize(height, width) | ^^^^^^^^^^^^^^^^^^^^^ @@ -8,7 +8,7 @@ LL | resize(height, width) = note: `#[warn(misbound_arg)]` on by default warning: arguments `inherit_stderr` and `inherit_stdout` are bound to `spawn`'s parameters `inherit_stdout` and `inherit_stderr`; all are `bool`, so the transposition type-checks - --> $DIR/misbound_arg.rs:80:5 + --> $DIR/misbound_arg.rs:76:5 | LL | spawn(opts.inherit_stderr, opts.inherit_stdout) | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ @@ -16,7 +16,7 @@ LL | spawn(opts.inherit_stderr, opts.inherit_stdout) = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: argument `column` is bound to `place`'s parameter `line`, but `place` also takes a parameter `column` of the same type `u32` - --> $DIR/misbound_arg.rs:85:11 + --> $DIR/misbound_arg.rs:81:11 | LL | place(column, extra, 0) | ^^^^^^ @@ -24,7 +24,7 @@ LL | place(column, extra, 0) = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: arguments `dst` and `src` are bound to `blit`'s parameters `src` and `dst`; all are `usize`, so the transposition type-checks - --> $DIR/misbound_arg.rs:90:5 + --> $DIR/misbound_arg.rs:86:5 | LL | c.blit(dst, src) | ^^^^^^^^^^^^^^^^ @@ -32,12 +32,28 @@ LL | c.blit(dst, src) = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error warning: arguments `registry` and `url` are bound to `under`'s parameters `url` and `registry`; all are `&str`, so the transposition type-checks - --> $DIR/misbound_arg.rs:95:5 + --> $DIR/misbound_arg.rs:91:5 | LL | under(registry, url) | ^^^^^^^^^^^^^^^^^^^^ | = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error -warning: 5 warnings emitted +warning: argument `inherit_stdout` is bound to `spawn`'s parameter `inherit_stderr`, but `spawn` also takes a parameter `inherit_stdout` of the same type `bool` + --> $DIR/misbound_arg.rs:97:25 + | +LL | let _ = spawn(true, opts.inherit_stdout); + | ^^^^^^^^^^^^^^^^^^^ + | + = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error + +warning: argument `height` is bound to `resize`'s parameter `width`, but `resize` also takes a parameter `height` of the same type `u32` + --> $DIR/misbound_arg.rs:98:12 + | +LL | resize(height, 0) + | ^^^^^^ + | + = help: give the two parameters distinct types (a newtype per quantity, an enum per flag) so a swap is a type error + +warning: 7 warnings emitted From be4f71e71cc0623753f14cedd6111fd2aacaf0ce Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 19:28:53 -0700 Subject: [PATCH 32/34] add crossed_alias --- README.md | 1 + src/crossed_alias.rs | 264 ++++++++++++++++++++++++++++++++++++++++ src/hir_shapes.rs | 130 +++++++++++++++++++- src/lib.rs | 4 +- ui/crossed_alias.rs | 101 +++++++++++++++ ui/crossed_alias.stderr | 67 ++++++++++ 6 files changed, 565 insertions(+), 2 deletions(-) create mode 100644 src/crossed_alias.rs create mode 100644 ui/crossed_alias.rs create mode 100644 ui/crossed_alias.stderr diff --git a/README.md b/README.md index 7f0c837..ce30842 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `parallel_params` | opt-in via `parallel-params-enabled`: parameters several functions declare alike and hand each other unchanged in one call: one value with no type, passable by halves | | `bool_params` | a crate-private fn with two or more `bool` parameters that a call fills with bare `true`/`false`: `f(x, true, false)` names neither flag, and the swapped call compiles | | `unnamed_tuple` | a private fn's tuple return with two members of one type that every caller destructures under the same names: only the type lacks them, and it accepts them transposed | +| `crossed_alias` | a `DependencyId` local, field or call result landing in a `PackageId` parameter, field, `let` or const, both aliasing one integer: two id kinds rustc erases to one | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/crossed_alias.rs b/src/crossed_alias.rs new file mode 100644 index 0000000..adf30d2 --- /dev/null +++ b/src/crossed_alias.rs @@ -0,0 +1,264 @@ +use clippy_utils::source::snippet; +use rustc_hir::def::{DefKind, Res}; +use rustc_hir::def_id::DefId; +use rustc_hir::{BinOpKind, Body, Expr, ExprKind, LetStmt, Node, Ty as HirTy}; +use rustc_lint::{LateContext, LateLintPass}; +use rustc_middle::ty::{self, Ty}; + +use crate::baseline::emit; +use crate::hir_shapes::{ + Callee, assigned_adt_field, callee_of, declared_ty, field_decl_ty, param_decl_ty, + return_decl_ty, value_expr, written_alias, +}; + +rustc_session::declare_lint! { + /// Flags a value declared under one integer type alias arriving at a + /// place declared under another: a `DependencyId` local passed as the + /// `PackageId` parameter, stored in a `PackageId` field, bound by + /// `let p: PackageId = ..`, returned from a `-> PackageId` function, or + /// defining a `PackageId` const, where both aliases name the same + /// primitive integer. Two aliases over one integer exist to tell two + /// kinds of number apart, and rustc erases both, so nothing rejects the + /// crossing; a newtype per kind would. The kinds are read off the + /// written types of this crate's locals, parameters, fields, consts and + /// signatures, so the lint stays quiet when either side has no alias (a + /// literal, a plain `u32`, arithmetic between two values), when one + /// alias is declared as the other, through `as` casts, on aliases that + /// bottom out in `core`/`std`/`libc` (representation, not identity), and + /// on declarations in other crates, whose written types it cannot see. + pub CROSSED_ALIAS, + Warn, + "a value declared as one integer alias flowing into a place declared as another" +} + +rustc_session::declare_lint_pass!(CrossedAlias => [CROSSED_ALIAS]); + +/// An integer alias as written at a slot, and the alias it bottoms out in. +#[derive(Clone, Copy)] +struct Kind<'tcx> { + written: DefId, + root: DefId, + int: Ty<'tcx>, +} + +/// `type A = B;` names `B`'s kind; follow the chain while it stays visible. +fn alias_root(cx: &LateContext<'_>, mut did: DefId) -> DefId { + for _ in 0..8 { + let Some(next) = did + .as_local() + .and_then(|local| cx.tcx.hir_node_by_def_id(local).alias_ty()) + .and_then(written_alias) + else { + break; + }; + did = next; + } + did +} + +fn is_representation_crate(cx: &LateContext<'_>, did: DefId) -> bool { + matches!( + cx.tcx.crate_name(did.krate).as_str(), + "core" | "std" | "alloc" | "libc" + ) +} + +/// The identity a written type claims: an alias, generic-free, whose chain +/// ends at a primitive integer without passing through a std crate. +fn kind_of<'tcx>(cx: &LateContext<'tcx>, ty: &HirTy<'_>) -> Option> { + let written = written_alias(ty)?; + let root = alias_root(cx, written); + if is_representation_crate(cx, written) || is_representation_crate(cx, root) { + return None; + } + let int = cx + .tcx + .type_of(root) + .instantiate_identity() + .skip_normalization(); + matches!(int.kind(), ty::Int(_) | ty::Uint(_)).then_some(Kind { written, root, int }) +} + +fn is_lit(e: &Expr<'_>) -> bool { + matches!(value_expr(e).kind, ExprKind::Lit(_)) +} + +/// The kind the value was declared with. An id offset by a literal +/// (`INVALID - 1`, `id + 1`) is still that kind of id; anything else +/// computed is no kind at all. +fn source_kind<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option> { + let e = value_expr(e); + if let ExprKind::Binary(op, l, r) = e.kind { + if !matches!(op.node, BinOpKind::Add | BinOpKind::Sub) { + return None; + } + return match (is_lit(l), is_lit(r)) { + (false, true) => source_kind(cx, l), + (true, false) => source_kind(cx, r), + _ => None, + }; + } + kind_of(cx, declared_ty(cx, e)?) +} + +/// Where the value lands, worded for the message. +enum Slot { + Param { callee: DefId, idx: usize }, + Field(DefId), + Local(String), + Return(DefId), + Const(DefId), +} + +fn def_name(cx: &LateContext<'_>, did: DefId) -> String { + cx.tcx + .opt_item_name(did) + .map_or_else(|| "this closure".to_owned(), |s| s.to_string()) +} + +fn check_edge<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, dest: &HirTy<'_>, slot: Slot) { + let Some(to) = kind_of(cx, dest) else { + return; + }; + let Some(from) = source_kind(cx, src) else { + return; + }; + if from.root == to.root || from.int != to.int { + return; + } + let to_name = def_name(cx, to.written); + let lands = match slot { + Slot::Param { callee, idx } => { + let param = cx + .tcx + .fn_arg_idents(callee) + .get(idx) + .copied() + .flatten() + .map_or_else(|| format!("#{idx}"), |i| i.to_string()); + format!( + "is passed as `{to_name}` parameter `{param}` of `{}`", + def_name(cx, callee) + ) + } + Slot::Field(f) => format!("is stored in `{to_name}` field `{}`", def_name(cx, f)), + Slot::Local(pat) => format!("is bound to `{pat}: {to_name}`"), + Slot::Return(f) => format!("is returned from `{}` as `{to_name}`", def_name(cx, f)), + Slot::Const(c) => format!("defines `{to_name}` const `{}`", def_name(cx, c)), + }; + let src = value_expr(src); + emit( + cx, + CROSSED_ALIAS, + src.span, + format!( + "`{}` is declared `{}` but {lands}; both are `{}`, so nothing rejects the crossing", + snippet(cx, src.span, ".."), + def_name(cx, from.written), + to.int, + ), + "a newtype per id kind makes this a type error", + ); +} + +impl<'tcx> LateLintPass<'tcx> for CrossedAlias { + fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { + match expr.kind { + ExprKind::Call(..) | ExprKind::MethodCall(..) => { + let (def, first, args) = match callee_of(cx, expr) { + Some(Callee::Path { def, args }) => (def, 0, args), + Some(Callee::Method { def, args, .. }) => (def, 1, args), + None => return, + }; + if !matches!(cx.tcx.def_kind(def), DefKind::Fn | DefKind::AssocFn) { + return; + } + for (i, arg) in args.iter().enumerate() { + let idx = first + i; + if let Some(dest) = param_decl_ty(cx, def, idx) { + check_edge(cx, arg, dest, Slot::Param { callee: def, idx }); + } + } + } + ExprKind::Struct(qpath, fields, _) => { + let Some(adt) = cx.typeck_results().expr_ty(expr).ty_adt_def() else { + return; + }; + let res = cx.qpath_res(qpath, expr.hir_id); + if !matches!( + res, + Res::Def( + DefKind::Struct + | DefKind::Union + | DefKind::Variant + | DefKind::TyAlias + | DefKind::AssocTy + | DefKind::Ctor(..), + _ + ) | Res::SelfTyAlias { .. } + | Res::SelfTyParam { .. } + | Res::SelfCtor(_) + ) { + return; + } + let variant = adt.variant_of_res(res); + for field in fields { + let idx = cx.typeck_results().field_index(field.hir_id); + let def = variant.fields[idx].did; + if let Some(dest) = field_decl_ty(cx, def) { + check_edge(cx, field.expr, dest, Slot::Field(def)); + } + } + } + ExprKind::Assign(place, value, _) => { + let Some((adt, ident, _)) = assigned_adt_field(cx, place) else { + return; + }; + if adt.is_enum() { + return; + } + let Some(field) = crate::adt_facts::struct_field(adt, ident.name) else { + return; + }; + if let Some(dest) = field_decl_ty(cx, field.did) { + check_edge(cx, value, dest, Slot::Field(field.did)); + } + } + ExprKind::Ret(Some(value)) => { + let owner = cx.tcx.hir_enclosing_body_owner(expr.hir_id).to_def_id(); + if let Some(dest) = return_decl_ty(cx, owner) { + check_edge(cx, value, dest, Slot::Return(owner)); + } + } + _ => {} + } + } + + fn check_local(&mut self, cx: &LateContext<'tcx>, local: &'tcx LetStmt<'tcx>) { + if let (Some(dest), Some(init)) = (local.ty, local.init) { + let pat = snippet(cx, local.pat.span, "..").into_owned(); + check_edge(cx, init, dest, Slot::Local(pat)); + } + } + + /// The body's value against its owner's written type: a function's tail + /// against its return type, a const or static's initializer against its + /// declaration. + fn check_body(&mut self, cx: &LateContext<'tcx>, body: &Body<'tcx>) { + let owner = cx.tcx.hir_body_owner_def_id(body.id()); + let node = cx.tcx.hir_node_by_def_id(owner); + if node.fn_decl().is_some() { + if let Some(dest) = return_decl_ty(cx, owner.to_def_id()) { + check_edge(cx, body.value, dest, Slot::Return(owner.to_def_id())); + } + } else if matches!(node, Node::Item(_) | Node::ImplItem(_) | Node::TraitItem(_)) + && matches!( + cx.tcx.def_kind(owner), + DefKind::Const { .. } | DefKind::Static { .. } | DefKind::AssocConst { .. } + ) + && let Some(dest) = node.ty() + { + check_edge(cx, body.value, dest, Slot::Const(owner.to_def_id())); + } + } +} diff --git a/src/hir_shapes.rs b/src/hir_shapes.rs index 35ba397..f4acae8 100644 --- a/src/hir_shapes.rs +++ b/src/hir_shapes.rs @@ -4,8 +4,11 @@ //! lint applies its own filters on top; what lives here is only the part they //! spelled identically. +use rustc_hir::def::{DefKind, Res}; use rustc_hir::def_id::DefId; -use rustc_hir::{Block, Expr, ExprKind, QPath, Stmt, StmtKind, UnOp}; +use rustc_hir::{ + Block, Expr, ExprKind, FnRetTy, HirId, Node, QPath, Stmt, StmtKind, Ty as HirTy, TyKind, UnOp, +}; use rustc_lint::LateContext; use rustc_middle::ty::AdtDef; use rustc_span::symbol::kw; @@ -318,3 +321,128 @@ pub(crate) fn indexed_field<'h>(e: &'h Expr<'h>) -> Option> { let (base, field, _) = assigned_field(place)?; Some(IndexedField { base, field, index }) } + +/// The expression whose value `e` carries, through `&`, `*`, tail-only +/// blocks and HIR temporaries: the layers that move or borrow a value +/// without computing a new one. +pub(crate) fn value_expr<'h>(mut e: &'h Expr<'h>) -> &'h Expr<'h> { + loop { + let peeled = peel_blocks_unsafe(e); + match peeled.kind { + ExprKind::AddrOf(_, _, inner) | ExprKind::Unary(UnOp::Deref, inner) => e = inner, + _ => return peeled, + } + } +} + +/// The type alias a written type names, through `&`/`&mut`: `A` and `&A` +/// for `type A = ..`. None for anything that is not a plain path to an alias. +pub(crate) fn written_alias(mut ty: &HirTy<'_>) -> Option { + while let TyKind::Ref(_, inner) = ty.kind { + ty = inner.ty; + } + match ty.kind { + TyKind::Path(QPath::Resolved(None, path)) => match path.res { + Res::Def(DefKind::TyAlias, did) => Some(did), + _ => None, + }, + _ => None, + } +} + +/// The type as written on a struct, union or variant field's declaration; +/// None for fields declared in another crate. +pub(crate) fn field_decl_ty<'tcx>( + cx: &LateContext<'tcx>, + field: DefId, +) -> Option<&'tcx HirTy<'tcx>> { + match cx.tcx.hir_node_by_def_id(field.as_local()?) { + Node::Field(f) => Some(f.ty), + _ => None, + } +} + +/// The type as written on parameter `idx` of a fn, method or closure (for a +/// method, 0 is the receiver); None for definitions in another crate. +pub(crate) fn param_decl_ty<'tcx>( + cx: &LateContext<'tcx>, + def: DefId, + idx: usize, +) -> Option<&'tcx HirTy<'tcx>> { + cx.tcx + .hir_node_by_def_id(def.as_local()?) + .fn_decl()? + .inputs + .get(idx) +} + +/// The return type as written on a fn, method or closure; None when it is +/// left off or the definition is in another crate. +pub(crate) fn return_decl_ty<'tcx>( + cx: &LateContext<'tcx>, + def: DefId, +) -> Option<&'tcx HirTy<'tcx>> { + match cx.tcx.hir_node_by_def_id(def.as_local()?).fn_decl()?.output { + FnRetTy::Return(ty) => Some(ty), + FnRetTy::DefaultReturn(_) => None, + } +} + +/// The type as written where a local binding is introduced: its `let` +/// annotation or its parameter's type. None when the binding sits inside a +/// larger pattern, whose written type (if any) is the whole pattern's. +pub(crate) fn local_decl_ty<'tcx>( + cx: &LateContext<'tcx>, + binding: HirId, +) -> Option<&'tcx HirTy<'tcx>> { + match cx.tcx.parent_hir_node(binding) { + Node::LetStmt(l) => l.ty, + Node::Param(p) => { + let owner = cx.tcx.hir_enclosing_body_owner(p.hir_id); + let idx = cx + .tcx + .hir_maybe_body_owned_by(owner)? + .params + .iter() + .position(|q| q.hir_id == p.hir_id)?; + param_decl_ty(cx, owner.to_def_id(), idx) + } + _ => None, + } +} + +/// The type as written at the declaration of the place or value `e` names: +/// a local's annotation or parameter type, a field's declared type, a const +/// or static's item type, a call's declared return type. None for anything +/// computed, inferred, or declared in another crate. +pub(crate) fn declared_ty<'tcx>( + cx: &LateContext<'tcx>, + e: &Expr<'tcx>, +) -> Option<&'tcx HirTy<'tcx>> { + match e.kind { + ExprKind::Path(ref qpath) => match cx.qpath_res(qpath, e.hir_id) { + Res::Local(binding) => local_decl_ty(cx, binding), + Res::Def( + DefKind::Const { .. } | DefKind::Static { .. } | DefKind::AssocConst { .. }, + did, + ) => cx.tcx.hir_node_by_def_id(did.as_local()?).ty(), + _ => None, + }, + ExprKind::Field(base, _) => { + let adt = cx + .typeck_results() + .expr_ty_adjusted(base) + .peel_refs() + .ty_adt_def()?; + if adt.is_enum() { + return None; + } + let idx = cx.typeck_results().opt_field_index(e.hir_id)?; + field_decl_ty(cx, adt.non_enum_variant().fields[idx].did) + } + ExprKind::Call(..) | ExprKind::MethodCall(..) => { + return_decl_ty(cx, callee_of(cx, e)?.def()) + } + _ => None, + } +} diff --git a/src/lib.rs b/src/lib.rs index 36cee0d..61d7f33 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ mod bypassed_conversion; mod bypassed_validator; mod claims; mod collapsed_error; +mod crossed_alias; mod crossed_index; mod ctor_flow; mod defaulted_failure; @@ -179,7 +180,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto use { asymmetric_guard::AsymmetricGuard, baseline::BaselineWriter, bool_params::BoolParams, bypassed_conversion::BypassedConversion, bypassed_validator::BypassedValidator, - collapsed_error::CollapsedError, crossed_index::CrossedIndex, + collapsed_error::CollapsedError, crossed_alias::CrossedAlias, crossed_index::CrossedIndex, defaulted_failure::DefaultedFailure, dependent_field::DependentField, discarded_error::DiscardedError, exclusive_options::ExclusiveOptions, flag_cluster::FlagCluster, forbidden_reach::ForbiddenReach, guard_flag::GuardFlag, @@ -249,6 +250,7 @@ pub fn register_lints(sess: &rustc_session::Session, s: &mut rustc_lint::LintSto }); add(s, true, BoolParams::default); add(s, true, UnnamedTuple::default); + add(s, true, || CrossedAlias); // Last, so its check_crate_post flushes after every lint has recorded. add(s, true, || BaselineWriter); } diff --git a/ui/crossed_alias.rs b/ui/crossed_alias.rs new file mode 100644 index 0000000..ea1ac1a --- /dev/null +++ b/ui/crossed_alias.rs @@ -0,0 +1,101 @@ +// A value declared under one integer alias must not flow into a place declared under another. + +pub type PackageId = u32; +pub type DependencyId = u32; +// An alias of an alias names the same kind, not a new one. +pub type PkgId = PackageId; +pub type NameHash = u64; + +pub const INVALID_PACKAGE: PackageId = PackageId::MAX; +// Flagged: a dependency sentinel derived from the package sentinel. +pub const ROOT_DEPENDENCY: DependencyId = INVALID_PACKAGE - 1; +// Fine: same alias. +pub const LAST_PACKAGE: PackageId = INVALID_PACKAGE - 1; + +pub struct Resolution { + pub package: PackageId, + pub dependency: DependencyId, + pub name_hash: NameHash, + pub count: u32, +} + +pub fn link(package: PackageId, dependency: DependencyId) -> u32 { + package ^ dependency +} + +pub fn first_dependency(r: &Resolution) -> DependencyId { + r.dependency +} + +pub fn swapped_call_is_flagged(pkg: PackageId, dep: DependencyId) -> u32 { + // Flagged: both arguments cross. + link(dep, pkg) +} + +pub fn struct_literal_is_flagged(pkg: PackageId, dep: DependencyId) -> Resolution { + Resolution { + // Flagged: the field is a package id, the value a dependency id. + package: dep, + // Fine: same alias. + dependency: dep, + // Fine: a `u32` alias into a `u64` alias cannot compile without a cast. + name_hash: 0, + // Fine: the field has no alias. + count: pkg, + } +} + +pub fn assignment_is_flagged(r: &mut Resolution, other: &Resolution) { + // Flagged: a field declared `DependencyId` written into one declared `PackageId`. + r.package = other.dependency; + // Fine: same field kind. + r.dependency = other.dependency; +} + +pub fn let_annotation_is_flagged(r: &Resolution) -> PackageId { + // Flagged: the call returns `DependencyId`. + let pkg: PackageId = first_dependency(r); + pkg +} + +pub fn return_is_flagged(dep: DependencyId) -> PackageId { + // Flagged: the tail is declared `DependencyId`, the signature `PackageId`. + dep +} + +pub fn explicit_return_is_flagged(r: &Resolution, early: bool) -> PackageId { + if early { + // Flagged. + return r.dependency; + } + r.package +} + +pub fn same_alias_is_fine(a: PackageId, r: &Resolution, dep: DependencyId) -> u32 { + let b: PackageId = a; + link(b, dep) + link(r.package, r.dependency) +} + +pub fn alias_of_alias_is_fine(p: PkgId, dep: DependencyId) -> PackageId { + let _ = link(p, dep); + p +} + +pub fn literal_and_arithmetic_are_fine(pkg: PackageId, dep: DependencyId) -> u32 { + let _ = link(0, 1); + // Arithmetic between two ids computes a number, not an id of either kind. + link(pkg + dep, dep - pkg) +} + +pub fn cast_is_fine(dep: DependencyId) -> PackageId { + let _ = link(dep as PackageId, dep); + dep as u32 +} + +pub fn plain_u32_is_fine(n: u32, r: &Resolution) -> PackageId { + let _ = link(n, r.count); + let m: u32 = r.dependency; + m +} + +fn main() {} diff --git a/ui/crossed_alias.stderr b/ui/crossed_alias.stderr new file mode 100644 index 0000000..645955a --- /dev/null +++ b/ui/crossed_alias.stderr @@ -0,0 +1,67 @@ +warning: `INVALID_PACKAGE - 1` is declared `PackageId` but defines `DependencyId` const `ROOT_DEPENDENCY`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:11:43 + | +LL | pub const ROOT_DEPENDENCY: DependencyId = INVALID_PACKAGE - 1; + | ^^^^^^^^^^^^^^^^^^^ + | + = help: a newtype per id kind makes this a type error + = note: `#[warn(crossed_alias)]` on by default + +warning: `dep` is declared `DependencyId` but is passed as `PackageId` parameter `package` of `link`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:32:10 + | +LL | link(dep, pkg) + | ^^^ + | + = help: a newtype per id kind makes this a type error + +warning: `pkg` is declared `PackageId` but is passed as `DependencyId` parameter `dependency` of `link`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:32:15 + | +LL | link(dep, pkg) + | ^^^ + | + = help: a newtype per id kind makes this a type error + +warning: `dep` is declared `DependencyId` but is stored in `PackageId` field `package`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:38:18 + | +LL | package: dep, + | ^^^ + | + = help: a newtype per id kind makes this a type error + +warning: `other.dependency` is declared `DependencyId` but is stored in `PackageId` field `package`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:50:17 + | +LL | r.package = other.dependency; + | ^^^^^^^^^^^^^^^^ + | + = help: a newtype per id kind makes this a type error + +warning: `first_dependency(r)` is declared `DependencyId` but is bound to `pkg: PackageId`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:57:26 + | +LL | let pkg: PackageId = first_dependency(r); + | ^^^^^^^^^^^^^^^^^^^ + | + = help: a newtype per id kind makes this a type error + +warning: `dep` is declared `DependencyId` but is returned from `return_is_flagged` as `PackageId`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:63:5 + | +LL | dep + | ^^^ + | + = help: a newtype per id kind makes this a type error + +warning: `r.dependency` is declared `DependencyId` but is returned from `explicit_return_is_flagged` as `PackageId`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:69:16 + | +LL | return r.dependency; + | ^^^^^^^^^^^^ + | + = help: a newtype per id kind makes this a type error + +warning: 8 warnings emitted + From c711fbaa2c4eb36c1d29d8c9fb209b559340de28 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 19:35:54 -0700 Subject: [PATCH 33/34] crossed_alias reads comparisons, skips cfg-selected aliases --- README.md | 2 +- src/adt_facts.rs | 16 +++++++++- src/crossed_alias.rs | 71 ++++++++++++++++++++++++++++++----------- ui/crossed_alias.rs | 23 +++++++++++++ ui/crossed_alias.stderr | 10 +++++- 5 files changed, 100 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index ce30842..d0e8fd0 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Mordant will not find every defect, but what it reports is real: a lint that can | `parallel_params` | opt-in via `parallel-params-enabled`: parameters several functions declare alike and hand each other unchanged in one call: one value with no type, passable by halves | | `bool_params` | a crate-private fn with two or more `bool` parameters that a call fills with bare `true`/`false`: `f(x, true, false)` names neither flag, and the swapped call compiles | | `unnamed_tuple` | a private fn's tuple return with two members of one type that every caller destructures under the same names: only the type lacks them, and it accepts them transposed | -| `crossed_alias` | a `DependencyId` local, field or call result landing in a `PackageId` parameter, field, `let` or const, both aliasing one integer: two id kinds rustc erases to one | +| `crossed_alias` | a `DependencyId` value passed, stored, bound, returned or compared where a `PackageId` is declared, both aliasing one integer: two id kinds only the aliases tell apart | Each diagnostic states what the lint found, why the type is wrong, and the type that replaces it. diff --git a/src/adt_facts.rs b/src/adt_facts.rs index d387259..b5ebc37 100644 --- a/src/adt_facts.rs +++ b/src/adt_facts.rs @@ -5,9 +5,9 @@ //! lint fire or not -- privacy, `is_struct`, a minimum field count -- stays in //! the lint, so this module only ever answers, never decides. -use rustc_hir::HirId; use rustc_hir::def::DefKind; use rustc_hir::def_id::DefId; +use rustc_hir::{HirId, find_attr}; use rustc_lint::LateContext; use rustc_middle::ty::{self, AdtDef, FieldDef, Ty, TyCtxt, VariantDef}; use rustc_span::{Symbol, sym}; @@ -150,3 +150,17 @@ pub(crate) fn inside_own_trait_impl(cx: &LateContext<'_>, hir_id: HirId, adt_did } } } + +/// The definition, or a module enclosing it, carries a `#[cfg]`: what it +/// names is chosen per platform or feature rather than fixed by the program. +pub(crate) fn cfg_selected(cx: &LateContext<'_>, mut did: DefId) -> bool { + loop { + if find_attr!(cx.tcx, did, CfgTrace(..)) { + return true; + } + match cx.tcx.opt_parent(did) { + Some(p) => did = p, + None => return false, + } + } +} diff --git a/src/crossed_alias.rs b/src/crossed_alias.rs index adf30d2..63583d9 100644 --- a/src/crossed_alias.rs +++ b/src/crossed_alias.rs @@ -5,6 +5,7 @@ use rustc_hir::{BinOpKind, Body, Expr, ExprKind, LetStmt, Node, Ty as HirTy}; use rustc_lint::{LateContext, LateLintPass}; use rustc_middle::ty::{self, Ty}; +use crate::adt_facts::cfg_selected; use crate::baseline::emit; use crate::hir_shapes::{ Callee, assigned_adt_field, callee_of, declared_ty, field_decl_ty, param_decl_ty, @@ -15,17 +16,19 @@ rustc_session::declare_lint! { /// Flags a value declared under one integer type alias arriving at a /// place declared under another: a `DependencyId` local passed as the /// `PackageId` parameter, stored in a `PackageId` field, bound by - /// `let p: PackageId = ..`, returned from a `-> PackageId` function, or - /// defining a `PackageId` const, where both aliases name the same - /// primitive integer. Two aliases over one integer exist to tell two - /// kinds of number apart, and rustc erases both, so nothing rejects the - /// crossing; a newtype per kind would. The kinds are read off the - /// written types of this crate's locals, parameters, fields, consts and - /// signatures, so the lint stays quiet when either side has no alias (a - /// literal, a plain `u32`, arithmetic between two values), when one - /// alias is declared as the other, through `as` casts, on aliases that - /// bottom out in `core`/`std`/`libc` (representation, not identity), and - /// on declarations in other crates, whose written types it cannot see. + /// `let p: PackageId = ..`, returned from a `-> PackageId` function, + /// defining a `PackageId` const, or compared with a `PackageId` value, + /// where both aliases name the same primitive integer. Two aliases over + /// one integer exist to tell two kinds of number apart, and rustc erases + /// both, so nothing rejects the crossing; a newtype per kind would. The + /// kinds are read off the written types of this crate's locals, + /// parameters, fields, consts and signatures, so the lint stays quiet + /// when either side has no alias (a literal, a plain `u32`, arithmetic + /// between two values), when one alias is declared as the other, through + /// `as` casts, on aliases that bottom out in `core`/`std`/`libc` or are + /// selected by `#[cfg]` (a platform's representation, not an identity), + /// and on declarations in other crates, whose written types it cannot + /// see. pub CROSSED_ALIAS, Warn, "a value declared as one integer alias flowing into a place declared as another" @@ -56,19 +59,22 @@ fn alias_root(cx: &LateContext<'_>, mut did: DefId) -> DefId { did } -fn is_representation_crate(cx: &LateContext<'_>, did: DefId) -> bool { +/// A std or libc alias, or one picked by `#[cfg]`, names how a platform +/// represents the number, not which kind of number it is. +fn is_representation(cx: &LateContext<'_>, did: DefId) -> bool { matches!( cx.tcx.crate_name(did.krate).as_str(), "core" | "std" | "alloc" | "libc" - ) + ) || cfg_selected(cx, did) } /// The identity a written type claims: an alias, generic-free, whose chain -/// ends at a primitive integer without passing through a std crate. +/// ends at a primitive integer without passing through a representation +/// alias. fn kind_of<'tcx>(cx: &LateContext<'tcx>, ty: &HirTy<'_>) -> Option> { let written = written_alias(ty)?; let root = alias_root(cx, written); - if is_representation_crate(cx, written) || is_representation_crate(cx, root) { + if is_representation(cx, written) || is_representation(cx, root) { return None; } let int = cx @@ -103,11 +109,16 @@ fn source_kind<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option, did: DefId) -> String { @@ -116,10 +127,25 @@ fn def_name(cx: &LateContext<'_>, did: DefId) -> String { .map_or_else(|| "this closure".to_owned(), |s| s.to_string()) } +fn is_comparison(op: BinOpKind) -> bool { + matches!( + op, + BinOpKind::Eq + | BinOpKind::Ne + | BinOpKind::Lt + | BinOpKind::Le + | BinOpKind::Gt + | BinOpKind::Ge + ) +} + fn check_edge<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, dest: &HirTy<'_>, slot: Slot) { - let Some(to) = kind_of(cx, dest) else { - return; - }; + if let Some(to) = kind_of(cx, dest) { + check_kinds(cx, src, &to, slot); + } +} + +fn check_kinds<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, to: &Kind<'tcx>, slot: Slot) { let Some(from) = source_kind(cx, src) else { return; }; @@ -145,6 +171,7 @@ fn check_edge<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, dest: &HirTy< Slot::Local(pat) => format!("is bound to `{pat}: {to_name}`"), Slot::Return(f) => format!("is returned from `{}` as `{to_name}`", def_name(cx, f)), Slot::Const(c) => format!("defines `{to_name}` const `{}`", def_name(cx, c)), + Slot::Compared(other) => format!("is compared with `{other}`, declared `{to_name}`"), }; let src = value_expr(src); emit( @@ -164,6 +191,12 @@ fn check_edge<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, dest: &HirTy< impl<'tcx> LateLintPass<'tcx> for CrossedAlias { fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) { match expr.kind { + ExprKind::Binary(op, l, r) if is_comparison(op.node) => { + if let Some(to) = source_kind(cx, r) { + let other = snippet(cx, value_expr(r).span, "..").into_owned(); + check_kinds(cx, l, &to, Slot::Compared(other)); + } + } ExprKind::Call(..) | ExprKind::MethodCall(..) => { let (def, first, args) = match callee_of(cx, expr) { Some(Callee::Path { def, args }) => (def, 0, args), diff --git a/ui/crossed_alias.rs b/ui/crossed_alias.rs index ea1ac1a..e46f1f0 100644 --- a/ui/crossed_alias.rs +++ b/ui/crossed_alias.rs @@ -71,6 +71,29 @@ pub fn explicit_return_is_flagged(r: &Resolution, early: bool) -> PackageId { r.package } +pub fn comparison_is_flagged(r: &Resolution, dep: DependencyId) -> bool { + // Flagged: a dependency id tested against the package sentinel. + if dep == INVALID_PACKAGE { + return false; + } + // Fine: same kind; a literal has no kind. + dep != r.dependency && r.package < LAST_PACKAGE && dep > 3 +} + +// One representation per platform, like `c_int`: not an identity. +#[cfg(not(windows))] +type Backing = u32; +#[cfg(windows)] +type Backing = u64; + +pub struct Handle(Backing); + +impl Handle { + pub fn cfg_selected_alias_is_fine(self) -> PackageId { + self.0 + } +} + pub fn same_alias_is_fine(a: PackageId, r: &Resolution, dep: DependencyId) -> u32 { let b: PackageId = a; link(b, dep) + link(r.package, r.dependency) diff --git a/ui/crossed_alias.stderr b/ui/crossed_alias.stderr index 645955a..9c571b8 100644 --- a/ui/crossed_alias.stderr +++ b/ui/crossed_alias.stderr @@ -63,5 +63,13 @@ LL | return r.dependency; | = help: a newtype per id kind makes this a type error -warning: 8 warnings emitted +warning: `dep` is declared `DependencyId` but is compared with `INVALID_PACKAGE`, declared `PackageId`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:76:8 + | +LL | if dep == INVALID_PACKAGE { + | ^^^ + | + = help: a newtype per id kind makes this a type error + +warning: 9 warnings emitted From 5d966493b40a129fe3b745845c7e6dc23cca47f2 Mon Sep 17 00:00:00 2001 From: Alistair Smith Date: Fri, 14 Aug 2026 19:46:13 -0700 Subject: [PATCH 34/34] crossed_alias sees tails after statements, branches and tuple structs --- src/crossed_alias.rs | 70 ++++++++++++++++++++++++++++++++++++----- src/hir_shapes.rs | 25 +++++++++------ ui/crossed_alias.rs | 21 +++++++++++++ ui/crossed_alias.stderr | 32 ++++++++++++++++--- 4 files changed, 128 insertions(+), 20 deletions(-) diff --git a/src/crossed_alias.rs b/src/crossed_alias.rs index 63583d9..2d6a24e 100644 --- a/src/crossed_alias.rs +++ b/src/crossed_alias.rs @@ -1,9 +1,9 @@ use clippy_utils::source::snippet; -use rustc_hir::def::{DefKind, Res}; +use rustc_hir::def::{CtorKind, DefKind, Res}; use rustc_hir::def_id::DefId; use rustc_hir::{BinOpKind, Body, Expr, ExprKind, LetStmt, Node, Ty as HirTy}; use rustc_lint::{LateContext, LateLintPass}; -use rustc_middle::ty::{self, Ty}; +use rustc_middle::ty::{self, Ty, VariantDef}; use crate::adt_facts::cfg_selected; use crate::baseline::emit; @@ -107,6 +107,49 @@ fn source_kind<'tcx>(cx: &LateContext<'tcx>, e: &'tcx Expr<'tcx>) -> Option(e: &'tcx Expr<'tcx>, f: &mut impl FnMut(&'tcx Expr<'tcx>)) { + let e = value_expr(e); + match e.kind { + ExprKind::If(_, then, other) => { + each_value(then, f); + if let Some(other) = other { + each_value(other, f); + } + } + ExprKind::Match(_, arms, _) => { + for arm in arms { + each_value(arm.body, f); + } + } + _ => f(e), + } +} + +/// The variant a tuple-struct or tuple-variant call `P(..)` / `Self(..)` +/// constructs, with its positional arguments. +fn constructed_variant<'tcx>( + cx: &LateContext<'tcx>, + expr: &Expr<'tcx>, +) -> Option<(&'tcx VariantDef, &'tcx [Expr<'tcx>])> { + let ExprKind::Call(callee, args) = expr.kind else { + return None; + }; + let ExprKind::Path(qpath) = &callee.kind else { + return None; + }; + let res = cx.qpath_res(qpath, callee.hir_id); + if !matches!( + res, + Res::Def(DefKind::Ctor(_, CtorKind::Fn), _) | Res::SelfCtor(_) + ) { + return None; + } + let adt = cx.typeck_results().expr_ty(expr).ty_adt_def()?; + Some((adt.variant_of_res(res), args)) +} + /// Where the value lands, worded for the message. enum Slot { Param { @@ -145,7 +188,13 @@ fn check_edge<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, dest: &HirTy< } } +/// `src` against the kind of the place it lands in, one check per value it +/// may evaluate to. fn check_kinds<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, to: &Kind<'tcx>, slot: Slot) { + each_value(src, &mut |value| check_value(cx, value, to, &slot)); +} + +fn check_value<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, to: &Kind<'tcx>, slot: &Slot) { let Some(from) = source_kind(cx, src) else { return; }; @@ -154,7 +203,7 @@ fn check_kinds<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, to: &Kind<'t } let to_name = def_name(cx, to.written); let lands = match slot { - Slot::Param { callee, idx } => { + &Slot::Param { callee, idx } => { let param = cx .tcx .fn_arg_idents(callee) @@ -167,13 +216,12 @@ fn check_kinds<'tcx>(cx: &LateContext<'tcx>, src: &'tcx Expr<'tcx>, to: &Kind<'t def_name(cx, callee) ) } - Slot::Field(f) => format!("is stored in `{to_name}` field `{}`", def_name(cx, f)), + &Slot::Field(f) => format!("is stored in `{to_name}` field `{}`", def_name(cx, f)), Slot::Local(pat) => format!("is bound to `{pat}: {to_name}`"), - Slot::Return(f) => format!("is returned from `{}` as `{to_name}`", def_name(cx, f)), - Slot::Const(c) => format!("defines `{to_name}` const `{}`", def_name(cx, c)), + &Slot::Return(f) => format!("is returned from `{}` as `{to_name}`", def_name(cx, f)), + &Slot::Const(c) => format!("defines `{to_name}` const `{}`", def_name(cx, c)), Slot::Compared(other) => format!("is compared with `{other}`, declared `{to_name}`"), }; - let src = value_expr(src); emit( cx, CROSSED_ALIAS, @@ -198,6 +246,14 @@ impl<'tcx> LateLintPass<'tcx> for CrossedAlias { } } ExprKind::Call(..) | ExprKind::MethodCall(..) => { + if let Some((variant, args)) = constructed_variant(cx, expr) { + for (field, arg) in variant.fields.iter().zip(args) { + if let Some(dest) = field_decl_ty(cx, field.did) { + check_edge(cx, arg, dest, Slot::Field(field.did)); + } + } + return; + } let (def, first, args) = match callee_of(cx, expr) { Some(Callee::Path { def, args }) => (def, 0, args), Some(Callee::Method { def, args, .. }) => (def, 1, args), diff --git a/src/hir_shapes.rs b/src/hir_shapes.rs index f4acae8..25937f9 100644 --- a/src/hir_shapes.rs +++ b/src/hir_shapes.rs @@ -322,17 +322,24 @@ pub(crate) fn indexed_field<'h>(e: &'h Expr<'h>) -> Option> { Some(IndexedField { base, field, index }) } -/// The expression whose value `e` carries, through `&`, `*`, tail-only -/// blocks and HIR temporaries: the layers that move or borrow a value -/// without computing a new one. +/// The expression whose value `e` carries, through `&`, `*`, HIR +/// temporaries and unlabeled blocks down to their tail (whatever statements +/// precede it): the layers that move or borrow a value without computing a +/// new one. pub(crate) fn value_expr<'h>(mut e: &'h Expr<'h>) -> &'h Expr<'h> { - loop { - let peeled = peel_blocks_unsafe(e); - match peeled.kind { - ExprKind::AddrOf(_, _, inner) | ExprKind::Unary(UnOp::Deref, inner) => e = inner, - _ => return peeled, - } + while let ExprKind::AddrOf(_, _, inner) + | ExprKind::Unary(UnOp::Deref, inner) + | ExprKind::DropTemps(inner) + | ExprKind::Block( + &Block { + expr: Some(inner), .. + }, + None, + ) = e.kind + { + e = inner; } + e } /// The type alias a written type names, through `&`/`&mut`: `A` and `&A` diff --git a/ui/crossed_alias.rs b/ui/crossed_alias.rs index e46f1f0..160516d 100644 --- a/ui/crossed_alias.rs +++ b/ui/crossed_alias.rs @@ -60,9 +60,22 @@ pub fn let_annotation_is_flagged(r: &Resolution) -> PackageId { pub fn return_is_flagged(dep: DependencyId) -> PackageId { // Flagged: the tail is declared `DependencyId`, the signature `PackageId`. + let _seen = dep; dep } +pub fn branch_tail_is_flagged(r: &Resolution, pick: u8) -> PackageId { + // Flagged: each branch is a value the function may return; two cross. + if pick == 0 { + r.dependency + } else { + match pick { + 1 => r.package, + _ => first_dependency(r), + } + } +} + pub fn explicit_return_is_flagged(r: &Resolution, early: bool) -> PackageId { if early { // Flagged. @@ -71,6 +84,14 @@ pub fn explicit_return_is_flagged(r: &Resolution, early: bool) -> PackageId { r.package } +pub struct Pinned(pub PackageId, pub u32); + +pub fn tuple_constructor_is_flagged(pkg: PackageId, dep: DependencyId) -> (Pinned, Pinned) { + // Flagged: the first positional field is declared `PackageId`. Fine: the + // second has no alias, and `pkg` matches. + (Pinned(dep, dep), Pinned(pkg, dep)) +} + pub fn comparison_is_flagged(r: &Resolution, dep: DependencyId) -> bool { // Flagged: a dependency id tested against the package sentinel. if dep == INVALID_PACKAGE { diff --git a/ui/crossed_alias.stderr b/ui/crossed_alias.stderr index 9c571b8..b210c2c 100644 --- a/ui/crossed_alias.stderr +++ b/ui/crossed_alias.stderr @@ -48,28 +48,52 @@ LL | let pkg: PackageId = first_dependency(r); = help: a newtype per id kind makes this a type error warning: `dep` is declared `DependencyId` but is returned from `return_is_flagged` as `PackageId`; both are `u32`, so nothing rejects the crossing - --> $DIR/crossed_alias.rs:63:5 + --> $DIR/crossed_alias.rs:64:5 | LL | dep | ^^^ | = help: a newtype per id kind makes this a type error +warning: `r.dependency` is declared `DependencyId` but is returned from `branch_tail_is_flagged` as `PackageId`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:70:9 + | +LL | r.dependency + | ^^^^^^^^^^^^ + | + = help: a newtype per id kind makes this a type error + +warning: `first_dependency(r)` is declared `DependencyId` but is returned from `branch_tail_is_flagged` as `PackageId`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:74:18 + | +LL | _ => first_dependency(r), + | ^^^^^^^^^^^^^^^^^^^ + | + = help: a newtype per id kind makes this a type error + warning: `r.dependency` is declared `DependencyId` but is returned from `explicit_return_is_flagged` as `PackageId`; both are `u32`, so nothing rejects the crossing - --> $DIR/crossed_alias.rs:69:16 + --> $DIR/crossed_alias.rs:82:16 | LL | return r.dependency; | ^^^^^^^^^^^^ | = help: a newtype per id kind makes this a type error +warning: `dep` is declared `DependencyId` but is stored in `PackageId` field `0`; both are `u32`, so nothing rejects the crossing + --> $DIR/crossed_alias.rs:92:13 + | +LL | (Pinned(dep, dep), Pinned(pkg, dep)) + | ^^^ + | + = help: a newtype per id kind makes this a type error + warning: `dep` is declared `DependencyId` but is compared with `INVALID_PACKAGE`, declared `PackageId`; both are `u32`, so nothing rejects the crossing - --> $DIR/crossed_alias.rs:76:8 + --> $DIR/crossed_alias.rs:97:8 | LL | if dep == INVALID_PACKAGE { | ^^^ | = help: a newtype per id kind makes this a type error -warning: 9 warnings emitted +warning: 12 warnings emitted