Skip to content

Add unchecked_non_zero lint - #17521

Open
dcsommer wants to merge 1 commit into
rust-lang:masterfrom
dcsommer:unchecked-non-zero
Open

Add unchecked_non_zero lint#17521
dcsommer wants to merge 1 commit into
rust-lang:masterfrom
dcsommer:unchecked-non-zero

Conversation

@dcsommer

@dcsommer dcsommer commented Aug 7, 2026

Copy link
Copy Markdown

NOTE: this PR was drafted with Opus 5. I (the human) reviewed all content, including by-hand fixes, prior to submission.

Motivation

A handful of standard library methods panic when a value is zero, and nothing in their signatures says so. fn rows(data: &[u8], row_len: usize) looks total, but data.chunks(row_len) aborts the process when row_len is 0. When the value comes from a computation, a config file or user input, the panic surfaces far from where the bad value was produced, and the type system offered no warning.

This is the same class of problem clippy already covers for indexing (indexing_slicing), Option/Result (unwrap_used), string slicing (string_slice) and arithmetic (arithmetic_side_effects) — but the zero preconditions have no lint, so codebases that deny the others still panic here.

The fix a user reaches for is NonZero<usize>, which moves the check to the boundary where the value enters the program and makes the call site total. The lint's help says so directly, and for the ilog family points at the checked_* variants that return None.

Scope

Call Panics when
chunks, chunks_exact, rchunks, rchunks_exact, windows and their _mut variants size is 0
Iterator::step_by step is 0
ilog2, ilog10, ilog receiver is 0, or negative when signed
ilog(base) base is below 2
error: `chunks` will panic if the chunk size is zero
  --> src/main.rs:2:10
   |
LL |     data.chunks(row_len);
   |          ^^^^^^^^^^^^^^^
   |
note: this may be zero
  --> src/main.rs:2:17
   |
LL |     data.chunks(row_len);
   |                 ^^^^^^^
   = help: consider taking the chunk size as a `NonZero<usize>`, or checking it before this call

Why one lint rather than three

The three families share the interesting part — deciding whether a value could be zero — so they share a single known_at_least prover. That has a direct maintainability consequence: the prover only has to be improved once. It currently accepts constants, NonZero::get() on an unsigned NonZero, and max(n); the obvious follow-ups (recognising a preceding assert!, an enclosing if x != 0, or usize::from(non_zero)) would land in one place and immediately benefit every covered API. Split across three lints, each improvement would have to be written and tested three times, and would drift.

Adding another zero-panicking API is likewise one match arm plus one Precondition literal — no new lint registration, no new #[clippy::version], no new docs page, no new test file. The Precondition struct keeps each site declarative (which expression, what to call it, what the minimum is, what help to give), so the wiring reads as data rather than control flow.

This also matches existing precedent rather than inventing a category: arithmetic_side_effects is a single restriction lint spanning every arithmetic operator that shares an overflow/divide-by-zero precondition, and it uses the same NonZero carve-out. Reusing that carve-out is deliberate — it's why division and modulo are not duplicated here.

The honest cost of merging them is granularity: you can't enable the slice checks without also getting ilog. I think that's the right trade for a lint this narrow, but it's the main thing I'd want challenged.

Correctness details

Three cases worth highlighting, all covered by tests:

  • A signed NonZero proves nothing here: NonZero<i32>::get() can be negative, so n.get().ilog2() still fires.
  • NonZero's own ilog2/ilog10 are infallible, and NonZero is an ADT rather than is_integral(), so they are correctly skipped.
  • step_by(0) is left to iterator_step_by_zero (warn-by-default) so the two fire on disjoint cases. The slice family keeps its known-zero case, since nothing catches chunks(0) today.

Compile-time cost

unchecked_non_zero::check is called once at the top of check_methods, so it sees every method call in the crate. It matches on the symbol first and only touches typeck tables once an arm has matched, so the common path is a handful of integer comparisons.

Checklist

  • Followed lint naming conventions
  • Added passing UI tests (including committed .stderr file)
  • cargo test passes locally
  • Executed cargo dev update_lints
  • Added lint documentation
  • Ran cargo dev fmt

changelog: new lint: [unchecked_non_zero]

@rustbot rustbot added the needs-fcp PRs that add, remove, or rename lints and need an FCP label Aug 7, 2026
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown

Lintcheck changes for 560fc94

Lint Added Removed Changed
clippy::unchecked_non_zero 23 0 0

This comment will be updated if you push new changes

@dcsommer
dcsommer force-pushed the unchecked-non-zero branch from d86a951 to 9a8b536 Compare August 7, 2026 20:05
@dcsommer
dcsommer marked this pull request as ready for review August 7, 2026 20:20
@rustbot rustbot added S-waiting-on-review Status: Awaiting review from the assignee but also interested parties S-waiting-on-community-reviews Status: This is awaiting for positive reviews from the community before a maintainer is assigned. labels Aug 7, 2026

@DanielEScherzer DanielEScherzer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

community review: looking at the lintcheck results, you might want to consider adding known_at_least support for

  • size_of results with non-ZST types (fastrand-2.1.0/src/lib.rs:539 with slice.chunks_exact_mut(core::mem::size_of::<u64>()))
  • addition results (chrono-0.4.38/src/offset/local/tz_info/parser.rs:62 with state.leap_seconds.chunks_exact(state.time_size + 4), when time_size is already usize and thus cannot be negative 4)

View changes since this review

Adds a restriction lint for std methods that panic unless a value is
non-zero, where that value cannot be shown to be non-zero:

- `chunks`, `chunks_exact`, `rchunks`, `rchunks_exact`, `windows` and
  their `_mut` variants (chunk/window size of 0)
- `Iterator::step_by` (step of 0)
- `ilog2`, `ilog10`, `ilog` (receiver of 0, or non-positive when signed;
  `ilog` also panics on a base below 2)

A value is accepted as valid when it const-evaluates high enough, is
`NonZero::get()` on an unsigned `NonZero`, or is `max(n)` for a large
enough constant `n` -- the same shape of carve-out `arithmetic_side_effects`
uses for division by zero.

`step_by` with a literal `0` is left to `iterator_step_by_zero`, which is
warn-by-default, so the two lints don't both fire on it.
@dcsommer
dcsommer force-pushed the unchecked-non-zero branch from 9a8b536 to 560fc94 Compare August 10, 2026 18:52
@rustbot

rustbot commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

☔ The latest upstream changes (possibly #17552) made this pull request unmergeable. Please resolve the merge conflicts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

needs-fcp PRs that add, remove, or rename lints and need an FCP S-waiting-on-community-reviews Status: This is awaiting for positive reviews from the community before a maintainer is assigned. S-waiting-on-review Status: Awaiting review from the assignee but also interested parties

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants