Files
zed/tooling/lints/src/map_lookup_then_insert.rs
T
Miguel Raz Guzmán Macedo ca2b5741bc lints: Add map_lookup_then_insert lint (#62681)
## Objective

Sometimes we can do a hash lookup twice, which is slow and unidiomatic.
We should use the Entry API instead.

## Solution

Add another `dylint` lint called `map_lookup_then_insert` that catches
those cases.

This works with HashMap, BTreeMap, HashSet, BTreeSet and
entry-compatible ADTs.

## What it catches

See the examples in the `tooling/lints/ui/map_lookup_then_insert.rs`.

It's mostly things of the form

```rust
if let Some(value) = map.get(&key) {
    *value
} else {
    map.insert(key, 1);
    1
}
```

which should be 

```rust
*map.entry(key).or_insert(1)
```

The two key expressions are matched up to borrows/derefs,
value-preserving
conversions (`clone`, `to_owned`, `to_string` on string types, `to_vec`
on
slices, `String::from` on `&str` — each resolved by definition, not by
name),
and immutable local aliases. So looking up `&key` and inserting
`key.clone()`
is caught too. When a conversion had to be peeled, the diagnostic
carries a
note that an entry-API rewrite would perform that copy on hits as well
as
misses.

## What it deliberately does not catch

Forms Clippy already catches.

## Findings

A `--workspace` run finds 31 warnings at 30 unique sites in `zed`:
the 16 the plain key matching finds, plus 15 more that require the
conversion/alias matching (e.g. `tiles_by_key.get(key)` followed by
`tiles_by_key.insert(key.clone(), tile)` in `metal_atlas.rs`).

Will post those fixes in a follow up PR.

## Testing

UI tests cover every `Entry` method positively and negatively, BTreeMap
mirrors, set cases, and the Clippy-deferral boundary. The
key-normalization logic is covered by nine positive cases (cloned keys,
UFCS forms, `to_owned`/`to_string`/`to_vec`/`String::from`, deref
layers, local aliases, sets) and six negative cases (inherent methods
merely named `clone`/`to_owned`, mutated aliases, a clone of a different
key, `to_string` on a non-string key, and side-effecting initializers).

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable


Release Notes:

- N/A
2026-08-28 20:01:28 +00:00

451 lines
17 KiB
Rust

use clippy_utils::diagnostics::span_lint_and_then;
use clippy_utils::visitors::{Visitable, for_each_expr_without_closures};
use clippy_utils::{SpanlessEq, expr_or_init, sym};
use rustc_hir::def_id::DefId;
use rustc_hir::{Expr, ExprKind, LangItem, LetStmt, MatchSource, UnOp};
use rustc_lint::{LateContext, LateLintPass};
use rustc_middle::ty::Ty;
use rustc_span::Symbol;
use std::ops::ControlFlow;
rustc_session::declare_lint! {
/// ### What it does
///
/// Flags control flow that branches on map value lookup methods and then
/// inserts into the same map with the same key.
///
/// The lint applies to any map type with an inherent `entry` method: std's
/// `HashMap` and `BTreeMap`, hasher aliases such as `FxHashMap`, and
/// third-party maps such as `hashbrown::HashMap` or `indexmap::IndexMap`.
///
/// The lint covers direct `match`, `if let`, `is_some`/`is_none`, and
/// `let ... else` forms using `get`, `get_mut`, or `get_key_value`.
///
/// Keys are compared after stripping borrows and derefs, peeling
/// conversions that yield an owned key equal to the original (`clone`,
/// `to_owned`, `to_string` on string types, `to_vec` on slices, and
/// `String::from` on `&str`), and resolving immutable local aliases to
/// their initializers. So looking up `&key` and inserting `key.clone()`
/// is recognized as the same key. Each peeled conversion is resolved by
/// definition, not by name, so an unrelated user method that happens to
/// be called `clone` does not count.
///
/// When such a conversion is peeled, the diagnostic carries a note: the
/// entry API takes the key by value up front, so a conversion the
/// original code performs only on the miss path would run on every call.
///
/// The same pattern on `HashSet` and `BTreeSet` is also flagged. Sets have
/// no stable entry API, and need none: `insert` alone searches once, skips
/// duplicates, and returns `false` when the value was already present.
///
/// Forms that Clippy already rewrites are excluded. `contains_key` or
/// `contains` followed by `insert` is `map_entry` or
/// `set_contains_or_insert`, and `get(..).is_some()`/`is_none()` on the
/// std collections is `unnecessary_get_then_check`, whose `contains_key`/
/// `contains` suggestion feeds the former two.
///
/// ### Why is this bad?
///
/// The lookup searches the map once and `insert` searches it again. The
/// map's entry API can reuse the result of one search.
pub MAP_LOOKUP_THEN_INSERT,
Warn,
"a map value lookup is followed by insertion for the same key"
}
pub(crate) struct MapLookupThenInsert;
rustc_session::impl_lint_pass!(MapLookupThenInsert => [MAP_LOOKUP_THEN_INSERT]);
impl<'tcx> LateLintPass<'tcx> for MapLookupThenInsert {
fn check_expr(&mut self, cx: &LateContext<'tcx>, expr: &'tcx Expr<'tcx>) {
if expr.span.from_expansion() {
return;
}
let repeated_lookup = match &expr.kind {
ExprKind::If(condition, then_expression, else_expression) => {
lookup_from_condition(cx, condition).and_then(|lookup| {
find_matching_insert(cx, lookup, *then_expression)
.or_else(|| {
else_expression
.and_then(|expression| find_matching_insert(cx, lookup, expression))
})
.map(|key_converted| (lookup, key_converted))
})
}
ExprKind::Match(scrutinee, arms, MatchSource::Normal) => parse_lookup(cx, scrutinee)
.and_then(|lookup| {
arms.iter()
.find_map(|arm| find_matching_insert(cx, lookup, arm.body))
.map(|key_converted| (lookup, key_converted))
}),
_ => None,
};
if let Some((lookup, key_converted)) = repeated_lookup {
emit_lint(cx, expr.span, lookup, key_converted);
}
}
fn check_local(&mut self, cx: &LateContext<'tcx>, local: &'tcx LetStmt<'tcx>) {
if local.span.from_expansion() {
return;
}
let Some(init) = local.init else {
return;
};
let Some(else_block) = local.els else {
return;
};
let Some(lookup) = parse_lookup(cx, init) else {
return;
};
if let Some(key_converted) = find_matching_insert(cx, lookup, else_block) {
emit_lint(cx, local.span, lookup, key_converted);
}
}
}
#[derive(Clone, Copy)]
struct Lookup<'tcx> {
map: &'tcx Expr<'tcx>,
key: &'tcx Expr<'tcx>,
}
fn lookup_from_condition<'tcx>(
cx: &LateContext<'tcx>,
condition: &'tcx Expr<'tcx>,
) -> Option<Lookup<'tcx>> {
match &condition.kind {
ExprKind::Let(let_expression) => parse_lookup(cx, let_expression.init),
ExprKind::MethodCall(segment, receiver, [], _)
if matches!(segment.ident.name.as_str(), "is_some" | "is_none")
&& !clippy_covers_get_then_check(cx, receiver) =>
{
parse_lookup(cx, receiver)
}
_ => None,
}
}
fn parse_lookup<'tcx>(
cx: &LateContext<'tcx>,
expression: &'tcx Expr<'tcx>,
) -> Option<Lookup<'tcx>> {
let expression = match &expression.kind {
ExprKind::MethodCall(segment, receiver, [], _)
if matches!(segment.ident.name.as_str(), "copied" | "cloned") =>
{
receiver
}
_ => expression,
};
let ExprKind::MethodCall(segment, map, [key], _) = &expression.kind else {
return None;
};
if !matches!(
segment.ident.name.as_str(),
"get" | "get_mut" | "get_key_value"
) {
return None;
}
let map_type = cx.typeck_results().expr_ty_adjusted(map).peel_refs();
let map_definition = map_type.ty_adt_def()?;
if !is_std_set(cx, map_definition.did()) && !has_inherent_entry_method(cx, map_definition.did())
{
return None;
}
// The purity check runs on the peeled key so that borrows and
// value-preserving conversions do not count as side effects.
let mut key_converted = false;
let peeled_key = peel_key_expr(cx, key, &mut key_converted);
if map.can_have_side_effects() || peeled_key.can_have_side_effects() {
return None;
}
Some(Lookup { map, key })
}
/// Searches `node` for an insertion into `lookup`'s map under `lookup`'s key.
/// Returns `Some(key_converted)` when one is found, where `key_converted`
/// records whether the match required peeling an owned-key conversion.
fn find_matching_insert<'tcx>(
cx: &LateContext<'tcx>,
lookup: Lookup<'tcx>,
node: impl Visitable<'tcx>,
) -> Option<bool> {
for_each_expr_without_closures(node, |expression| {
if let Some(insert) = parse_insert(expression)
&& SpanlessEq::new(cx).eq_expr(lookup.map, insert.map)
&& let Some(key_converted) = keys_match(cx, lookup.key, insert.key)
{
ControlFlow::Break(key_converted)
} else {
ControlFlow::Continue(())
}
})
}
fn parse_insert<'tcx>(expression: &'tcx Expr<'tcx>) -> Option<Lookup<'tcx>> {
// Maps insert `(key, value)`; sets insert only the value.
let ExprKind::MethodCall(segment, map, [key] | [key, _], _) = &expression.kind else {
return None;
};
if segment.ident.name.as_str() != "insert" {
return None;
}
Some(Lookup { map, key })
}
/// Checks whether the two key expressions denote the same key value. Returns
/// `Some(key_converted)` on a match, where `key_converted` records whether an
/// owned-key conversion had to be peeled to see it.
///
/// Two forms are recognized:
/// * the keys are equal after peeling borrows, derefs, and value-preserving
/// conversions, e.g. looking up `&entry.key` and inserting
/// `entry.key.clone()`;
/// * after additionally resolving immutable local aliases to their
/// initializers, e.g. `let owned = key.clone();` before a lookup of `&key`
/// and an insertion of `owned`. Both resolved forms must be free of side
/// effects: two structurally equal side-effecting initializers (say, two
/// separate `next_key()` calls) are two different key values.
fn keys_match<'tcx>(
cx: &LateContext<'tcx>,
lookup_key: &'tcx Expr<'tcx>,
insert_key: &'tcx Expr<'tcx>,
) -> Option<bool> {
let mut key_converted = false;
let lookup_key = peel_key_expr(cx, lookup_key, &mut key_converted);
let insert_key = peel_key_expr(cx, insert_key, &mut key_converted);
if SpanlessEq::new(cx).eq_expr(lookup_key, insert_key) {
return Some(key_converted);
}
let lookup_key = resolve_local_aliases(cx, lookup_key, &mut key_converted);
let insert_key = resolve_local_aliases(cx, insert_key, &mut key_converted);
if !lookup_key.can_have_side_effects()
&& !insert_key.can_have_side_effects()
&& SpanlessEq::new(cx).eq_expr(lookup_key, insert_key)
{
return Some(key_converted);
}
None
}
/// Follows immutable local bindings back to their initializers, peeling each
/// initializer down to its underlying key expression. `expr_or_init` only
/// resolves bindings that cannot be reassigned, so the binding's value is the
/// initializer's value wherever the binding is referenced.
fn resolve_local_aliases<'tcx>(
cx: &LateContext<'tcx>,
mut expression: &'tcx Expr<'tcx>,
key_converted: &mut bool,
) -> &'tcx Expr<'tcx> {
loop {
let resolved = expr_or_init(cx, expression);
if resolved.hir_id == expression.hir_id {
return expression;
}
expression = peel_key_expr(cx, resolved, key_converted);
}
}
/// Strips layers that do not change which slot of the map the key selects:
/// borrows, derefs, and owned-key conversions of the underlying expression.
fn peel_key_expr<'tcx>(
cx: &LateContext<'tcx>,
mut expression: &'tcx Expr<'tcx>,
key_converted: &mut bool,
) -> &'tcx Expr<'tcx> {
loop {
match &expression.kind {
ExprKind::AddrOf(_, _, inner) | ExprKind::Unary(UnOp::Deref, inner) => {
expression = inner;
}
_ => match peel_value_preserving_conversion(cx, expression) {
Some(inner) => {
*key_converted = true;
expression = inner;
}
None => return expression,
},
}
}
}
/// If `expression` is a conversion that yields an owned key equal to its
/// input — under the same `Eq`/`Hash`/`Ord` contract the map lookup itself
/// relies on — returns the input.
///
/// Each conversion is resolved by definition, not by name, so a user-defined
/// method that merely happens to be called `clone` or `to_owned` does not
/// count. For the `ToOwned` family, the `Borrow` contract (the owned and
/// borrowed forms hash and compare identically) is the same contract that
/// makes the map lookup itself well-defined. For `Clone`, the original code
/// already inserts the clone under the probed key, so the lint assumes
/// nothing the code did not.
fn peel_value_preserving_conversion<'tcx>(
cx: &LateContext<'tcx>,
expression: &'tcx Expr<'tcx>,
) -> Option<&'tcx Expr<'tcx>> {
match &expression.kind {
ExprKind::MethodCall(segment, receiver, [], _) => {
let method = cx
.typeck_results()
.type_dependent_def_id(expression.hir_id)?;
let preserves_value = match segment.ident.name.as_str() {
"clone" => is_assoc_of_diag_trait(cx, method, sym::Clone),
"to_owned" => is_assoc_of_diag_trait(cx, method, sym::ToOwned),
// `ToString` runs an arbitrary `Display` impl; only the
// string types are guaranteed to format as themselves.
"to_string" => {
is_assoc_of_diag_trait(cx, method, sym::ToString) && {
let receiver_type =
cx.typeck_results().expr_ty_adjusted(receiver).peel_refs();
receiver_type.is_str() || is_string(cx, receiver_type)
}
}
"to_vec" => cx.tcx.impl_of_assoc(method).is_some_and(|impl_id| {
cx.tcx.type_of(impl_id).instantiate_identity().is_slice()
}),
_ => false,
};
preserves_value.then_some(receiver)
}
// UFCS forms: `Clone::clone(&key)`, `ToOwned::to_owned(&key)`, and
// `String::from(key)` where `key` is a `&str`.
ExprKind::Call(callee, [argument]) => {
let ExprKind::Path(qpath) = &callee.kind else {
return None;
};
let function = cx.qpath_res(qpath, callee.hir_id).opt_def_id()?;
let preserves_value = is_assoc_of_diag_trait(cx, function, sym::Clone)
|| is_assoc_of_diag_trait(cx, function, sym::ToOwned)
|| (is_assoc_of_diag_trait(cx, function, sym::From)
&& is_string(cx, cx.typeck_results().expr_ty(expression))
&& cx.typeck_results().expr_ty(argument).peel_refs().is_str());
preserves_value.then_some(argument)
}
_ => None,
}
}
/// Whether `item` is an associated item of the trait named by the diagnostic
/// item `trait_name`. Qualified paths can resolve to a trait impl's item
/// rather than the trait's, so impl items are first mapped back to the trait
/// item they implement.
fn is_assoc_of_diag_trait(cx: &LateContext<'_>, item: DefId, trait_name: Symbol) -> bool {
let item = cx
.tcx
.opt_associated_item(item)
.and_then(|associated| associated.trait_item_def_id())
.unwrap_or(item);
cx.tcx
.trait_of_assoc(item)
.is_some_and(|trait_id| cx.tcx.is_diagnostic_item(trait_name, trait_id))
}
fn is_string(cx: &LateContext<'_>, ty: Ty<'_>) -> bool {
ty.ty_adt_def()
.is_some_and(|definition| cx.tcx.is_lang_item(definition.did(), LangItem::String))
}
/// Clippy already rewrites `get(..).is_some()`/`is_none()` on the std maps
/// and sets: `unnecessary_get_then_check` turns the condition into
/// `contains_key`/`contains`, which `map_entry`/`set_contains_or_insert` then
/// turn into the final form. Defer to that chain instead of duplicating it.
/// Conditions using `get_mut` or `get_key_value`, and any `get` on a non-std
/// map, are not covered by Clippy and stay ours.
fn clippy_covers_get_then_check(cx: &LateContext<'_>, receiver: &Expr<'_>) -> bool {
let ExprKind::MethodCall(segment, map, [_], _) = &receiver.kind else {
return false;
};
if segment.ident.name.as_str() != "get" {
return false;
}
cx.typeck_results()
.expr_ty_adjusted(map)
.peel_refs()
.ty_adt_def()
.is_some_and(|definition| {
matches!(
cx.tcx.get_diagnostic_name(definition.did()),
Some(sym::HashMap | sym::BTreeMap | sym::HashSet | sym::BTreeSet)
)
})
}
/// Sets are gated by diagnostic name rather than by an `entry` method: their
/// entry API is unstable (`hash_set_entry`), and the suggested rewrite (plain
/// `insert`) relies on std's no-op-when-present insertion semantics.
fn is_std_set(cx: &LateContext<'_>, definition: DefId) -> bool {
matches!(
cx.tcx.get_diagnostic_name(definition),
Some(sym::HashSet | sym::BTreeSet)
)
}
/// A lookup-then-insert pattern is only worth reporting when the map offers an
/// entry API to rewrite it with. Rather than hard-coding `HashMap` and
/// `BTreeMap`, accept any type whose inherent impls provide an `entry` method;
/// this also covers maps like `hashbrown::HashMap` and `indexmap::IndexMap`.
/// Hasher aliases such as `FxHashMap` need no special case because they
/// resolve to the underlying map type.
fn has_inherent_entry_method(cx: &LateContext<'_>, map_definition: DefId) -> bool {
let entry = Symbol::intern("entry");
cx.tcx.inherent_impls(map_definition).iter().any(|&imp| {
cx.tcx
.associated_items(imp)
.filter_by_name_unhygienic(entry)
.next()
.is_some()
})
}
fn emit_lint(
cx: &LateContext<'_>,
span: rustc_span::Span,
lookup: Lookup<'_>,
key_converted: bool,
) {
let is_set = cx
.typeck_results()
.expr_ty_adjusted(lookup.map)
.peel_refs()
.ty_adt_def()
.is_some_and(|definition| is_std_set(cx, definition.did()));
let (message, help) = if is_set {
(
"this set lookup and insertion search for the same value twice",
"`insert` alone searches once, skips duplicates, and returns `false` \
when the value was already present",
)
} else {
(
"this map lookup and insertion search for the same key twice",
"to search the map only once, use the entry API: \
https://doc.rust-lang.org/std/collections/hash_map/enum.Entry.html",
)
};
span_lint_and_then(cx, MAP_LOOKUP_THEN_INSERT, span, message, |diagnostic| {
diagnostic.help(help);
if key_converted {
diagnostic.note(
"the inserted key is an owned copy of the looked-up key; a rewrite that \
passes the key by value up front would make that copy on hits as well \
as misses",
);
}
});
}