Mordant
Lints for Rust that find the rules your code relies on but your types do not enforce.
A rule like "always build a Port through Port::new" usually
lives in a comment or in somebody's head. Mordant finds rules like that and points at the
type change that would make the compiler enforce them. It runs next to Clippy, not
instead of it.
Run it
Mordant is built with a pinned nightly, and it lints projects on stable Rust without changing their toolchain. Install the nightly and Mordant once on each machine:
rustup toolchain install nightly-2026-09-01 --component rustc-dev --component llvm-tools-preview
cargo +nightly-2026-09-01 install --locked --git https://github.com/scarletindustries/mordant
Then run the lints from the workspace root:
cargo mordant --workspace --all-targets # report
cargo mordant --workspace --fix # also rewrite wildcard arms
Read a finding
Here Port::new checks that a number fits in 16 bits, but the crate also
builds a Port by hand and skips the check:
mod port {
pub struct Port {
pub(crate) n: u16,
}
impl Port {
pub fn new(n: u32) -> Result<Port, ()> {
if n <= u16::MAX as u32 {
Ok(Port { n: n as u16 })
} else {
Err(())
}
}
}
}
use port::Port;
fn bypass() -> Port {
Port { n: 0 }
}
warning: `port::Port` is built by hand here, skipping the check on `n` that `Port::new` makes
--> src/lib.rs:24:5
|
24 | Port { n: 0 }
| ^^^^^^^^^^^^^
|
note: the check in `Port::new`
--> src/lib.rs:12:16
|
12 | if n <= u16::MAX as u32 {
| ^^^^^^^^^^^^^^^^^^^^
= help: build it with `Port::new(..)`, or make `n` private so a literal like this only compiles inside `Port`'s module
= note: `#[warn(unchecked_construction)]` on by default
The first line is the claim, the note is the evidence and the
help is the fix. Here the fix makes n private, so nothing
outside the module can skip Port::new:
mod port {
pub struct Port {
n: u16,
}
impl Port {
pub fn new(n: u32) -> Result<Port, ()> {
if n <= u16::MAX as u32 {
Ok(Port { n: n as u16 })
} else {
Err(())
}
}
}
}
use port::Port;
fn build(n: u32) -> Result<Port, ()> {
Port::new(n)
}
When the code is right, silence that one finding with
#[allow(unchecked_construction)] on the item.
Run it in CI
Mordant Action runs the lints on every pull request and fails the job on any finding.
name: mordant
on: [pull_request]
jobs:
mordant:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: scarletindustries/mordant-action@v1
Findings show up as annotations on the pull request. Set fail-on: never to
report without failing, and ref to pin a Mordant commit.
Configuration
Settings live in the [mordant] table of a mordant.toml at the
workspace root. Without the file every default lint runs, and a misspelt key stops the
run with an error rather than being ignored.
[mordant]
disabled = ["unused_pub", "group:naming"] # never run these
bool-cluster-enabled = true # turn on an opt-in lint
The lints
Each family is also a lint group, named in brackets, so
#![allow(mordant_naming)] covers a whole family. Opt-in lints only run once
mordant.toml turns them on.
State that should be a type (mordant_state)
|
|
|---|---|
options_as_enum
|
Option fields that are never Some together, an enum in disguise
|
parallel_bools
|
Bools that are always assigned together, so they are one state |
bool_cluster
|
A struct with so many bools that most combinations mean nothing (opt-in) |
runtime_typestate
|
A ready flag that several methods check before they do anything |
always_unwrapped_option
|
An Option field that every reader unwraps
|
derived_field
|
A field that always has the same value for a given value of another |
field_valid_only_when
|
A field that only means something while a sibling has one value |
bool_beside_option
|
A bool that is always the is_some() of the Option beside it
|
parallel_vecs
|
Vecs that grow together and are read at the same index |
parallel_params
|
Parameters that always travel together, a value with no type (opt-in) |
stringly_state
|
A string that only ever holds one of a few literals |
tuple_wants_struct
|
A tuple that every caller unpacks under the same names |
some_still_unchecked
|
Some(x) if x.ready(), where a Some that fails counts as None
(opt-in)
|
Checks that some path skips (mordant_checks)
|
|
unchecked_construction
|
A checked type built or changed without its check |
defaulted_failure
|
Rejected input replaced by a default, so processing carries on |
unchecked_input_len
|
A length checked on one path and trusted on another (opt-in) |
guard_blind_to_action
|
if self.can_x() guarding changes that can_x never looks at
|
stale_across_reentry
|
A length or pointer read from self, trusted after a callback
|
error_collapsed_to_bool
|
An error turned into false, and then the false thrown away
|
narrowed_two_ways
|
One value narrowed with try_from in one place and as in another
|
cast_bypasses_from
|
A transmute or pointer cast around a checked conversion
|
sentinel_integer
|
A MAX that means none, used unchecked as an index
|
Errors that lose their type (mordant_errors)
|
|
stringly_error
|
An exported function whose error is a String
|
stringified_error
|
A typed error turned into a string in map_err
|
discarded_error
|
something().ok();, which throws the error away
|
unread_error_variant
|
A private enum variant that is built but never matched |
Enums wider than their use (mordant_enums)
|
|
wildcard_over_own_enum
|
A _ => arm that will swallow the next variant
|
param_wider_than_callers
|
A panic on a variant no caller passes |
return_wider_than_body
|
A panic on a variant the function never returns |
Code written twice (mordant_duplication)
|
|
same_match_twice
|
The same match over one enum, written in two places
|
reimplemented_helper
|
Two functions with the same signature and body |
generic_body_not_generic
|
Generic code that ignores its type parameters, compiled once per type (opt-in) |
Names doing a type's job (mordant_naming)
|
|
bare_bool_args
|
f(x, true, false), where nothing says which flag is which
|
arg_named_like_other_param
|
resize(height, width) against fn resize(width, height)
|
interchangeable_aliases
|
Two aliases of one integer, used in place of each other |
index_of_other_kind
|
An index of one kind used on a table of another |
unit_mismatch
|
timeout_ms + deadline_ns
|
Map keys and locks (mordant_keys_locks)
|
|
key_not_identity
|
A map keyed on something that does not identify its value |
insert_then_unwrap
|
insert(k, v) followed by get(&k).unwrap()
|
lock_order
|
Two locks taken in both orders, the shape of a deadlock |
Comments that have gone stale (mordant_comments)
|
|
stale_safety_comment
|
A SAFETY: comment naming something that no longer exists
(opt-in)
|
stale_panic_message
|
A panic message naming something that no longer exists |
Public code nobody uses (mordant_unused)
|
|
unused_pub
|
A pub item that nothing in the workspace uses
|
Your own rules (mordant_custom)
|
|
forbidden_reach
|
A call path your config bans |