perfectionist lints

perfectionist is a Dylint plugin; see the README for setup. Lint-control attributes use the perfectionist:: namespace.

Index

LintDefaultDescription
allow_attributesactive#[allow] for a deterministically-firing lint should be removed or be #[expect]
allow_attributes_without_reasonactive#[allow] / #[expect] attribute lacks an explanatory reason = "..." field
arbitrary_source_item_orderingactiveitem in a module body sits below a section it belongs above: pub mod, then pub use, then private imports and other items
avoidable_string_escapesactivestring literal contains only raw-expressible escapes; prefer the raw-string form
bare_emailactivebare email address in comment or doc comment; wrap in <...> or prefix with mailto:
bare_identifier_referenceactivebackticked identifier in a doc comment that resolves in scope should be an intra-doc link
bare_issue_referenceactiveambiguous bare #NNN issue / PR reference in comment
bare_urlactivebare URL in comment or doc comment; wrap in <...> or use a labelled markdown link
clap_help_markdownactivemarkdown construct in a clap-derived doc comment leaks into --help output
cloning_getteractivegetter returns an owned copy of a field where a borrow would serve
core_instead_of_stdinactiveitem named through core or alloc instead of std
excessive_cognitive_complexityactivefunction body has a cognitive complexity above the configured maximum
excessive_inline_testsactiveinline test code should be extracted to a separate file
excessive_nestingactivefunction body nests constructs deeper than the configured maximum
exhaustive_error_enumsinactiveerror-shaped type is missing #[non_exhaustive]
folded_command_setteractivefold over a singular CommandExtra setter re-implements the plural
import_granularity_mismatchactiveimport granularity does not match the configured import_granularity_mismatch.style
import_grouping_mismatchinactiveimport grouping does not match the configured import_grouping_mismatch.style
impure_macro_argumentsactivemacro invocation passes an impure expression that should be bound to a let first
lint_attribute_trailing_commentactivetrailing comment on a lint-level attribute should be lifted into a reason = "..." field
macro_trailing_commaactivemacro invocation does not follow rustfmt's vertical trailing-comma policy
mutating_command_builderactivea command setter taking &mut self where command-extra's by-value form exists
named_prelude_importsactivenamed item cherry-picked from a prelude module instead of glob-imported
needless_borrowed_parametersactiveborrowed parameter is only used to produce its owned form
overly_complex_conditionactivecondition has more boolean operators than the configured maximum
overly_long_fileactivesource file has more lines of code than the configured maximum
overly_long_functionactivefunction body has more lines of code than the configured maximum
overly_long_method_chaininactiveexpression chain has more method calls than the configured maximum
overly_long_print_macroactivesplittable print macro with an embedded-newline template exceeds the configured line width
redundant_derive_more_forward_templateactivederive_more formatting template only restates the forward the derive already performs
single_letter_closure_paramactiveclosure parameter has a single-letter name
single_letter_const_genericactiveconst generic parameter has a single-letter name
single_letter_const_itemactiveconst item has a single-letter name
single_letter_function_paramactivefunction parameter has a single-letter name
single_letter_genericactivegeneric type parameter has a single-letter name
single_letter_let_bindingactivelet binding has a single-letter name
single_letter_static_itemactivestatic item has a single-letter name
thiserror_usageactivethiserror import, derive, or attribute; this catalogue prefers derive_more::{Display, Error}
too_many_local_bindingsactivefunction body binds more distinct local names than the configured maximum
too_many_struct_fieldsactivestruct has more fields than the configured maximum
uncombined_self_importinactivea module import and an adjacent item import from it can be combined through self
unicode_ellipsis_in_commentsactiveU+2026 HORIZONTAL ELLIPSIS in non-doc comments; prefer ...
unicode_ellipsis_in_docsactiveU+2026 HORIZONTAL ELLIPSIS in doc comments; prefer ...
unicode_ellipsis_in_panic_messagesactiveU+2026 HORIZONTAL ELLIPSIS in panic / assertion / expect messages; prefer ...
unknown_perfectionist_lintsactivelint-control attribute references a perfectionist::* lint that this plugin does not register
unordered_derivesinactivetrait names in a #[derive(...)] list are not in the configured order
unpinned_repo_refactiverepository URL references a branch or tag instead of a commit SHA
wildcard_importsactiveglob (*) import in a module body, outside the prelude and root-re-export exceptions

Rules

perfectionist::allow_attributes↑ top

active#[allow] for a deterministically-firing lint should be removed or be #[expect]

What it does

Flags #[allow(<lints>)] when every named lint fires deterministically — a built-in rustc lint (not on the exempt list), a clippy::* / rustdoc::* lint, or a tool-namespaced lint such as perfectionist::*. Such a suppression can be resolved two ways:

  1. Remove it — when the lint can no longer fire at the site (a dead suppression, e.g. clippy::too_many_arguments left on a function that has since shed its arguments).
  2. Replace it with #[expect] — when the lint still fires and you want the suppression to report itself the moment it stops.

If the attribute also names a lint the rule leaves alone (an exempt or unknown lint), only the deterministic names are resolved; the rest stay under #[allow].

Crate- and module-level scopes (#![allow(...)], and outer #[allow(...)] on a mod item) are left alone by default, because a cfg-conditional body inside the scope may fire the lint in one configuration and not another — set apply_to_outer_scopes = true to opt in.

Why restrict this?

This is a stylistic preference, not a correctness issue. A suppression often outlives the problem it suppressed. #[allow] stays silent forever, including after the underlying issue is resolved, so a project accumulates stale #[allow] attributes that no longer apply. #[expect] emits unfulfilled_lint_expectations the moment the named lint stops triggering at the site — exactly when the suppression becomes dead — so routine compilation tells the author to remove it. Every #[expect] is also a self-test that the lint does fire at the site, so a future refactor that inadvertently fixes the issue is observed rather than hidden.

Interaction with Clippy

clippy::allow_attributes (restriction, off by default) also pushes #[allow] towards #[expect], but rewrites every #[allow] indiscriminately, and only towards #[expect]. This rule flags only the lints that fire deterministically, leaving lint groups and conditionally-firing lints under #[allow]. Crucially, it does not assume #[expect] is always the answer: a deterministic #[allow] may already be dead, in which case #[expect] would be unfulfilled and removal is the right fix, so the rule offers both. Reach for this rule for that precision, or clippy::allow_attributes for a blunt crate-wide sweep.

Example

Avoid:

#[allow(clippy::too_many_arguments, reason = "matches pnpm's signature")]
fn build_fetcher(/* ... */) {}

Prefer:

#[expect(clippy::too_many_arguments, reason = "matches pnpm's signature")]
fn build_fetcher(/* ... */) {}
Configuration

Configure via dylint.toml under ["perfectionist::allow_attributes"].

extra_exempt_lints : [string] optional

Extra lints to exempt, on top of the built-in default set (the cfg-conditional unused_* / reachability lints). Names are matched against the fully-namespaced lint name shown in diagnostics (e.g. clippy::too_many_arguments). Merged with the defaults rather than replacing them.

ignore_exempt_lints : [string] optional

Lints to drop from the exempt set, even if they appear in the built-in defaults or in extra_exempt_lints. Use this to opt a default exemption back into rewriting (e.g. ["dead_code"] in a project with no cfg-gated dead code).

apply_to_outer_scopes : boolean optional

When true, also rewrite crate-level #![allow(...)] and module-level #[allow(...)] attributes. Default false because cfg-conditional bodies inside the scope are common.

apply_to_tool_namespaces : boolean optional

When false, only clippy::*, rustdoc::*, and built-in lints are rewritten; other tool namespaces (perfectionist::* and similar) are left alone. Default true — a tool namespace's lints are assumed to fire deterministically like a built-in, so perfectionist::* (and similar) are rewritten by default.

Source: src/rules/allow_attributes.rs

perfectionist::allow_attributes_without_reason↑ top

active#[allow] / #[expect] attribute lacks an explanatory reason = "..." field

What it does

Requires every #[allow(<lints>)] and #[expect(<lints>)] attribute to carry an explanatory reason = "..." field. #[allow] and #[expect] are the two levels that fully silence a lint's output; the project's record of suppressions needs to know why each one exists.

The check is purely local — the attribute itself — and does not depend on any inherited or ambient lint level.

Why restrict this?

This is a stylistic preference, not a correctness issue. Suppressions outlive the conditions that justify them. A bare #[allow(clippy::too_many_arguments)] told the original author to ignore a complaint; six months later, no one knows whether the rationale was "matches upstream signature", "intentional over-engineering", or "we'll fix it in the next refactor". The reason field records intent at the moment of suppression, and rustc renders it back in unfulfilled_lint_expectations notes when a stale #[expect] is encountered.

Interaction with Clippy

clippy::allow_attributes_without_reason (restriction, off by default) flags a missing reason = "..." on the same two levels this rule does — #[allow] and #[expect] — and only those, so the trigger overlaps. This rule adds a quality floor Clippy lacks: min_reason_length (default 3) flags a content-free reason = "ok" that Clippy accepts, and a blank reason = "" is treated as missing. It also takes a per-lint exempt_lints list and fires regardless of the crate's MSRV, where Clippy's is gated on the lint_reasons stabilisation. For the bare presence check alone, the Clippy lint is equivalent.

Example

Avoid:

#[allow(clippy::too_many_arguments)]
fn build_fetcher(/* ... */) {}

Prefer:

#[allow(clippy::too_many_arguments, reason = "matches upstream signature")]
fn build_fetcher(/* ... */) {}
Configuration

Configure via dylint.toml under ["perfectionist::allow_attributes_without_reason"].

exempt_lints : [string] optional

Lints excluded from the requirement. Useful for project-wide suppressions whose rationale lives in the project README rather than per-site. Each entry is the lint's full name as it appears inside the attribute (clippy::module_name_repetitions, dead_code, ...).

min_reason_length : non-zero unsigned integer optional

Minimum length of the reason value. A one- or two-character reason ("x", "ok") satisfies the literal presence requirement but conveys nothing; the default floor of 3 excludes those cases. Projects that want a higher bar (e.g. require a full sentence) can raise it. The lower bound is 1 — 0 is rejected at parse time, since an empty literal is already treated as a missing reason regardless of this knob.

Source: src/rules/allow_attributes_without_reason.rs

perfectionist::arbitrary_source_item_ordering↑ top

activeitem in a module body sits below a section it belongs above: pub mod, then pub use, then private imports and other items

What it does

Holds the top-level items of a module body to one fixed sequence:

  1. pub mod declarations,
  2. pub use re-exports,
  3. private imports and every other item.

An item that sits below a section it belongs above is flagged — a pub mod under a pub use, a pub use under a private import or a fn, and so on. The last section is not ordered internally: a private use and a struct may appear in either order.

Two shapes are exempt, being neither flagged nor closing the section they sit in: an extern crate declaration, which #[macro_use] extern crate foo; pins to the top of a crate root, and a #[cfg(...)]-gated use, so a trailing #[cfg(unix)] use ...; block may follow the main import block.

Only the top-level sequence of a module body is considered. Items nested deeper — inside an impl, a trait, or a function body — are out of scope.

Any explicit visibility counts as pub for the ordering, so pub(crate) mod is a pub mod declaration and pub(super) use is a pub use re-export.

Why restrict this?

This is a stylistic preference, not a correctness issue. Rust attaches no meaning to the order of items in a module body, and no layout is wrong in the abstract. The one this rule enforces answers a reader's questions in the order they ask them: what this module contains (its submodules), what it publishes (its re-exports), and only then what it borrows and how it is built. Applying it uniformly also keeps the top of every file scanning alike, and stops a new submodule from being appended wherever the diff happened to be open.

No autofix is offered. Moving an item carries its attributes, doc comment, and any comment written above it, and a pub use hoisted above a pub mod may cross an item it names — edits a reader should make deliberately.

Interaction with Clippy

clippy::arbitrary_source_item_ordering (restriction, off by default) covers adjacent ground from the other end. It is highly general: it orders many item kinds against an ordering table the project supplies and can sort alphabetically within a group. This rule takes no configuration and bakes in the single layout above. A project that wants to spell out its own ordering should prefer the Clippy lint and turn this one off in dylint.toml:

[perfectionist]
disable = ["arbitrary_source_item_ordering"]

Enabling both lets the two orderings contradict each other.

Example

Avoid: a pub mod below the pub use that re-exports from it

pub use parser::Parser;
pub mod parser;

Avoid: a private fn above a pub use

fn helper() {}
pub use parser::Parser;

Prefer:

pub mod parser;
pub mod printer;

pub use parser::Parser;
pub use printer::Printer;

use std::collections::HashMap;

fn helper() {}

Configuration: none.

Source: src/rules/arbitrary_source_item_ordering.rs

perfectionist::avoidable_string_escapes↑ top

activestring literal contains only raw-expressible escapes; prefer the raw-string form

What it does

Forbids regular string literals whose only backslash escapes are ones a raw string would express verbatim — \", \\, and \'. The autofix rewrites the literal to the raw form r"..." / r#"..."#, picking the smallest hash count that avoids a delimiter collision.

Literals inside macro invocations are covered too: every string literal in a macro call, whatever its position — including a format!-family template that contains a {...} placeholder. The rewrite is value-preserving (a raw string still parses placeholders, and {{ / }} survive verbatim), so the literal's role doesn't matter. Suppress a site where the regular form is deliberately preferred.

That includes literals a macro uses for their source spelling rather than their value, such as stringify! and dbg!, where the raw form has the same value but a different reflected text. This is intentional: code whose behaviour depends on a literal's exact spelling instead of its value is rare and a code smell; suppress it per site with #[expect(perfectionist::avoidable_string_escapes)].

Pattern-position literals in ordinary code (e.g. match s { "C:\\path" => ... }) are out of scope; only expression-position literals are rewritten. A literal written as a pattern inside a macro call (e.g. matches!(s, "C:\\path")) is rewritten anyway, since a literal's position isn't distinguished inside a macro.

Whitespace and control-character escapes (\n, \t, \r, \0) and Unicode escapes (\x.., \u{..}) are exempt — a raw string cannot express them, and the regular form is the only choice. A literal that mixes eliminable and inexpressible escapes is also left alone; the rewrite would force the author to split the literal or fall back to concat!, which loses more than it gains.

Why restrict this?

This is a stylistic preference, not a correctness issue. The rule trades one noise source (interior backslash escapes) for a slightly more elaborate string syntax. The benefit is highest in strings full of file paths, regex patterns, JSON snippets, or embedded source code — all of which would otherwise be a sea of \\ and \".

Example

Avoid:

let json = "{\"name\":\"foo\"}";
let path = "C:\\Users\\foo\\bar";

Prefer:

let json = r#"{"name":"foo"}"#;
let path = r"C:\Users\foo\bar";
Configuration

Configure via dylint.toml under ["perfectionist::avoidable_string_escapes"].

min_escapes_to_trigger : non-zero unsigned integer optional

Minimum number of eliminable escapes a string must contain before the lint fires. Default 1 catches every escapable string; set to 2 to skip single-escape literals where the raw form is arguably noisier than the original. The lower bound is 1 — 0 is rejected at parse time, since suggesting r"hello" for "hello" would just trip clippy::needless_raw_strings on the next pass, and a minimum of 1 already excludes that case.

eligible_escapes : [string] optional

Escape sequences considered eliminable by switching to raw form. Only the three Rust escapes whose decoded character is exactly the byte after the backslash — "\"", "\\", "\\'" — are accepted; entries listed here that fall outside that closed set are silently dropped. (\n, \t, \xNN, \u{...} and other escapes decode to a different character and cannot be expressed verbatim in a raw string, so they have no place in this list.) Use this knob to narrow eligibility — e.g. ["\\\""] to only flag literals whose sole escapes are escaped quotes — not to extend it.

Source: src/rules/avoidable_string_escapes.rs

perfectionist::bare_email↑ top

activebare email address in comment or doc comment; wrap in <...> or prefix with mailto:

What it does

Flags bare email addresses (user@example.com) in doc comments (///, //!) and regular comments (//, /* */). Wrapping in <...>, prefixing with mailto:, or both turns the address into an explicit autolink across CommonMark, GitHub-flavored markdown, and rustdoc.

A forbid style is available for projects that prefer to keep contact information out of source entirely.

Why restrict this?

This is a stylistic preference, not a correctness issue. Bare email addresses rely on the renderer's autolinkification, which is inconsistent across markdown engines. The <email> / mailto:email forms make the autolink intent explicit.

Example

Avoid:

/// Report security issues to security@example.com.

Prefer:

/// Report security issues to <security@example.com>.
Configuration

Configure via dylint.toml under ["perfectionist::bare_email"].

style : Style optional

Required form for compliant email addresses. Defaults to either.

scan_doc_comments : boolean optional

Scan doc comments (///, //!, /** */, /*! */). Defaults to true.

scan_regular_comments : boolean optional

Scan regular comments (//, /* */). Defaults to true.

skip_addresses : [string] optional

Skip these exact addresses. Useful for noreply@github.com and similar placeholders that the project deliberately leaves bare in changelog entries. Empty by default.

skip_domains : [string] optional

Skip addresses whose domain exactly equals any of these. Empty by default.

Types

Style enum

Required form for compliant email addresses.

"angle_brackets" (Rust: AngleBrackets)

Wrap the address with < and > — <user@example.com>.

"mailto" (Rust: Mailto)

Prefix the address with mailto: — mailto:user@example.com.

"both" (Rust: Both)

Combine both — <mailto:user@example.com>.

"either" (Rust: Either)

Accept any of the three wrapped forms (<email>, mailto:email, or <mailto:email>); the autofix emits two MaybeIncorrect suggestions for the author to pick from.

"forbid" (Rust: Forbid)

Forbid email addresses outright — no autofix, just a help note recommending the address be moved to an external file or removed entirely.

Source: src/rules/bare_email.rs

perfectionist::bare_identifier_reference↑ top

activebackticked identifier in a doc comment that resolves in scope should be an intra-doc link

What it does

Flags a backticked identifier in a doc comment (`Foo`) that resolves as a Rust path in the documented item's scope but is not written as a rustdoc intra-doc link ([`Foo`]).

Only bare single identifiers whose name resolves to an item in the enclosing module's scope are flagged; a backticked word that names nothing in scope is left alone.

A publicly-reachable item that mentions a private one is reported too, with no link offered.

Why restrict this?

This is a stylistic preference, not a correctness issue. Both `Foo` and [`Foo`] render as monospaced text, so the page looks the same at a glance. The link form additionally turns the mention into a clickable cross-reference and lets rustdoc's rustdoc::broken_intra_doc_links lint catch the day a rename leaves the prose pointing at a type that no longer exists. Spelling every in-scope mention as a link keeps the documentation navigable and the references checked.

The preference is sharper where a publicly-reachable item names a private one: the name is one its reader cannot look up, and it can be renamed at will, being no part of the API it appears in. A link does not answer either objection.

Example

Avoid:

/// Installs the package described by `PackageManifest` into `Store`.
pub fn install(manifest: &PackageManifest, store: &Store) {}

Prefer:

/// Installs the package described by [`PackageManifest`] into [`Store`].
pub fn install(manifest: &PackageManifest, store: &Store) {}

Caveat: linking is not always correct

When a backticked word names a foreign symbol that merely shares its name with an in-scope item, rewriting `Foo` to [`Foo`] links to the local item — a wrong reference that still compiles. The rule therefore only suggests the link rather than applying it automatically. For an external target, write an explicit reference link instead: [`Foo`][foo-ext] plus a [foo-ext]: <url> definition.

Configuration

Configure via dylint.toml under ["perfectionist::bare_identifier_reference"].

skip_idents : [string] optional

Identifiers the rule never suggests linking, even when they resolve in scope. Empty by default. Use this for a name a doc comment deliberately mentions without wanting a cross-reference — a historical type kept for context, or a word that happens to collide with an in-scope item but is meant as prose.

reference_scope : ReferenceScope optional

How far from the documenting item a referenced name may resolve for the rule to check it, by where the referenced item lives in the module tree. A backticked word that matches an accidentally-added cross-module import is a common source of churn, so a project can narrow (or widen) this. Defaults to crate: a project's own items are kept linked, but mentions of the standard library and third-party crates are left alone.

check_pascal_case : boolean optional

Whether to check PascalCase names. Defaults to true.

A mixed or non-conformist name (fooBar, foo_BAR, __foo, foo__bar) is checked regardless of this field: such a spelling is rare and, when it matches a local identifier, rarely an accident.

check_upper_case : boolean optional

Whether to check UPPER_CASE (SCREAMING_SNAKE_CASE) names. Defaults to true.

A mixed or non-conformist name (fooBar, foo_BAR, __foo, foo__bar) is checked regardless of this field: such a spelling is rare and, when it matches a local identifier, rarely an accident.

check_snake_case : boolean optional

Whether to check snake_case names. Defaults to true.

A mixed or non-conformist name (fooBar, foo_BAR, __foo, foo__bar) is checked regardless of this field: such a spelling is rare and, when it matches a local identifier, rarely an accident.

min_words : non-zero unsigned integer optional

Minimum number of words a name must have to be checked. Defaults to 1 (check everything). At 3, foo, foo_bar, Foo, FooBar, FOO, and FOO_BAR are exempt, while foo_bar_baz, FooBarBaz, and FOO_BAR_BAZ are checked.

A mixed or non-conformist name (fooBar, foo_BAR, __foo, foo__bar) is checked regardless of this threshold: such a spelling is rare and, when it matches a local identifier, rarely an accident.

Types

ReferenceScope enum

How far from the documenting item a referenced name may resolve for the rule to check it, configured by reference_scope. The axis is where the referenced item lives relative to the documenting item's module, not the spelling of the use path that brought it in.

"own_module" (Rust: OwnModule)

Only items defined directly in the documenting item's own module. Every name reached through a use is left alone.

"module_tree" (Rust: ModuleTree)

Also items from within that module's own subtree (e.g. reached through use self::child::Item), but still not names that reach outside the module — use super::..., use crate::..., and imports from other crates.

"crate" (Rust: Crate)

Any item defined anywhere in the current crate (first-party), but nothing from another crate. A project's own items are where documentation drifts — a rename leaves a stale mention — while the standard library and third-party crates are stable and outside the project's control, so a bare mention of them is low risk.

"third_party" (Rust: ThirdParty)

The current crate and its third-party dependencies, but not the standard / built-in libraries (std, core, alloc, proc_macro, test). Use this when dependency references are worth checking but the frozen standard library is not.

"anywhere" (Rust: Anywhere)

Any name that resolves in scope, however it got there — including the standard library.

Source: src/rules/bare_identifier_reference.rs

perfectionist::bare_issue_reference↑ top

activeambiguous bare #NNN issue / PR reference in comment

What it does

Flags bare #NNN issue / pull-request references in doc comments (///, //!) — and, when opted in, in plain // line comments. The autofix rewrites the reference; the doc_comment_form knob selects the shape (inline [#123](URL), reference [#123], a bare URL, or a <URL> autolink).

A bare #NNN is deeply ambiguous: it might be an issue, a pull request, a colour like #123, or any other numbered item, so no suggestion is ever MachineApplicable. The suggest_issue_url / suggest_pr_url knobs choose which link target(s) the autofix offers — each MaybeIncorrect — and with neither enabled the lint is help-only. The author can also resolve the ambiguity by enclosing the token in backticks (so it reads as code) or by using a spelling without a leading #.

Why restrict this?

This is a stylistic preference, not a correctness issue. A bare #123 renders as literal text in CommonMark; only GitHub's markdown flavour autolinks the token, and only when the rendering surface is itself within a GitHub repository view. The link form renders portably across rustdoc, GitHub, and any other markdown engine.

Example

Avoid:

/// Closes #123 and supersedes #124.

Prefer: (with repository = "https://github.com/owner/repo" — forge is detected from the host), picking the issue link for one and the pull-request link for the other:

/// Closes [#123](https://github.com/owner/repo/issues/123) and
/// supersedes [#124](https://github.com/owner/repo/pull/124).
Configuration

Configure via dylint.toml under ["perfectionist::bare_issue_reference"].

forge : Forge optional

Git-hosting service the repository is on — one of github, gitlab, gitea — which fixes the issue / PR path layout. When unset, it is detected from the repository host: the public instances (github.com, gitlab.com, codeberg.org, gitea.com) and the conventional self-hosted subdomains gitlab.*, github.*, gitea.* and forgejo.* all need no forge. Set it explicitly for a self-hosted instance on a host that gives no such hint (e.g. git.example.com). If it is neither set nor detected from the host, no issue / PR link is suggested.

repository : string optional

The repository's URL, in any form you'd clone or paste: an http(s):// URL ("https://github.com/owner/repo"), an ssh:// URL ("ssh://git@github.com/owner/repo.git"), or the scp-like shorthand ("git@github.com:owner/repo.git"). No fixed default.

suggest_issue_url : boolean optional

Offer a suggestion that links the reference as an issue. Defaults to true.

suggest_pr_url : boolean optional

Offer a suggestion that links the reference as a pull request. Defaults to true. Ignored on GitLab, where a bare #NNN is always an issue (merge requests are written !NNN), so only the issue suggestion is offered there.

doc_comment_form : DocForm optional

Doc-comment fix form: inline for [#N](URL), reference for the two-piece [#N] + [#N]: URL form (the definition is appended to the doc block; in a /** */ block doc comment only the #N token is rewritten and the definition is left to the author). Defaults to inline. Ignored for plain-comment fixes — those follow plain_comment_form instead.

include_plain_comments : boolean optional

When true, also lint plain // line comments. The autofix in plain comments uses plain_comment_form's URL shape (since plain comments aren't markdown). Plain block comments (/* ... */) are out of scope regardless. Defaults to false.

plain_comment_form : PlainForm optional

Replacement form used inside plain // comments when include_plain_comments = true. Defaults to bare_url. Ignored for doc comments and when no repository is configured.

Types

Forge enum

A recognised git-hosting service. The chosen forge fixes the issue / PR URL layout. It can be given explicitly (needed for a self-hosted instance, whose host isn't recognised) or left unset and detected from the repository host.

"github" (Rust: GitHub)

GitHub or a GitHub Enterprise instance. Paths: /issues/{number}, /pull/{number}.

"gitlab" (Rust: GitLab)

GitLab (gitlab.com or self-hosted). Paths: /-/issues/{number}, /-/merge_requests/{number}.

"gitea" (Rust: Gitea)

Gitea / Forgejo (including Codeberg). Paths: /issues/{number}, /pulls/{number}.

DocForm enum

Markdown-link shape produced by the autofix inside doc comments.

"inline" (Rust: Inline)

[#123](URL) — the URL is inlined. Keeps #123 as the visible link text.

"reference" (Rust: Reference)

[#123], with a matching [#123]: URL definition appended to the end of the doc block (after a blank line so it parses as a definition). In a /** */ block doc comment the definition can't be placed safely, so there the fix rewrites only the #123 token and leaves the definition to the author.

"bare_url" (Rust: BareUrl)

https://.../issues/123 — the bare URL replaces the #123 token outright (the #123 text is not kept). NB: in a doc comment the sibling perfectionist::bare_url lint then flags the substituted URL; pick bracketed_url for a form it accepts.

"bracketed_url" (Rust: BracketedUrl)

<https://.../issues/123> — a markdown autolink replaces the #123 token outright. bare_url accepts this form.

PlainForm enum

URL shape used inside plain // comments when include_plain_comments = true.

"bare_url" (Rust: BareUrl)

Substitute the URL itself (https://...), unwrapped. Many editors auto-detect a bare URL as clickable. NB: the sibling perfectionist::bare_url lint, whose default also scans regular comments, will then flag the substituted URL — pick bracketed_url to produce a form both rules accept.

"bracketed_url" (Rust: BracketedUrl)

Substitute <https://...>. The angle-bracket delimiter gives the URL a clear boundary when it abuts surrounding punctuation; editors that auto-link URLs typically recognise it, and bare_url accepts it.

Source: src/rules/bare_issue_reference.rs

perfectionist::bare_url↑ top

activebare URL in comment or doc comment; wrap in <...> or use a labelled markdown link

What it does

Flags bare http:// and https:// URLs in doc comments (///, //!) and regular comments (//, /* */). Wrapping the URL in <...> (or using the labelled [text](url) form) is the portable rendering across CommonMark, GitHub-flavored markdown, and rustdoc.

Why restrict this?

This is a stylistic preference, not a correctness issue. Bare URLs rely on the renderer's autolinkification: rustdoc renders them, GitHub renders them, but plain CommonMark does not. The <...> form is the explicit, portable spelling.

Example

Avoid:

/// See https://example.com for details.

Prefer:

/// See <https://example.com> for details.
Configuration

Configure via dylint.toml under ["perfectionist::bare_url"].

scan_doc_comments : boolean optional

Scan doc comments (///, //!, /** */, /*! */). Defaults to true.

scan_regular_comments : boolean optional

Scan regular comments (//, /* */). Defaults to true.

safe_trailing_chars : [single-character string] optional

Characters that, when the URL ends in one of them, keep the autofix at MachineApplicable. Defaults to ["/", "_", "-", "=", "&", "+"]. ASCII alphanumerics and / are always treated as safe regardless of this list; entries here supplement that built-in set.

skip_hosts : [string] optional

Hosts to skip, compared case-insensitively. Defaults to ["localhost"].

Source: src/rules/bare_url.rs

perfectionist::clap_help_markdown↑ top

activemarkdown construct in a clap-derived doc comment leaks into --help output

What it does

Forbids markdown-specific constructs in a doc comment that clap's derive macros consume as --help text — HTML tags, inline / reference / intra-doc links, code blocks, code spans, and headings. The rule fires on the doc comment of a struct or enum deriving clap::Parser, Args, Subcommand, ValueEnum, or CommandFactory, and on the doc comments of their fields and variants.

A ValueEnum's own type-level doc comment is left alone: unlike its variant docs — clap's per-value help — it never reaches --help.

Bold, italics, and lists are not flagged by default — clap renders them as their literal characters, which usually reads cleanly — but are available through the extra_constructs knob.

The lint stays silent on a node that overrides its help text with a plain string (#[arg(help = "...")], #[command(about = "...")], ...), because the doc comment is then no longer the source of truth for --help. A node marked #[clap(verbatim_doc_comment)] instead gets a softer note that the markdown will appear verbatim in the terminal.

A project that never wants a doc comment to become --help text at all — preferring an explicit override on every command, argument, and value — can opt into the stricter require_help_override mode. It flags every clap-derived doc comment that reaches --help without such an override, markdown or not, and reports each as a single missing-override finding rather than per markdown construct. A node with no doc comment is still left alone. This mode is off by default.

Why is this bad?

By default, clap does not render doc comments through a markdown processor. The raw text is shown verbatim in the terminal --help output. Writing [`PathBuf`] produces a docs.rs link in HTML output but shows literally as [`PathBuf`] in the terminal — a classic two-audience leak. The doc comment serves both cargo doc readers and --help readers, and markdown that helps the former actively degrades the latter.

The escape hatch is to override the help text with a plain string, keeping the rich doc comment for cargo doc:

/// Builds the lockfile by walking [`Dependency`] graphs.
#[arg(help = "Builds the lockfile by walking dependency graphs.")]
pub deps: PathBuf,

Example

Avoid:

#[derive(clap::Parser)]
struct Cli {
    /// Path to the [`PackageManifest`].
    ///
    /// See [the manifest format](https://example.com/manifest).
    manifest: PathBuf,
}

Prefer: (no markdown in the help text)

#[derive(clap::Parser)]
struct Cli {
    /// Path to the package manifest.
    manifest: PathBuf,
}
Configuration

Configure via dylint.toml under ["perfectionist::clap_help_markdown"].

extra_constructs : [ConstructCategory] optional

Markdown constructs to forbid in addition to the built-in default set (html, inline_link, reference_link, intra_doc_link, code_block, code_span, heading). Empty by default; bold, italic, and list are the usual additions — clap renders them acceptably, so they are off by default.

ignore_constructs : [ConstructCategory] optional

Constructs to drop from the forbidden set, even if they appear in the built-in defaults. Empty by default; applied after the merge with extra_constructs, so this knob always wins. Use it to permit a construct in help text, e.g. ignore_constructs = ["code_span"].

require_help_override : boolean optional

For projects that never let a doc comment become --help text, preferring an explicit clap override (#[arg(help = "...")], #[command(about = "...")], ...) on every command, argument, and value. When true, the rule flags every clap-derived doc comment that reaches --help without such an override, regardless of whether it contains markdown — the doc comment stays for cargo doc, but --help must come from a plain-string override. A node with no doc comment is left alone: the requirement is only that a present doc comment never feeds --help unoverridden, so a bare #[arg(help = "...")] with no /// is fine. This supersedes the markdown scan — an override silences the markdown concern anyway — so an unoverridden doc comment is reported once as a missing override rather than per markdown construct. Defaults to false.

Types

ConstructCategory enum

A markdown construct category the rule can be configured to forbid, as it appears in the extra_constructs / ignore_constructs arrays of dylint.toml. A category is coarser than the constructs it names: reference_link covers both a [text][id] link and its [id]: dest definition, while an autolink (<https://example.com>) falls under no category at all and is never forbidden.

"html" (Rust: Html)

Raw HTML tags (<br>, <code>, <a href="...">, ...).

"inline_link" (Rust: InlineLink)

Inline links: [text](https://example.com).

"reference_link" (Rust: ReferenceLink)

Reference links ([text][id]) and their [id]: ... definitions.

"intra_doc_link" (Rust: IntraDocLink)

Intra-doc links: [`Type`] and [Type].

"code_block" (Rust: CodeBlock)

Fenced, ~~~-fenced, or four-space-indented code blocks.

"code_span" (Rust: CodeSpan)

Inline code spans: `value`.

"heading" (Rust: Heading)

ATX (# Heading) and Setext (Heading\n=====) headings.

"bold" (Rust: Bold)

**bold** / __bold__ strong emphasis.

"italic" (Rust: Italic)

*italic* / _italic_ emphasis.

"list" (Rust: List)

Bullet and ordered list markers.

Source: src/rules/clap_help_markdown.rs

perfectionist::cloning_getter↑ top

activegetter returns an owned copy of a field where a borrow would serve

What it does

Flags a getter — an inherent method taking &self whose whole body is one field of self copied out through clone, to_owned, to_string, to_vec, to_path_buf, or to_os_string — and asks for the borrowed form instead: &str for a String field, &Path for a PathBuf, &OsStr for an OsString, &CStr for a CString, &[T] for a Vec<T>, whatever the inner type borrows as under an Option or a Box, and &T otherwise.

The call has to reproduce the field's own type for a borrow to serve in its place. So a Copy field is left alone — returning it by value is the borrowed form's equal — and so is a call that renders the field rather than copying it, such as to_string on a numeric field, where no borrow of the field is a String.

An Rc or an Arc field is left alone too: cloning one bumps a refcount rather than copying what it points at, so a borrow saves nothing, and a caller that keeps the handle has to own one.

What counts as a getter is decided by the first of these that applies:

  1. to_*, into_* and as_* are conversions, never getters. Each prefix carries its own promise about cost and ownership, so the copy is the name's business rather than this rule's.
  2. A method named for a field of self is a getter. This is Rust's own convention for a getter's name, so no configuration overrides it.
  3. Any other name is a getter where the last getter_name_patterns entry matching it says so. There is always such an entry: every list's first one covers every name.

Every clause also requires the &self receiver and no other parameter.

A method of a trait impl is left alone, since the trait fixes its signature, and so is a method produced by a macro.

Test code is left alone; set exempt_tests to false to measure it like any other code.

Why restrict this?

This is a stylistic preference, not a correctness issue. A getter that clones decides for every caller that they wanted an owned copy, and most did not: they compare, print, or pass the value on. The borrowed form serves every caller, costs nothing, and leaves the one caller that does need ownership to say so with a .to_owned() at the call site, where the reader can see the copy. It also stops the getter from advertising the field's representation: a &str getter can later be backed by a Box<str> or an interned symbol without a caller changing.

Interaction with Clippy

clippy::clone_on_copy is what catches the .clone() on a Copy field that this rule leaves alone. No Clippy lint looks at what a getter returns.

Example

Avoid:

impl Person {
    fn first_name(&self) -> String {
        self.first_name.clone()
    }
    fn middle_name(&self) -> Option<String> {
        self.middle_name.clone()
    }
}

Prefer:

impl Person {
    fn first_name(&self) -> &str {
        &self.first_name
    }
    fn middle_name(&self) -> Option<&str> {
        self.middle_name.as_deref()
    }
}
Configuration

Configure via dylint.toml under ["perfectionist::cloning_getter"].

getter_name_patterns : [name-pattern string] optional

Which further method names are getters, beyond the ones the rule settles on its own. A method named for a field of self is a getter whatever this says, so every entry here speaks only about the names left over. Each takes one of four forms:

  • * — every other name is a getter.
  • prefix_* — every other name starting with prefix_ is a getter.
  • !* — no other name is a getter.
  • !prefix_* — no other name starting with prefix_ is a getter.

The first entry is * or !*, which says what every other name means before the rest of the list narrows it; a list that opened with a prefix would leave the names it does not mention resting on a baseline the reader has to know, so one is rejected. The last entry that matches a name is then the one that decides, so a later entry overrides an earlier one:

  • ["*", "!clone_*", "!cloned_*"] measures every other name, except one whose own prefix says it copies.
  • ["!*", "get_*"] measures the get_* names and no other.

Defaults to ["!*", "get_*"].

as_*, to_* and into_* are conversions, which the rule never measures whatever this says. No entry may name one, or any longer prefix under one; such an entry could not change an outcome, and is rejected at config-parse time rather than silently doing nothing.

exempt_tests : boolean optional

Whether test code is left alone: getters inside a #[cfg(test)] module or an integration-test or benchmark target. Defaults to true.

Source: src/rules/cloning_getter.rs

perfectionist::core_instead_of_std↑ top

inactiveitem named through core or alloc instead of std

What it does

Flags an item named through core:: or alloc:: when the same item is reachable through std::, and rewrites the leading crate segment to std. alloc:: paths are covered too unless also_alloc turns that half off, and individual paths can be exempted with skip_paths.

This is the counterpart of clippy::std_instead_of_core and clippy::std_instead_of_alloc, which push paths the other way — towards the narrower crate — so that a library keeps compiling without std. Enable those two on a crate that is, or may become, no_std; enable this one on a crate that has settled on std. The two directions contradict each other, so no crate wants both.

Some paths are deliberately left alone. Nothing is flagged in a #![no_std] crate, where there is no std:: to name. A path is left alone when its std:: spelling would name a different item — core::panic::PanicInfo is not std::panic::PanicInfo — and so is a macro path, because core::panic! and std::panic! are genuinely different macros. A path produced by a macro expansion is left alone as well.

Where one core token is shared by several names (use core::{fmt::Display, panic::PanicInfo};), the rewrite is offered only when every one of them is reachable through std::; otherwise the names that are get a help instead, since rewriting the shared token would change the others too.

Why restrict this?

This is a stylistic preference, not a correctness issue. Everything public in core and alloc is reachable through std, so in a crate that will always link std the spellings are interchangeable, and which one a given line uses is usually an accident — whichever crate the code was pasted from, or whichever path an editor's auto-import happened to offer. Naming one crate root throughout keeps every use line starting with the same word, and keeps an alloc:: path from requiring an extern crate alloc; that a std crate has no other use for.

Example

Avoid:

use core::fmt::Display;
use alloc::sync::Arc;

Prefer:

use std::fmt::Display;
use std::sync::Arc;
Configuration

Configure via dylint.toml under ["perfectionist::core_instead_of_std"].

also_alloc : boolean optional

Whether an alloc:: path is a violation alongside a core:: one. Set it to false in a crate that keeps its alloc:: paths on purpose — one tracking core cleanliness against a possible no_std future while the alloc dependency is permanent. Defaults to true.

skip_paths : [string] optional

Paths that are never flagged, each written as the absolute extern-crate path it is — with the leading :: and every segment spelled out, as in "::core::mem::transmute". Only a ::core:: or ::alloc:: entry can ever match, and matching is exact and syntactic against the path as written, with no re-export or alias resolution. Exempting a path also withdraws the automatic rewrite from any name sharing its crate segment (use core::{mem::transmute, fmt::Display};), because that rewrite would move the exempted path too. Defaults to [].

Source: src/rules/core_instead_of_std.rs

perfectionist::excessive_cognitive_complexity↑ top

activefunction body has a cognitive complexity above the configured maximum

What it does

Measures the cognitive complexity of every function and method body and flags the ones above max_complexity.

The measure is SonarSource's Cognitive Complexity, applied to Rust: a count of how much control flow a reader has to hold in their head, weighted by how deeply it is nested. Each construct adds to the score as follows.

ConstructIncrement
if, match, for, while, loop1 + nesting depth
else if, else, let ... else, match-arm guard1
each run of like boolean operators1
each labelled break / continue1
each recursive call1

A closure adds nothing itself but deepens the nesting of what it contains. ?, .await, return, and an unlabelled break or continue add nothing.

The score counts what the author wrote. Code produced by a macro expansion contributes nothing, so a println! or a project's own macro_rules! is as cheap as a call, though an if written inside a macro's arguments still counts. A function that is itself produced by a macro is not measured. Nested functions are measured on their own, not as part of the function that contains them.

Test code is measured like any other code; set exempt_tests to leave it alone.

Why restrict this?

This is a stylistic preference, not a correctness issue. A function that branches and loops many times over, at several levels of nesting, is doing more than one thing, and the reader has to reconstruct all of it to change any of it. The cap pushes such a function to be split into smaller ones, each named for the one thing it does, and it pushes deeply nested code to be flattened with early returns and let ... else. The metric is designed so that both moves lower the score: a match moved into its own function loses its nesting penalty, and a guard clause costs one where the if it replaces cost one per level.

Interaction with Clippy

clippy::cognitive_complexity (restriction, off by default) is a count of branches with a correction for early returns. It charges the same for a branch at the top of a function as for one five levels deep, charges every ? and every desugared while condition as a branch, and measures the expansion of a macro rather than its invocation, so a function built from a local macro_rules! can score in the hundreds. This rule scores nesting, ignores what the reader does not see, and treats a macro invocation as one step. Enable one or the other, not both.

clippy::too_many_lines and clippy::excessive_nesting measure length and depth on their own and pair well with this rule.

Example

Avoid: a nested if inside a for inside a match — the innermost if alone costs 3

fn describe(kind: Kind, items: &[Item]) -> String {
    match kind {
        Kind::Listed => {
            let mut out = String::new();
            for item in items {
                if item.visible && !item.deprecated {
                    out.push_str(&item.name);
                } else if item.deprecated {
                    out.push_str("(deprecated)");
                }
            }
            out
        }
        Kind::Counted => items.len().to_string(),
    }
}

Prefer: one function per thing, each flat

fn describe(kind: Kind, items: &[Item]) -> String {
    match kind {
        Kind::Listed => list_names(items),
        Kind::Counted => items.len().to_string(),
    }
}

fn list_names(items: &[Item]) -> String {
    items.iter().map(Item::label).collect()
}

impl Item {
    fn label(&self) -> &str {
        if self.deprecated {
            return "(deprecated)";
        }
        if self.visible { &self.name } else { "" }
    }
}

Fixing a flagged function

The diagnostic splits the score into what came from branching and what came from nesting depth. Read that split first; it says which fix is the real one.

One shortcut is worth naming, because it is tempting. The score is measured per function body, and a call counts as one step however much stands behind it, so pulling a tangled block into a private helper always lowers the number, even when it helps the reader not at all. That moves the complexity; it does not remove it. An extraction is real only when the new function stands on its own: a name that describes one responsibility, a small interface, and no mutable state threaded back to the caller. If the helper only makes sense read together with its single call site, the tangle is still there under a new name. The goal is the reader's burden, not the number.

Configuration

Configure via dylint.toml under ["perfectionist::excessive_cognitive_complexity"].

max_complexity : unsigned integer optional

The highest cognitive complexity a function may have without being flagged. Defaults to 15.

exempt_tests : boolean optional

Whether test code is left alone: functions inside a #[cfg(test)] module, #[test] functions, and everything in an integration-test or benchmark target. Defaults to false, so a test is held to the same limit as the code it exercises.

Source: src/rules/excessive_cognitive_complexity.rs

perfectionist::excessive_inline_tests↑ top

activeinline test code should be extracted to a separate file

What it does

Caps how much inline unit-test code a production file may carry. Inline test code — #[cfg(test)] mod X { ... } blocks, #[test] fns, #[cfg(test)] fn helpers, and any other #[cfg(test)] item — is summed per file. The default external_when_long style flags a file once its inline-test footprint crosses inline_max_lines (or the optional inline_max_fraction_of_file); external_only flags every inline test item regardless of length. A file whose top-level items are entirely test code is exempt — it is itself a valid extraction target.

An external #[cfg(test)] mod <name>; (its body in a separate file) is neutral: it is already extracted, so it neither charges the footprint nor is otherwise checked. A test module gated by a compound predicate that still implies test — #[cfg(all(test, unix))], #[cfg(all(test, feature = "..."))] — is recognised the same as a bare #[cfg(test)], so its external file is not re-flagged as inline test code in a production file.

Only the library or binary crate is checked. Integration tests (tests/), benchmarks (benches/), and examples (examples/) are separate targets, not the library or binary whose unit-test footprint this rule governs; for those compiled under cfg(test) their top-level #[test] functions are the target rather than unit tests misplaced in a production file, so they are left untouched.

Why restrict this?

This is a stylistic preference, not a correctness issue. Keeping a large test suite out of the production file means the file an editor tab, a grep hit, or a diff shows is production code rather than a wall of fixtures. The threshold is deliberately configurable because the exact budget varies by project.

Example

Avoid:

// File: foo.rs
#[cfg(test)]
mod tests {
    /* ... 200 lines of test code ... */
}

Prefer:

// File: foo.rs
mod tests;
// File: foo/tests.rs
/* ... 200 lines of test code ... */
Configuration

Configure via dylint.toml under ["perfectionist::excessive_inline_tests"].

inline_style : InlineStyle optional

How inline test code is handled. Defaults to external_when_long.

inline_max_lines : unsigned integer optional

Absolute cap, in lines, on the summed inline-test footprint of a file under external_when_long; always active. Defaults to 50.

inline_max_fraction_of_file : float optional

Optional relative cap: the share inline_test_lines / file_lines a file's inline tests may occupy under external_when_long. Accepted values are 0.0 <= x < 1.0; omit the key to disable the relative cap (the default).

Types

InlineStyle enum

How inline test code is treated (the inline_style knob).

"external_only" (Rust: ExternalOnly)

Every inline test item is flagged; all test code must move to an external mod <name>;, whatever its length.

"external_when_long" (Rust: ExternalWhenLong)

Inline test code is allowed up to the configured budget; beyond that it must move to an external mod <name>;.

Source: src/rules/excessive_inline_tests.rs

perfectionist::excessive_nesting↑ top

activefunction body nests constructs deeper than the configured maximum

What it does

Measures how deeply the constructs in a function or method body nest and flags the body when the deepest point is more than max_depth (default 3) levels down.

Each construct is a level: an if (an else if stays at the same level; an else body is inside), a match (its arms are inside), a for, while, or loop, a closure, the body of a let ... else, and a free-standing block such as an unsafe block or the block a let initialises from. The block that is a construct's own body is not a level of its own, so if ready { work() } is 1 level, not 2.

The depth counts what the author wrote. A construct produced by a macro expansion is not a level, though an if written inside a macro's arguments still counts; ? and .await are not levels, a for or while loop counts once, and the body of an async fn or async block is not a level of its own. A function produced by a macro is not measured, and a nested function is measured on its own.

Test code is measured like any other code; set exempt_tests to leave it alone.

Why restrict this?

This is a stylistic preference, not a correctness issue. Each level of nesting is a condition the reader has to keep true in their head while reading everything inside it; past three, the code at the deepest point can only be understood by re-reading the way in. Deep nesting almost always flattens: a guard clause or let ... else returns early instead of wrapping the rest, an arm guard folds a condition into a pattern, continue unindents a loop body. Extracting the inner levels answers it just as well, when the new function can be named for what it does rather than where it came from and needs few of the enclosing locals; one that takes most of them as parameters has moved the nesting into an argument list rather than removed it.

Interaction with Clippy

clippy::excessive_nesting (complexity, but inert until excessive-nesting-threshold is set) counts every brace pair from the crate root, mod, impl, and fn included, so a method in an impl in a module already sits at three before its first if, and one threshold has to serve files of every shape. This rule measures each function body on its own, from zero, and counts constructs rather than braces. Enable one or the other, not both.

Example

Avoid: 4 levels — for, if let, match, if

for entry in entries {
    if let Some(meta) = entry.metadata() {
        match meta.kind() {
            Kind::File => {
                if meta.len() > limit {
                    report(entry);
                }
            }
            Kind::Dir => descend(entry),
        }
    }
}

Prefer: 2 levels — a let ... else guard in place of the if let, an arm guard in place of the if

for entry in entries {
    let Some(meta) = entry.metadata() else { continue };
    match meta.kind() {
        Kind::File if meta.len() > limit => report(entry),
        Kind::File => {}
        Kind::Dir => descend(entry),
    }
}

Prefer: the same body, cut where it has a name of its own — report_or_descend is named for what it does and takes three values, none of them the loop's state. That leaves 2 levels on each side of the cut.

for entry in entries {
    if let Some(meta) = entry.metadata() {
        report_or_descend(entry, meta, limit);
    }
}

fn report_or_descend(entry: Entry, meta: Meta, limit: u64) {
    match meta.kind() {
        Kind::File => {
            if meta.len() > limit {
                report(entry);
            }
        }
        Kind::Dir => descend(entry),
    }
}
Configuration

Configure via dylint.toml under ["perfectionist::excessive_nesting"].

max_depth : unsigned integer optional

The deepest a construct may sit without the function being flagged. Defaults to 3.

exempt_tests : boolean optional

Whether test code is left alone: functions inside a #[cfg(test)] module, #[test] functions, and everything in an integration-test or benchmark target. Defaults to false, so a test is held to the same limit as the code it exercises.

Source: src/rules/excessive_nesting.rs

perfectionist::exhaustive_error_enums↑ top

inactiveerror-shaped type is missing #[non_exhaustive]

What it does

Flags publicly-exposed error enums that lack a #[non_exhaustive] attribute. An enum is treated as an error enum when its name ends in Error (configurable) or it implements std::error::Error. Publicly-exposed sum-like structs (a single field whose type is itself an enum) follow the same rule.

"Publicly-exposed" defaults to pub items; pub(crate) and the whole-crate "every item" sweep are configurable.

Why restrict this?

This is a stylistic preference, not a correctness issue. Adding a variant to an error enum is one of the most common reasons to publish a new minor version of an error-producing library, and #[non_exhaustive] is the standard way to make that addition not a SemVer break for downstream pattern matches. Applying it up front means future variants land without a coordinated major release across the dependents that exhaustively match on the enum.

The opinion is opt-in: some projects deliberately use exhaustive error enums to force downstream consumers to handle every new variant, and binary crates have no SemVer surface to protect. The rule is therefore inactive by default — enable it per crate by adding to dylint.toml:

[perfectionist]
enable = ["exhaustive_error_enums"]

Interaction with Clippy

clippy::exhaustive_enums and clippy::exhaustive_structs (restriction, off by default) require #[non_exhaustive] on every exported enum / struct. This rule scopes that same requirement to error types — those whose name ends in Error or that implement std::error::Error — because adding a variant to an error type downstream code matches on is the breaking change #[non_exhaustive] exists to absorb, while demanding it on all exported types is usually too broad. Enable the Clippy lints instead if you do want that blanket policy.

Example

Avoid:

#[derive(Debug)]
pub enum RuntimeError {
    SerializationFailure,
}

Prefer:

#[derive(Debug)]
#[non_exhaustive]
pub enum RuntimeError {
    SerializationFailure,
}
Configuration

Configure via dylint.toml under ["perfectionist::exhaustive_error_enums"].

require_for : RequireFor optional

Visibility threshold for the rule.

extra_suffixes : [string] optional

Additional identifier suffixes that mark a type as "an error" purely by name, without inspecting its trait implementations. Merged with the built-in defaults (["Error"]); empty by default. List project-specific vocabulary here (Failure, Fault, ...) without having to re-state the standard suffix.

ignore_suffixes : [string] optional

Identifier suffixes to drop from the by-name match set, even if they appear in the built-in defaults or in extra_suffixes. Empty by default; checked after the merge with the built-ins, so this knob always wins. Use it when a project deliberately does not want the Error suffix to trigger the by-name branch — types that implement std::error::Error are still flagged via the trait branch.

Types

RequireFor enum

"pub" (Rust: Pub)

Require #[non_exhaustive] on items that are effectively reachable from outside the crate (declared pub, re-exported pub, and not buried inside a non-pub module). A pub enum FooError inside a non-pub module is not flagged because it cannot be matched on by any downstream crate.

"pub_crate" (Rust: PubCrate)

In addition to the Pub case, require #[non_exhaustive] on items literally declared pub(crate) (i.e., restricted to the crate root). Items declared pub(in some::module) are not promoted by this mode even if their effective reach happens to extend to the crate root.

"all" (Rust: All)

Require #[non_exhaustive] on every error-shaped item regardless of visibility.

Source: src/rules/exhaustive_error_enums.rs

perfectionist::folded_command_setter↑ top

activefold over a singular CommandExtra setter re-implements the plural

What it does

Flags a fold over a singular command_extra::CommandExtra setter where the trait declares the plural that does the same thing, and names the plural. The pairs are with_arg against with_args, with_env against with_envs, and without_env against without_envs.

Every spelling of the setter counts, a closure that only forwards to it included.

The rule names a plural only where the code can call it.

The fold's receiver has to be a place expression followed by at most one argument-less method call. VARS, list.iter() and self.names.into_iter() qualify; a longer one is left alone.

A fold inside the plural's own body is left alone where the suggestion would make the plural call itself.

Why restrict this?

This is a stylistic preference, not a correctness issue. The fold is correct and produces an identical Command.

The preference is that a fold over a builder setter makes the reader reconstruct an operation the API already names. fold is a general-purpose combinator, so meeting one obliges the reader to work out what is being accumulated and in what order before they can see that the answer is "these variables are removed". without_envs says that outright, and the receiver stops being an accumulator threaded through a closure.

The fold form also has more places to get wrong. A fold whose closure swaps its accumulator and item, or returns the wrong one of the two, compiles in some shapes and silently builds the wrong command. The plural has no such surface.

command-extra defines each plural as the fold of its singular, so a hand-rolled fold is not an alternative to the plural but its body, inlined.

Applicability

The fix is applied only where it is known to compile and to behave as the fold did. Elsewhere the suggestion is advice, with a help line saying what to check first.

The suggestion keeps the receiver's call unless the plural makes that same call itself.

Example

Avoid:

INHERITED_VARS
    .iter()
    .fold(Command::new("cargo"), CommandExtra::without_env)

Prefer:

Command::new("cargo").without_envs(INHERITED_VARS.iter())

Avoid:

flags
    .iter()
    .fold(Command::new("ls"), |command, flag| command.with_arg(flag))

Prefer:

Command::new("ls").with_args(flags.iter())

Configuration: none.

Source: src/rules/folded_command_setter.rs

perfectionist::import_granularity_mismatch↑ top

activeimport granularity does not match the configured import_granularity_mismatch.style

What it does

Enforces a single project-wide import-granularity style, chosen via style:

The names map one-to-one onto rustfmt's unstable imports_granularity (Crate / Module / Item). Only use statements that sit next to each other in a module body, share a visibility, and carry matching attributes are merged; the three respect_* knobs tighten or loosen that grouping.

Under crate style a name that is both an item and a module (use crate::thing; next to use crate::thing::T;) has two valid one-use shapes — the self-fold crate::thing::{self, T} and the sibling-split crate::{thing, thing::T} — that bind different namespaces. By default neither single-statement form is flagged and both are offered when a merge is forced; the optional self_merge knob (fold / split) picks one and enforces it.

Globs (use foo::*) are governed by perfectionist::wildcard_imports, not by this rule: a top-level glob is left alone under item.

Why restrict this?

This is a stylistic preference, not a correctness issue. None of the three shapes is wrong in the abstract — the violation is a mismatch with the project's configured style. Enforcing one keeps use blocks scanning uniformly and makes import diffs predictable. rustfmt can enforce the same shape, but only on the nightly channel; this lint gives stable-toolchain projects a hard CI check instead of a silent reformat.

Example

Style: Module (default)

Avoid:

use std::collections::HashMap;
use std::collections::BTreeMap;

Prefer:

use std::collections::{BTreeMap, HashMap};

Style: Crate

Avoid:

use std::collections::{BTreeMap, HashMap};
use std::io::Read;

Prefer:

use std::{collections::{BTreeMap, HashMap}, io::Read};

Style: Item

Avoid:

use std::collections::{BTreeMap, HashMap};

Prefer:

use std::collections::BTreeMap;
use std::collections::HashMap;
Configuration

Configure via dylint.toml under ["perfectionist::import_granularity_mismatch"].

style : Style optional

Import-granularity style to enforce. Defaults to module — the shape that scales best as a use block grows. Set crate to collapse every crate root into one nested use, or item to put every imported name on its own line.

respect_cfg_blocks : boolean optional

Never merge use statements that carry differing #[cfg(...)] / #[cfg_attr(...)] attributes. Defaults to true: a platform-gated import is never folded together with an unconditional one. Set false to ignore cfg attributes when deciding what may merge.

respect_visibility : boolean optional

Never merge a pub use (or pub(crate) use, etc.) with a plain use, or two re-exports whose visibility differs. Defaults to true. Set false to ignore visibility when deciding what may merge.

respect_doc_comments : boolean optional

Never merge a use that carries its own doc comment (/// or #[doc = "..."]) into a neighbouring statement, so the comment keeps describing exactly the import it was written above. Defaults to true. Set false to allow such a use to merge.

self_merge : SelfMerge optional

Under style = "crate", force one shape when a path segment names both an item and a module (use crate::thing; next to use crate::thing::T;). Unset by default — the two shapes are not interchangeable, so the rule flags neither single-statement form and offers both as MaybeIncorrect when a merge is forced. Set fold to always enforce use crate::thing::{self, T};, or split to always enforce use crate::{thing, thing::T};; either value also flags the opposite single-statement form. Ignored under module and item style.

Types

Style enum

Import-granularity style. The three values map one-to-one onto rustfmt's unstable imports_granularity option (Crate, Module, Item).

"crate" (Rust: Crate)

One use per crate root. Every shared prefix is collapsed into nested braces, e.g. use std::{collections::HashMap, io::{Error, ErrorKind}};.

"module" (Rust: Module)

One use per leaf module. Items pulled from the same module are merged into a single braced list; items from sibling modules sit on their own use lines, e.g. use std::collections::{BTreeMap, HashMap};.

"item" (Rust: Item)

One use per leaf item. Every imported name lives on its own line, e.g. use std::collections::BTreeMap;.

SelfMerge enum

How to resolve a path segment that names both an item and a module under crate style — use crate::thing; next to use crate::thing::T;. The two one-use-per-root shapes are not interchangeable, so unless this knob is set the rule offers both and the author picks. Only consulted under style = "crate".

"fold" (Rust: Fold)

Always enforce the self-fold use crate::thing::{self, T}; — the shape rustfmt's imports_granularity = "Crate" produces. The sibling-split form is flagged and rewritten to it.

"split" (Rust: Split)

Always enforce the sibling-split use crate::{thing, thing::T};. The self-fold form is flagged and rewritten to it.

Source: src/rules/import_granularity_mismatch.rs

perfectionist::import_grouping_mismatch↑ top

inactiveimport grouping does not match the configured import_grouping_mismatch.style

What it does

Enforces a single project-wide grouping style for the run of use statements at the top of a module body. The rule is inactive by default; a project opts in and sets style to one of:

Orthogonally to style, the reexports knob controls pub re-exports — any use with an explicit visibility. Like style it is mandatory once the rule is enabled, and is one of: grouped, which pulls every re-export into one contiguous leading block above the private imports, visibility outranking path and cfg gating; split, which breaks that block into two — submodule re-exports (pub use child::Item;, a multi-segment path) above alias re-exports (pub use Item; / pub use Item as Alias;, a single-segment path); or by_path, which gives re-exports no dedicated block, classifying each by its path like a private import.

This rule only governs the partitioning of imports into blocks. Whether items within each use are merged or split is the job of perfectionist::import_granularity_mismatch.

Why restrict this?

This is a stylistic preference, not a correctness issue. Neither layout is wrong in the abstract — the violation is a mismatch with the project's configured style. Enforcing one keeps import blocks scanning uniformly and makes import diffs predictable. rustfmt's group_imports option can enforce the same shape, but only on the nightly channel; this lint gives stable-toolchain projects a hard CI check instead of a silent reformat. The rule is inactive by default; enable it and pick a style in dylint.toml:

[perfectionist]
enable = ["import_grouping_mismatch"]

["perfectionist::import_grouping_mismatch"]
style = "multi_block"
reexports = "grouped"

Example

Style: Multi block

Avoid:

use clap::Parser;
use std::time::Duration;
use crate::args::Args;

Prefer:

use std::time::Duration;

use crate::args::Args;

use clap::Parser;

Style: Single block

Avoid:

use std::time::Duration;

use crate::args::Args;

use clap::Parser;

Prefer: (one contiguous block; inner order left to cargo fmt)

use clap::Parser;
use crate::args::Args;
use std::time::Duration;

Style: Single block, #[cfg]-gated imports

Avoid: (cfg-gated imports mixed into the one block)

use std::fs::Metadata;
#[cfg(unix)]
use std::os::unix::fs::MetadataExt;
use super::size::Bytes;

Prefer: (cfg-gated imports kept in a trailing block)

use std::fs::Metadata;
use super::size::Bytes;

#[cfg(unix)]
use std::os::unix::fs::MetadataExt;

Re-exports in their own block (reexports = "grouped")

Avoid: (a pub use re-export mixed in with private imports)

pub use reflection::Reflection;
use super::size;

Prefer: (re-exports kept in their own leading block)

pub use reflection::Reflection;

use super::size;

Split re-exports (reexports = "split")

Avoid: (submodule re-exports and alias re-exports intermixed)

pub use Reflection as HardlinkListReflection;
pub use iter::Iter;
pub use reflection::Reflection;

Prefer: (submodule re-exports lead, alias re-exports follow)

pub use iter::Iter;
pub use reflection::Reflection;

pub use Reflection as HardlinkListReflection;
Configuration

Configure via dylint.toml under ["perfectionist::import_grouping_mismatch"].

style : Style mandatory

The grouping style to enforce: single_block or multi_block. It has no default — a project enabling the rule states which layout it wants — so it must be set when the rule is enabled.

reexports : ReexportGrouping mandatory

How pub re-exports are grouped: grouped, split, or by_path. Like style it has no default — a project enabling the rule states how it wants re-exports laid out — so it must be set when the rule is enabled. grouped pulls every re-export into one contiguous leading block above all private imports; split breaks that block into two — submodule re-exports (pub use child::Item;) above alias re-exports (pub use Item; / pub use Item as Alias;); by_path gives re-exports no dedicated block at all, classifying each by its path like a private import.

order : [Group] optional

The order the groups appear in, top to bottom. Defaults to ["std", "internal", "thirdparty"].

cfg_block_handling : CfgBlockHandling optional

How #[cfg(...)]-gated imports are grouped. Defaults to trailing: a cfg-gated import forms its own trailing block under both styles. Set merge to keep cfg-gated imports with the rest — in their natural path group under multi_block, or in the single block under single_block.

Types

Style enum

How use statements are partitioned into blocks.

"single_block" (Rust: SingleBlock)

Every use statement sits in one contiguous block, with no blank lines between imports.

"multi_block" (Rust: MultiBlock)

Imports are partitioned into ordered groups separated by exactly one blank line. The group set is std (std / core / alloc / proc_macro / test), internal (crate / super / self), and third-party (every other crate).

ReexportGrouping enum

How pub re-exports are grouped relative to the private imports. A re-export is any use with an explicit visibility (pub, pub(crate), pub(super), pub(in ...)); a private (Inherited) import is not one.

"by_path" (Rust: ByPath)

Re-exports get no dedicated block: each is classified purely by its path, exactly like a private import, so a pub use child::Item sits in the same block as a private import of the same origin.

"grouped" (Rust: Grouped)

Every re-export is pulled into one contiguous leading block above all private imports, separated by a blank line. A cfg-gated re-export stays in this block rather than the trailing cfg block: visibility takes precedence, keeping the public surface together.

"split" (Rust: Split)

Re-exports form a leading region split into two blank-separated blocks: submodule re-exports (a multi-segment path such as pub use child::Item;, which lifts an item out of a child module) above alias re-exports (a single-segment path such as pub use Item; or pub use Item as Alias;, which only renames an item already in scope). The single-vs-multi-segment split is purely syntactic; a pub use foo; that re-exports an external crate counts as an alias re-export. As under grouped, a cfg-gated re-export stays in its re-export sub-block rather than the trailing cfg block.

Group enum

One of the three groups a use statement is classified into. The order knob is a permutation of these three values.

"std" (Rust: Std)

std, core, alloc, proc_macro, test.

"internal" (Rust: Internal)

crate, super, self.

"thirdparty" (Rust: Thirdparty)

Every other crate.

CfgBlockHandling enum

How a #[cfg(...)]-gated import is grouped. An import is gated whether the cfg is written directly or applied through a #[cfg_attr(<cfg>, cfg(...))]; a cfg_attr that applies any other attribute leaves the import unconditionally present and does not gate it.

"trailing" (Rust: Trailing)

Give every #[cfg(...)]-gated import its own trailing block, regardless of the imported path: an always-last group under multi_block, a trailing block below the single block under single_block.

"merge" (Rust: Merge)

Keep a cfg-gated import with the rest: slotted into its natural path group under multi_block, or left in the single block under single_block.

Source: src/rules/import_grouping_mismatch.rs

perfectionist::impure_macro_arguments↑ top

activemacro invocation passes an impure expression that should be bound to a let first

What it does

Flags impure expressions passed as top-level arguments to a function-like (name!(...)) or array-like (name![...]) macro invocation. The fix is to bind the expression to a let first and pass the binding instead, guaranteeing exactly-once evaluation.

Curly-brace invocations (name! { ... }) are out of scope: by convention they are DSL bodies (thread_local! { ... }, quote! { ... }, html! { ... }) where the evaluation contract is the macro's, not the call site's.

Why is this bad?

A function-like or array-like macro may evaluate any top-level argument zero, one, or many times depending on its matcher. Functions guarantee exactly-once evaluation per argument; macros do not, even when the call shape looks identical. The classic case is debug_assert_eq!:

debug_assert_eq!(map.insert(key, value), None, "duplicate");

In debug builds the call runs and the assertion holds. In release builds debug_assertions is off, the body folds to if false { ... }, and the argument expressions are not evaluated — insert never runs and the map ends the function in a state the author did not intend. The bug only surfaces under --release.

The same trap covers any macro that expands its capture more than once (min!/max!-style, retry loops): a side-effecting expression repeated produces wrong results.

Terminology

In this rule, pure means safe for the surrounding macro to drop or duplicate: evaluating the argument zero, one, or many times is observationally equivalent. Impure is anything else, and is what the rule flags.

The classification is syntactic: the rule recognises a curated set of shapes known to satisfy the property and treats everything else as impure. A const fn call, a Result::map chain over a pure base, or vec.fold(...) is therefore impure under this rule unless its shape is recognised — the lint cannot prove side-effect-freedom in general, only spot it. The trade-off favours flagging side-effect-free expressions over silently passing a real hazard. The set is narrower than the functional-programming notion of purity and is keyed to what a macro can actually do with its captures, not to side-effect-freedom in the abstract.

The recognised pure shapes are: literals, paths, field accesses, indexing of pure bases, dereferences, references, the logical / bitwise not of a pure expression (!ready), casts, the unit literal (), parenthesised / tuple / array-literal / array-repeat groups whose elements are all pure, binary chains of pure operands joined by side-effect-free operators, zero-arg method calls whose name is in the curated pure-getter set (len, is_empty, as_str, as_bytes, as_ref, as_mut, as_deref, as_slice, plus anything in extra_pure_methods), and calls to core / std macros whose expansion is a compile- time constant (concat!, env!, option_env!, include_str!, include_bytes!, stringify!, cfg!, line!, column!, file!, module_path!, plus anything in extra_pure_macros). A comparison like vec.len() <= cap evaluates the same way regardless of how many times the macro touches it, so binding it to a let would only force the comparison to run in release builds for no benefit; the same logic applies to env!("HOME") inside debug_assert_eq!(...) — there is nothing to evaluate at runtime.

Example

Bad:

debug_assert_eq!(map.insert(key, value), None, "duplicate");

Good:

let ejected = map.insert(key, value);
debug_assert_eq!(ejected, None, "duplicate");
Configuration

Configure via dylint.toml under ["perfectionist::impure_macro_arguments"].

mode : Mode optional

Eligibility mode. Defaults to allow_and_deny.

deny_extra : [string] optional

Macros added to the built-in deny set. Each entry is a fully-qualified macro path (no trailing !) or a bare macro name to match by final segment only.

allow_extra : [string] optional

Macros added to the built-in allow set. Each entry is a fully-qualified macro path (no trailing !) or a bare macro name to match by final segment only. Only meaningful in AllowAndDeny and Blanket modes; in DenyOnly the allow set is unused.

ignore : [string] optional

Macros to skip entirely, regardless of which set they would otherwise match. Each entry is a fully-qualified macro path (no trailing !) or a bare macro name to match by final segment only.

extra_pure_methods : [string] optional

Method names added to the built-in pure-method list. Each entry is a bare method identifier (no (), no receiver). A .method() invocation on a pure base is then accepted as a pure postfix when the method takes no arguments. Add a project-local method here only when it is genuinely safe for the surrounding macro to drop or duplicate the call (the rule's working definition of pure) — typically an O(1) side-effect-free getter that the lint's syntactic classification can't otherwise see.

ignore_pure_methods : [string] optional

Method names to drop from the pure-method list, even if they appear in the built-in defaults or in extra_pure_methods. Empty by default; checked after the merge, so this knob always wins. Useful for opting back into linting on a default entry the project does not consider pure — for example, removing as_ref for a project that wraps it in an impure implementation.

extra_pure_macros : [string] optional

Macro names added to the built-in pure-macro list. Each entry is matched against the invocation's final path segment (so my_crate::const_str matches by the "const_str" tail). A pure-macro call passed as an argument to another macro is treated as a pure atom — the rule does not propose binding it to a let. Use this knob for project-specific macros whose expansion is a compile-time constant (a literal, a &'static str, a bool); their inclusion satisfies the rule's pure-as-drop-or-duplicate-safe definition trivially, since there is no runtime expression for the surrounding macro to drop or duplicate.

ignore_pure_macros : [string] optional

Macro names to drop from the pure-macro list, even if they appear in the built-in defaults or in extra_pure_macros. Checked after the merge, so this knob always wins.

Types

Mode enum

Eligibility mode.

"deny_only" (Rust: DenyOnly)

Flag only invocations of the curated deny set (debug_assert* plus deny_extra). Every other macro is silently accepted.

"blanket" (Rust: Blanket)

Flag every function-like or array-like invocation that carries an impure top-level argument, regardless of any built-in classification — unless the invocation matches an allow_extra entry. The built-in allow set is deliberately ignored in this mode; project exceptions go in allow_extra.

"allow_and_deny" (Rust: AllowAndDeny)

Curated deny set plus curated allow set, both extensible via deny_extra / allow_extra. Macros classified by neither are flagged — flagging unrecognised macros is deliberate so the rule remains useful in projects that depend on uncatalogued proc macros.

Source: src/rules/impure_macro_arguments.rs

perfectionist::lint_attribute_trailing_comment↑ top

activetrailing comment on a lint-level attribute should be lifted into a reason = "..." field

What it does

When a lint-level attribute (#[allow], #[expect], #[warn], #[deny], #[forbid]) carries a trailing // ... line comment — on the same source line as the attribute's closing ] — that documents why the level was chosen, lifts the comment into the attribute's reason = "..." field and removes the original comment.

Only the trailing placement counts: a same-line comment after ] is unambiguously about the attribute. A comment on the preceding line is intentionally out of scope — it is just as often documentation for the next item as it is attribute rationale, and a static check cannot tell the two apart.

Doc comments (///, //!) and block comments (/* ... */) are out of scope.

Why restrict this?

This is a stylistic preference, not a correctness issue. reason = "..." is part of the attribute and travels with it through every refactor; a free-floating comment can be separated from its attribute by an unrelated edit. Compiler diagnostics render the reason field in the lint's message, so the rationale reaches the reader at the moment of confusion. One canonical location for the rationale also removes the "is this comment for the attribute, or for the next item?" question.

Example

Avoid:

#[allow(clippy::too_many_arguments)] // matches upstream signature
fn build_fetcher(/* ... */) {}

Prefer:

#[allow(clippy::too_many_arguments, reason = "matches upstream signature")]
fn build_fetcher(/* ... */) {}

Configuration: none.

Source: src/rules/lint_attribute_trailing_comment.rs

perfectionist::macro_trailing_comma↑ top

activemacro invocation does not follow rustfmt's vertical trailing-comma policy

What it does

For function-like macro invocations whose top-level arguments are comma-separated, enforces rustfmt's trailing_comma = "Vertical" policy that rustfmt itself does not apply inside macro bodies: multi-line invocations must end with a trailing comma; single-line invocations must not.

Eligibility is name-based — a curated list of core / std and well-known third-party macros (vec!, format!, println!, assert_eq!, dbg!, log::info!, tracing::debug!, anyhow::bail!, maplit::hashmap!, ...), extended via extra_macros and overridden via ignore.

Attribute-style invocations (#[derive(...)], #[serde(...)], etc.) are out of scope.

Why restrict this?

This is a stylistic preference, not a correctness issue. rustfmt's default trailing_comma = "Vertical" policy keeps argument lists uniform: every multi-line list ends with a comma, every single-line list does not. rustfmt opts out of macro bodies because a macro matcher can make the trailing comma load-bearing; for the curated macros covered by this lint, it cannot, and the policy applies without risk.

Multi-line invocations whose first top-level token starts on the opening-delimiter line (visual-indent / compact layout, e.g. vec![Inner { ... }]) are skipped: rustfmt's Vertical policy only adds a trailing comma when each top-level item is on its own line, separate from the delimiter, and strips any comma added to the compact shape. The two tools have to agree.

Example

Avoid:

let xs = vec![
    1,
    2,
    3
];
let ys = vec![1, 2, 3,];

Prefer:

let xs = vec![
    1,
    2,
    3,
];
let ys = vec![1, 2, 3];
Configuration

Configure via dylint.toml under ["perfectionist::macro_trailing_comma"].

extra_macros : [string] optional

Additional macro paths to treat as name-based eligible, on top of the curated built-in list. Each entry is matched by its final path segment, so "my_crate::vec_like" and "vec_like" both target invocations whose last segment is vec_like. Empty by default. Only add macros whose trailing comma is syntactically optional at the top level; macros that treat the comma as a fully optional separator throughout (rather than only at the tail) should not be listed here.

ignore : [string] optional

Macro paths to opt out of the rule, even if they would otherwise be eligible via the built-in list or extra_macros. Matched by final path segment, like extra_macros. Checked first, so this knob always wins over eligibility. Empty by default.

Source: src/rules/macro_trailing_comma.rs

perfectionist::mutating_command_builder↑ top

activea command setter taking &mut self where command-extra's by-value form exists

What it does

Flags a setter called on an owned command — arg, args, env, envs, env_remove, env_clear, current_dir, stdin, stdout, stderr — and names the command_extra::CommandExtra counterpart that takes self instead of &mut self. Any command the trait covers counts: std::process::Command, a Box of one, and, behind a feature apiece, tokio::process's and async_process's.

A receiver it could not take ownership of — a &mut Command, or a field reached through one — is left alone. So is a crate that neither depends on command-extra nor belongs to a workspace declaring it; command_extra_dependency sets how far the lint looks for that declaration. Where command-extra is in the build at all, a chain is left alone unless every setter in it has a counterpart there — the by-value forms arrived over several releases, and a chain is ported whole or not at all.

A fix is applied where the whole change is known: a chain is rewritten at once, never in part. Elsewhere the diagnostic describes the change, and may show the rename without applying it.

Why restrict this?

This is a stylistic preference, not a correctness issue. Both forms build the same command, and the standard library's form is not wrong.

The preference is that a builder whose steps each yield a value composes with everything else, and one whose steps yield a borrow composes with nothing. Because Command's setters return &mut Command, a chain of them cannot be returned from a function, cannot initialise a let in one expression, cannot fill a struct field, and cannot be an accumulator. Each of those forces a mut binding whose only job is to exist until the settings are done. The by-value form removes that intermediate.

Example

Avoid:

fn lister(dir: &Path) -> Command {
    let mut command = Command::new("ls");
    command
        .current_dir(dir)
        .args(["-l", "-a"])
        .env("LANG", "C");
    command
}

Prefer:

fn lister(dir: &Path) -> Command {
    Command::new("ls")
        .with_current_dir(dir)
        .with_args(["-l", "-a"])
        .with_env("LANG", "C")
}
Configuration

Configure via dylint.toml under ["perfectionist::mutating_command_builder"].

command_extra_dependency : RequiredDeclaration optional

How far to look for a declaration of command-extra before the lint will name its methods. Without the crate somewhere in reach, the diagnostic would name something the author cannot write. Defaults to workspace: a workspace that has settled on the crate has settled for its members, so a member that has not inherited it yet is told all the same — what it is missing is a line in a manifest. Narrow it to crate where the members are deliberately not uniform, or widen it to unchecked to hear from everywhere, including where the method named cannot be written yet.

Types

RequiredDeclaration enum

Which manifest has to declare command-extra.

"crate" (Rust: Crate)

What Cargo gave the crate under lint to compile against, whether the manifest names a version of its own or inherits the workspace's with command-extra.workspace = true. A sibling that has not inherited it is left alone, and so is a build script, which is compiled against [build-dependencies] alone.

"workspace" (Rust: Workspace)

The manifest of the crate under lint, or the [workspace.dependencies] table of the workspace it belongs to, whether or not the crate has inherited the entry yet.

"unchecked" (Rust: Unchecked)

No declaration is required: the lint speaks in every crate, including one where the method it names cannot be written until the dependency arrives.

Source: src/rules/mutating_command_builder.rs

perfectionist::named_prelude_imports↑ top

activenamed item cherry-picked from a prelude module instead of glob-imported

What it does

Flags a use statement that cherry-picks a named item out of a prelude module (use serde::prelude::Serialize;) and leaves the glob form (use serde::prelude::*;) alone. The set of segment names treated as preludes is configurable via prelude_segment_names (default ["prelude"]), and individual prelude paths can be exempted with allowed_paths.

This is the dual of perfectionist::wildcard_imports: that rule restricts globs in general but lets preludes glob freely, while this rule restricts named imports from a prelude and lets the glob form through.

Why restrict this?

This is a stylistic preference, not a correctness issue. A prelude module is, by convention, a curated set of items the crate author decided should always travel together as a glob. Cherry-picking individual items from a prelude defeats that intent and usually means the importer should reach into the prelude's source module instead.

One rewrite covers the whole use: a statement with several cherry-picks carries it once, and a help on the rest. Where the rewrite cannot be shown to preserve what the use binds, only the help is offered.

Example

Avoid:

use serde::prelude::Serialize;
use diesel::prelude::{table, AsChangeset};

Prefer:

From the canonical module: import each item where it actually lives.

use serde::Serialize;
use diesel::{table, AsChangeset};

As a prelude glob: pull in the whole curated set at once.

use diesel::prelude::*;
Configuration

Configure via dylint.toml under ["perfectionist::named_prelude_imports"].

prelude_segment_names : [string] optional

Path segment names recognised as preludes. Matches the knob of the same name on perfectionist::wildcard_imports, so a project can flip both rules with one value. Defaults to ["prelude"].

allowed_paths : [string] optional

Prelude module paths whose named imports are never flagged — the module path leading up to and including the prelude segment. Each entry is absolute: an extern-crate path with a leading :: ("::diesel::prelude"), or a crate-root path written "crate::prelude". Because an entry matches the path up to and including the prelude segment, it must end with a prelude_segment_names segment. Matching is exact and syntactic: the entry must equal the path as written in the use (up to the prelude segment), with no re-export or alias resolution. Defaults to [].

Source: src/rules/named_prelude_imports.rs

perfectionist::needless_borrowed_parameters↑ top

activeborrowed parameter is only used to produce its owned form

What it does

Flags a function or method parameter taken by shared reference (&str, &Path, &OsStr, &CStr, &[T]) whose only use in the body is to produce its owned counterpart (String, PathBuf, OsString, CString, Vec<T>) — via to_owned, to_string, to_path_buf, to_vec, to_os_string, clone, into, or String::from(..) and friends. Such a parameter should take the owned form directly.

Only the conservative single-use case is flagged: the parameter must be referenced exactly once, that use must be the conversion, and the conversion must execute unconditionally — no if / match / loop, closure, or short-circuiting && / || may sit between it and the enclosing function. This is deliberately conservative: even the always-executed if condition and match scrutinee positions count as disqualifying, not just the branch arms.

Test code and build scripts are exempt by default; exempt_tests and exempt_build_scripts turn either off.

Why restrict this?

This is a stylistic preference, not a correctness issue. Taking the borrowed form and converting inside the body forces a copy even when the caller already owns the value. Taking the owned form moves the caller's value straight in; a caller that holds only a borrow performs the very same conversion at the call site that the borrowed signature was performing internally. The total number of copies is identical for the worst caller and one cheaper for the best caller.

Interaction with Clippy

clippy::needless_pass_by_value (pedantic, off by default) covers the opposite direction: a by-value parameter that is never consumed should be taken by reference. clippy::ptr_arg (style, on by default) is orthogonal — it rewrites a &String / &Vec<T> / &PathBuf parameter to the more permissive &str / &[T] / &Path. Neither moves a borrowed parameter to its owned form, so enabling all three gives full coverage of the owned-vs-borrowed trade-off.

Example

Avoid:

fn push_path(list: &mut Vec<PathBuf>, item: &Path) {
    list.push(item.to_path_buf());
}

Prefer:

fn push_path(list: &mut Vec<PathBuf>, item: PathBuf) {
    list.push(item);
}
Configuration

Configure via dylint.toml under ["perfectionist::needless_borrowed_parameters"].

extra_conversion_methods : [string] optional

Additional method names that count as a conversion of the borrowed parameter to its owned form. Merged with the built-in defaults (["to_owned", "to_string", "to_path_buf", "to_vec", "to_os_string", "clone", "into"]); empty by default. A flagged conversion must still actually produce the owned counterpart of the parameter's type, so listing an unrelated method here never widens the lint beyond owned-producing calls.

ignore_conversion_methods : [string] optional

Method names to drop from the conversion set, even if they appear in the built-in defaults or in extra_conversion_methods. Empty by default; checked after the merge with the built-ins, so this knob always wins.

exempt_tests : boolean optional

Whether test code is exempt: anything gated to test builds by #[cfg(test)] (or a compound predicate implying it), anything inside a #[test] function, and every tests/ or benches/ crate. An examples/ crate is not covered. Defaults to true.

exempt_build_scripts : boolean optional

Whether a build script — build.rs, or whatever Cargo.toml's build key names — is exempt. Defaults to true.

Source: src/rules/needless_borrowed_parameters.rs

perfectionist::overly_complex_condition↑ top

activecondition has more boolean operators than the configured maximum

What it does

Counts the boolean operators (&&, ||) in the condition of an if, if let, while, while let, or match-arm guard, and flags the condition when the count is above max_operators.

Only the condition itself is counted, not the branches it selects, and a closure inside the condition is a scope of its own. An && with a let on either side of it is not counted, since no binding can replace it. A condition produced by a macro expansion is not measured, though a condition written inside a macro's arguments is. The let that binds a boolean is not a condition, so naming the expression is what satisfies the rule.

Why restrict this?

This is a stylistic preference, not a correctness issue. A condition of that many clauses is a predicate the author had in mind but did not write down; the reader has to reconstruct it from the clauses, and a later editor has to work out which clause to change. Binding the predicate, or the part of it that names a concept, to a let gives it the name the author had, puts a debugger-visible value on it, and turns the if back into a sentence.

Example

Avoid:

if entry.is_file()
    && !entry.is_hidden()
    && entry.len() > 0
    && entry.depth() < max_depth
    && !ignored.contains(entry.path())
{
    copy(entry);
}

Prefer: a leading part, which a let runs exactly when the if would have

let is_visible_file =
    entry.is_file() && !entry.is_hidden() && entry.len() > 0;
if is_visible_file
    && entry.depth() < max_depth
    && !ignored.contains(entry.path())
{
    copy(entry);
}

Avoid: a part that follows a clause and reads a binding from the chain

if let Some(entry) = next_entry()
    && entry.depth() < max_depth
    && entry.is_file()
    && !entry.is_hidden()
    && entry.len() > 0
    && !ignored.contains(entry.path())
{
    copy(entry);
}

Prefer: a closure, which stays unevaluated until the condition reaches it

let is_visible_file =
    |entry: &Entry| entry.is_file() && !entry.is_hidden() && entry.len() > 0;
if let Some(entry) = next_entry()
    && entry.depth() < max_depth
    && is_visible_file(&entry)
    && !ignored.contains(entry.path())
{
    copy(entry);
}
Configuration

Configure via dylint.toml under ["perfectionist::overly_complex_condition"].

max_operators : unsigned integer optional

The most && and || operators a condition may have without being flagged. Defaults to 3.

Source: src/rules/overly_complex_condition.rs

perfectionist::overly_long_file↑ top

activesource file has more lines of code than the configured maximum

What it does

Counts the lines of code in each source file of the crate — the crate root and every mod name; that lives in a file of its own — and flags a file with more than max_lines (default 500).

A line counts when it holds anything other than whitespace and comments, so blank lines, comment-only lines, doc comments, and the lines a block comment spans are free. An inline mod name { ... } is part of the file that holds it, not a file of its own.

A file of test code — a mod tests; behind #[cfg(test)], or any file of an integration-test or benchmark target — is measured like any other; set exempt_tests to leave it alone.

Why restrict this?

This is a stylistic preference, not a correctness issue. A file is the unit a reader opens, searches, and scrolls; when one grows past a few hundred lines it is holding more than one concern, and the reader has to find the boundaries between them that a module split would have drawn. The cap pushes a growing file to become a directory of files named for what each does, and it stops the next addition from landing wherever the file happened to be open.

Example

Avoid: src/config.rs holding the settings struct, the parser for each of three file formats, the environment overlay, and the validation, in 2000 lines.

Prefer: src/config.rs declaring the struct and pub mod env; pub mod json; pub mod toml; pub mod validate; pub mod yaml;, each a file a reader can take in whole.

Configuration

Configure via dylint.toml under ["perfectionist::overly_long_file"].

max_lines : unsigned integer optional

The most lines of code a file may have without being flagged. Defaults to 500.

exempt_tests : boolean optional

Whether files of test code are left alone: a module behind #[cfg(test)], and every file of an integration-test or benchmark target. Defaults to false, so a test file is held to the same limit as the code it exercises.

Source: src/rules/overly_long_file.rs

perfectionist::overly_long_function↑ top

activefunction body has more lines of code than the configured maximum

What it does

Counts the lines of code in a function or method body and flags the body when the count is above max_lines.

A line counts when it holds anything other than whitespace and comments, so blank lines, comment-only lines, doc comments, and the lines a block comment spans are free. The braces that open and close the body are not counted. A function produced by a macro is not measured. A nested function's lines count towards the function that contains it, since they sit in its body.

Test code is measured like any other code; set exempt_tests to leave it alone.

Why restrict this?

This is a stylistic preference, not a correctness issue. A function that runs past a screen is read in pieces, and the reader has to hold the pieces they have scrolled past. A cap on length is the plainest of the size measures, and the one that catches a body that is long without being complex — a sequence of straight-line steps, each of which would read better as a function named for what it does.

Interaction with Clippy

clippy::too_many_lines (pedantic, off by default) measures the same thing, but it measures a function produced by a macro defined in the same crate and skips only what another crate's macro produced, where this rule skips every generated function, whose line count tracks the macro body's layout rather than what a reader scrolls through. Clippy also has no exemption for test code, and its default cap is 100. This rule exists so a project can hold its functions to the same limit it holds their complexity and their local bindings to, with the same knobs. Enable one or the other, not both.

Example

Avoid: a body that reads, validates, transforms, and writes in one run of eighty lines.

Prefer: a body of four calls, each to a function that reads, validates, transforms, or writes.

Configuration

Configure via dylint.toml under ["perfectionist::overly_long_function"].

max_lines : unsigned integer optional

The most lines of code a function body may have without being flagged. Defaults to 50.

exempt_tests : boolean optional

Whether test code is left alone: functions inside a #[cfg(test)] module, #[test] functions, and everything in an integration-test or benchmark target. Defaults to false, so a test is held to the same limit as the code it exercises.

Source: src/rules/overly_long_function.rs

perfectionist::overly_long_method_chain↑ top

inactiveexpression chain has more method calls than the configured maximum

What it does

Counts the distinct method calls in one expression chain — a.b().c().d() is three — and flags the chain when the count is above max_calls (default 5).

Only method calls on the chain's spine count: the receiver of each call, down to the value the chain starts from. A run of the same method — .arg("-v").arg("build").arg(path) — counts once, so a builder is measured by its distinct calls. A ? or an .await between two calls neither counts nor breaks the chain. A field access (self.items.iter() starts at self.items) and a function call (Vec::new().push(1) starts at Vec::new()) end the spine. A closure's body is measured on its own, so a chain inside a map(|item| ...) is a chain of its own. A chain produced by a macro expansion is not measured.

Test code is measured like any other code; set exempt_tests to leave it alone.

Where a chain stops being readable is a matter of taste, and a codebase written around iterator pipelines will disagree with one written around named intermediates. The rule is therefore inactive by default — enable it per crate by adding to dylint.toml:

[perfectionist]
enable = ["overly_long_method_chain"]

Why restrict this?

This is a stylistic preference, not a correctness issue. A long chain is a pipeline whose intermediate values have no names, so the reader has to infer what each stage produces from the method that consumes it. Up to a handful of stages that is what makes iterator code read well; past that, the reader is holding a stack of anonymous types. Binding a stage to a let named for what it holds, or moving a run of stages into a function named for what it does, gives the reader the name the chain withheld.

Example

Avoid:

let names = manifest
    .dependencies
    .iter()
    .filter(|(_, spec)| spec.is_workspace())
    .map(|(name, _)| name.as_str())
    .filter(|name| !name.starts_with('@'))
    .map(str::to_owned)
    .collect::<Vec<_>>()
    .join(", ");

Prefer:

let workspace_deps = manifest
    .dependencies
    .iter()
    .filter(|(_, spec)| spec.is_workspace())
    .map(|(name, _)| name.as_str());
let unscoped: Vec<String> = workspace_deps
    .filter(|name| !name.starts_with('@'))
    .map(str::to_owned)
    .collect();
let names = unscoped.join(", ");
Configuration

Configure via dylint.toml under ["perfectionist::overly_long_method_chain"].

max_calls : unsigned integer optional

The most method calls one chain may have without being flagged. Defaults to 5.

exempt_tests : boolean optional

Whether test code is left alone: chains inside a #[cfg(test)] module, a #[test] function, or an integration-test or benchmark target. Defaults to false, so a test is held to the same limit as the code it exercises.

Source: src/rules/overly_long_method_chain.rs

perfectionist::overly_long_print_macro↑ top

activesplittable print macro with an embedded-newline template exceeds the configured line width

What it does

Flags a println!-style macro call whose format template embeds a \n newline and whose source line is wider than max_line_width display columns, and folds the template across lines with the backslash-newline continuation escape:

println!(
    "error: The error was caused by {err_src}\n\
    hint: Run {magic_cmd} to solve the problem",
);

The rewrite is byte-for-byte output-preserving: every \n stays, and the trailing \<newline><indent> continuation strips exactly the source newline and indentation it adds.

Eligibility is name-based — a curated list of the macros whose output is unchanged by the fold (println!, eprintln!, print!, eprint!, writeln!, write!, and the log family log! / error! / warn! / info! / debug! / trace!), replaced wholesale via target_macros. Macros that return a value (format!, format_args!) or terminate (panic!, assert!, the debug_assert* family, ...) are deliberately out of scope.

A template that is a runtime expression rather than a string literal, a raw string literal, or a template with no foldable interior \n, is left alone.

Why restrict this?

This is a stylistic preference, not a correctness issue. A long single line whose string already contains \n is hard to read and hard to scan in a diff; folding it at the embedded newlines lets each output line read as its own source line without changing a byte of what the program prints.

Example

Avoid:

println!("error: The error was caused by {err_src}\nhint: Run {magic_cmd} to solve the problem");

Prefer:

println!(
    "error: The error was caused by {err_src}\n\
    hint: Run {magic_cmd} to solve the problem",
);
Configuration

Configure via dylint.toml under ["perfectionist::overly_long_print_macro"].

max_line_width : unsigned integer optional

Source-line width that triggers the rule. The width is the Unicode display width of the line containing the macro invocation, not its byte length, so a line of CJK text is measured the way a terminal renders it. Common alternatives to the default 100 are 80 (terminal) or 120 (wide editors).

target_macros : [string] optional

Macros eligible for folding, each a "a::b::c"-style path (no trailing !). A single-segment entry matches by the invocation's final segment (so "info" covers log::info!); a multi-segment entry tail-matches the invocation path.

Replaces the built-in list wholesale when present, so a project adding one macro must re-state the rest. Defaults to the stdout / stderr writers (println, eprintln, print, eprint), the Write writers (writeln, write), and the log family (log, error, warn, info, debug, trace). Every one of those is single-segment, so error covers log::error! and tracing::error! alike.

Source: src/rules/overly_long_print_macro.rs

perfectionist::redundant_derive_more_forward_template↑ top

activederive_more formatting template only restates the forward the derive already performs

What it does

Flags a derive_more formatting attribute whose template is nothing but the forward the derive already performs, and suggests deleting it.

A formatting derive on a container with exactly one field forwards to that field, so a template that does nothing but name that field says nothing the derive does not — written inline (#[display("{_0}")], #[display("{the_only_field}")]) or as an argument naming that field (#[display("{}", _0)]), on a struct or on a single-field enum variant. An enum-level #[display("{_variant}")] is the container-level counterpart: it names exactly what each variant would be formatted with anyway.

Each of derive_more's formatting derives is read through its own helper attribute — Display through #[display(...)], LowerHex through #[lower_hex(...)], and so on — but the trait a template forwards to comes from the placeholder, not from the attribute's name. So #[lower_hex("{_0}")] stays unflagged: a bare {} forwards to Display, which is not what the LowerHex derive would have done.

#[debug(...)] is never flagged. derive_more's Debug derive defaults to the struct-shaped Wrapper("inner") output rather than to a forward, so its template always changes the rendering.

A stray positional index warns without an autofix. #[display("{1}", _0)] forwards to the field regardless of the index, so it is redundant — but {1} names an argument that was never supplied, so the fix (delete, or supply the argument) is left to the author.

Beyond that the rule is silent wherever deleting the attribute would change the generated impl — among them an adorned placeholder such as #[display("{_0:>8}")], a variant under any enum-level template, an argument that is not a bare field name (#[display("{}", self.0)]), a bound(...) written beside the template, a cfg-gated container or field, and a template inside a #[cfg_attr(...)].

The rule runs only in a crate where a derive_more derive actually expands. Within one, a derive renamed on import (use derive_more::Display as D;) is not recognised; a re-export under the same name is.

Why restrict this?

This is a stylistic preference, not a correctness issue: the attribute compiles to exactly the code the derive emits without it. It is the same shape of dead weight as an unused import or a needless borrow — it restates what the compiler already does, and a reader has to read the whole template before concluding it changes nothing.

Example

Avoid:

#[derive(derive_more::Display)]
#[display("{_0}")]
struct SanitizedHtml(String);

Prefer:

#[derive(derive_more::Display)]
struct SanitizedHtml(String);

Configuration: none.

Source: src/rules/redundant_derive_more_forward_template.rs

perfectionist::single_letter_closure_param↑ top

activeclosure parameter has a single-letter name

What it does

Flags closure parameters whose identifier is one ASCII letter, unless the closure is a trivial single-expression callback or the identifier is in the conventional-name exempt set (n for an unsigned count, f for a fmt::Formatter, i / j / k for indices). "Single-expression" is a shared precondition for the trivial-callback exception: the body must be a bare expression or a block whose only content is a trailing expression — a body with any let binding or other statement before the trailing expression disqualifies the closure regardless of which branch below would otherwise apply. Given that, one of two further shapes must hold:

The conventional-name exempt set matches the one used by perfectionist::single_letter_function_param: |i| ... is the canonical index closure, just as fn step(i: usize) is the canonical index parameter. Bodies that use the index for slicing or arithmetic (|i| &hex[i..i + 2]) are not structurally trivial, so the exempt set is what keeps them out of the diagnostic.

Why restrict this?

This is a stylistic preference, not a correctness issue. A multi-line closure body whose parameter is a single letter forces the reader to scroll back to the closure header for context on every reference. The trivial-callback exception covers sort_by(|a, b| ...) and .map(|x| x.field) shapes that are short enough that the parameter's role is unambiguous from the call site.

Interaction with Clippy

clippy::min_ident_chars (restriction, off by default) also flags single-character closure parameters, under a single crate-wide threshold and one shared allow-list. It cannot express this rule's trivial-callback exemption (.map(|x| x.field), sort_by(|a, b| ...)) or its conventional-name set, so it flags the short callbacks this rule deliberately allows. Enabling both is redundant — choose the context-aware behaviour here, or min_ident_chars for the one-knob sweep.

Example

Avoid:

.map(|t| {
    let columns = build_columns(t);
    format_row(&columns)
})

Prefer:

.map(|tree_row| {
    let columns = build_columns(tree_row);
    format_row(&columns)
})
Configuration

Configure via dylint.toml under ["perfectionist::single_letter_closure_param"].

extra_trivial_callback_methods : [string] optional

Additional method / function names whose closure argument may carry single-letter parameters when the body is a single expression. Merged with the built-in defaults (the curated core / std callbacks plus selected itertools and into-sorted adaptors); empty by default. List project-specific DSL helpers (when, iter_by, third-party callbacks such as into_sorted_by, ...) here without having to re-state the standard ones.

ignore_trivial_callback_methods : [string] optional

Method / function names to drop from the trivial-callback set, even if they appear in the built-in defaults or in extra_trivial_callback_methods. Empty by default; checked after the merge with the built-ins, so this knob always wins. Useful for opting back into linting on a default entry the project does not consider trivial.

extra_allowed_idents : [single-letter string] optional

Additional identifiers to allow as closure parameter names. Merged with the built-in defaults (["n", "f", "i", "j", "k"]); empty by default. Use this to whitelist project-specific conventional names without having to re-state the standard ones. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

extra_denied_idents : [single-letter string] optional

Identifiers to deny (always flag), removing them from the exempt set even if they appear in the built-in defaults or in extra_allowed_idents. Empty by default; checked after the merge with the built-ins, so this knob always wins. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

Source: src/rules/single_letter_closure_param.rs

perfectionist::single_letter_const_generic↑ top

activeconst generic parameter has a single-letter name

What it does

Flags const generic parameter declarations (<const N: usize>) whose identifier is one ASCII letter.

Why restrict this?

This is a stylistic preference, not a correctness issue. A single-letter const generic parameter is opaque at every use site; a descriptive identifier (LEN, COLS, LANES) documents the parameter's role both at the declaration and at every substitution.

Interaction with Clippy

clippy::min_ident_chars (restriction, off by default) flags short identifiers in many positions, but explicitly skips const generic parameters — its visitor returns early on GenericParamKind::Const, so it never flags <const N: usize>. This rule covers exactly that gap, so the two are complementary rather than redundant: enable both to also catch short bindings, parameters, and item names.

Example

Avoid:

struct Data<Left, Right, const M: usize, const N: usize> {
    left:  [Left;  M],
    right: [Right; N],
}

Prefer:

struct Data<Left, Right, const LEFT_LEN: usize, const RIGHT_LEN: usize> {
    left:  [Left;  LEFT_LEN],
    right: [Right; RIGHT_LEN],
}
Configuration

Configure via dylint.toml under ["perfectionist::single_letter_const_generic"].

allowed_idents : [single-letter string] optional

Identifiers the rule will not flag. Empty by default. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

Source: src/rules/single_letter_const_generic.rs

perfectionist::single_letter_const_item↑ top

activeconst item has a single-letter name

What it does

Flags const items (free, associated, and block-level) whose identifier is one ASCII letter.

Why restrict this?

This is a stylistic preference, not a correctness issue. A single-letter const item is opaque at every use site, and the item's scope (module-wide or crate-wide for pub const) makes that opacity propagate. A descriptive identifier (DIMENSION, BUFFER_LEN, MAX_RETRIES) carries its own documentation.

Interaction with Clippy

clippy::min_ident_chars (restriction, off by default) also flags single-character const item names, under a single crate-wide min-ident-chars-threshold and one shared allow-list that spans several kinds at once — bindings, parameters, and item names. (It skips generic and const-generic parameters, which the single_letter_* family handles separately.) This rule governs const items alone, with its own allowed_idents. Enabling both is redundant for const items — choose per-kind control here, or min_ident_chars for the one-knob sweep.

Example

Avoid:

const N: usize = 2;

Prefer:

const DIMENSION_COUNT: usize = 2;
Configuration

Configure via dylint.toml under ["perfectionist::single_letter_const_item"].

allowed_idents : [single-letter string] optional

Identifiers the rule will not flag. Empty by default. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

Source: src/rules/single_letter_const_item.rs

perfectionist::single_letter_function_param↑ top

activefunction parameter has a single-letter name

What it does

Flags function and method parameters whose identifier is one ASCII letter, except for a curated set of conventional names (n for an unsigned count, f for a fmt::Formatter, i / j / k for indices).

Why restrict this?

This is a stylistic preference, not a correctness issue. Parameter names are the first piece of documentation a caller reads (in rustdoc, in IDE hover tips, in error messages). A descriptive parameter name carries that documentation; a single letter does not.

Interaction with Clippy

clippy::min_ident_chars (restriction, off by default) also flags single-character function parameters, under a single crate-wide min-ident-chars-threshold and one shared allow-list spanning several kinds at once (bindings, parameters, item names). This rule governs parameters alone and ships parameter-tuned default allowances (n, f, i, j, k) plus an independent extra_allowed_idents / extra_denied_idents pair. Enabling both is redundant for parameters — choose per-kind control here, or min_ident_chars for the one-knob sweep.

Example

Avoid:

fn write_row(w: &mut Writer, t: &TreeRow) -> io::Result<()> { ... }

Prefer:

fn write_row(writer: &mut Writer, tree_row: &TreeRow) -> io::Result<()> { ... }
Configuration

Configure via dylint.toml under ["perfectionist::single_letter_function_param"].

extra_allowed_idents : [single-letter string] optional

Additional identifiers to allow as function or method parameter names. Merged with the built-in defaults (["n", "f", "i", "j", "k"]); empty by default. Use this to whitelist project-specific conventional names without having to re-state the standard ones. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

extra_denied_idents : [single-letter string] optional

Identifiers to deny (always flag), removing them from the exempt set even if they appear in the built-in defaults or in extra_allowed_idents. Empty by default; checked after the merge with the built-ins, so this knob always wins. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

Source: src/rules/single_letter_function_param.rs

perfectionist::single_letter_generic↑ top

activegeneric type parameter has a single-letter name

What it does

Flags generic type parameters whose identifier is one ASCII letter (T, U, K, V, ...).

Why restrict this?

This is a stylistic preference, not a correctness issue. Single-letter generic names propagate through the type signatures and bounds; they force every reader to scroll back to the declaration to recover the role of each parameter. Descriptive names (Element, Key, Reader) keep complex signatures self-documenting.

Interaction with Clippy

clippy::min_ident_chars (restriction, off by default) flags short identifiers in many positions, but explicitly skips generic type parameters — its visitor returns early on GenericParamKind::Type, so it never flags <T>. This rule covers exactly that gap, so the two are complementary rather than redundant: enable both to also catch short bindings, parameters, and item names.

Single-character lifetime names ('a) are likewise out of scope for the single_letter_* family; clippy::single_char_lifetime_names (restriction) covers those.

Example

Avoid:

pub fn collect_keys<K, V>(map: BTreeMap<K, V>) -> Vec<K> {
    /* fifty lines */
}

Prefer:

pub fn collect_keys<Key, Value>(map: BTreeMap<Key, Value>) -> Vec<Key> {
    /* fifty lines */
}

Configuration: none.

Source: src/rules/single_letter_generic.rs

perfectionist::single_letter_let_binding↑ top

activelet binding has a single-letter name

What it does

Flags let x = ...; bindings whose identifier is one ASCII letter.

Why restrict this?

This is a stylistic preference, not a correctness issue. A descriptive let binding documents what the right-hand side computed; a single-letter name does not. The rule allows let n = ... and other names in a configurable set of exempt identifiers for the well-worn cases (unsigned counts).

Interaction with Clippy

clippy::min_ident_chars (restriction, off by default) also flags single-character let bindings, under a single crate-wide min-ident-chars-threshold and one shared allow-list spanning several kinds at once (bindings, parameters, item names). This rule governs let bindings alone, with its own exempt set. Enabling both is redundant for let bindings — choose per-kind control here, or min_ident_chars for the one-knob sweep.

Example

Avoid:

let m = entry.metadata()?;

Prefer:

let metadata = entry.metadata()?;
Configuration

Configure via dylint.toml under ["perfectionist::single_letter_let_binding"].

extra_allowed_idents : [single-letter string] optional

Additional identifiers to allow as let binding names. Merged with the built-in defaults (["n"]); empty by default. Use this to whitelist project-specific conventional names without having to re-state the standard ones. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

extra_denied_idents : [single-letter string] optional

Identifiers to deny (always flag), removing them from the exempt set even if they appear in the built-in defaults or in extra_allowed_idents. Empty by default; checked after the merge with the built-ins, so this knob always wins. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

Source: src/rules/single_letter_let_binding.rs

perfectionist::single_letter_static_item↑ top

activestatic item has a single-letter name

What it does

Flags static items whose identifier is one ASCII letter.

Why restrict this?

This is a stylistic preference, not a correctness issue. A single-letter static item is opaque at every use site, and the item's scope (module-wide or crate-wide for pub static) makes that opacity propagate. A descriptive identifier (BUFFER, CACHE, COUNTER) carries its own documentation.

Interaction with Clippy

clippy::min_ident_chars (restriction, off by default) also flags single-character static item names, under a single crate-wide min-ident-chars-threshold and one shared allow-list that spans several kinds at once — bindings, parameters, and item names. (It skips generic and const-generic parameters, which the single_letter_* family handles separately.) This rule governs static items alone, with its own allowed_idents. Enabling both is redundant for static items — choose per-kind control here, or min_ident_chars for the one-knob sweep.

Example

Avoid:

static N: AtomicUsize = AtomicUsize::new(0);

Prefer:

static REQUEST_COUNT: AtomicUsize = AtomicUsize::new(0);
Configuration

Configure via dylint.toml under ["perfectionist::single_letter_static_item"].

allowed_idents : [single-letter string] optional

Identifiers the rule will not flag. Empty by default. Each entry is a single ASCII letter (a-z, A-Z); any other character is rejected at config-parse time.

Source: src/rules/single_letter_static_item.rs

perfectionist::thiserror_usage↑ top

activethiserror import, derive, or attribute; this catalogue prefers derive_more::{Display, Error}

What it does

Flags every use of thiserror in the consumer crate. These syntactic shapes trigger the lint:

  1. Derives. #[derive(thiserror::Error)] directly, or #[derive(Error)] / #[derive(te::Error)] when a sibling use thiserror::Error; / use thiserror as te; brings the derive macro into scope under any local name, anywhere in the crate. #[cfg_attr(_, derive(thiserror::Error))] is unwrapped (including nested cfg_attr).
  2. Attributes. #[error(...)] attributes attached to an item the rule has already classified as thiserror-derived, on the item, its enum variants, or its fields. #[cfg_attr(_, error(...))] is unwrapped symmetrically with the derive side.
  3. Imports. Every use or extern crate statement that brings a thiserror path into scope: use thiserror::*, use thiserror::Error, use thiserror::Error as MyError;, use thiserror::{self as te};, use thiserror as te;, extern crate thiserror;, extern crate thiserror as te;, the braced top-level form use {thiserror::Error, ...};, and pub use re-exports.

The lint is detection-only: it emits a help-style diagnostic pointing at the offending site and suggests migrating to #[derive(derive_more::Display, derive_more::Error)]. There is no autofix — the migration involves a mix of derive-list edits, format-string positional translation (thiserror's {0} ↔ derive_more's {_0}), attribute renames (#[error(...)] ↔ #[display(...)]), and edge cases (#[error(transparent)], #[backtrace]) whose mechanical rewrite is too risky to apply without review.

Alias collection is crate-wide rather than per-module: a use thiserror::Error; anywhere in the crate makes the bare #[derive(Error)] short-hand resolve as thiserror everywhere. In practice that overlap is rare and the rule treats it as acceptable false-positive surface; a project that hits it can suppress individual sites with #[expect(perfectionist::thiserror_usage)].

Why restrict this?

This is a stylistic preference, not a correctness issue. The catalogue picks derive_more for error formatting and source chaining. Mixing in thiserror fragments the attribute vocabulary across the codebase and adds a second derive crate that has no functional capability derive_more lacks. A project that wants the choice the other way around can disable this rule.

Example

Avoid:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum MyError {
    #[error("missing field {0}")]
    MissingField(MissingFieldError),
}

Prefer:

use derive_more::{Display, Error};

#[derive(Debug, Display, Error)]
pub enum MyError {
    #[display("missing field {_0}")]
    MissingField(MissingFieldError),
}

Configuration: none.

Source: src/rules/thiserror_usage.rs

perfectionist::too_many_local_bindings↑ top

activefunction body binds more distinct local names than the configured maximum

What it does

Counts the distinct names a function or method body binds — through let, if let, while let, let ... else, for, match arms, and the parameters of closures it contains — and flags the body when the count is above max_bindings.

A name counts once however many times it is bound, so shadowing (let input = input.trim();) is free. A name that begins with _ is not counted, nor is a name invented by a macro expansion or by a compiler desugaring, nor are the function's own parameters. A name the author passed into a macro does count, even though the let around it came from the expansion. A nested function is counted on its own, not as part of the function that contains it. A function that is itself produced by a macro is not counted.

Test code is counted like any other code; set exempt_tests to leave it alone.

Why restrict this?

This is a stylistic preference, not a correctness issue. Every local name is a value the reader has to keep track of, and a body that needs many of them at once is usually several steps written out in sequence, each with its own intermediates. Unlike a branch or a loop, such a body has no control flow for a complexity measure to see, so it passes those checks however long it grows. The cap pushes each step into a function of its own, whose signature then names the few values that cross between them, or pushes related intermediates into a struct.

Interaction with Clippy

clippy::too_many_arguments caps the names that enter a function through its signature; this rule caps the ones the body introduces. clippy::too_many_lines caps the body's length instead, which a wrapped chain or a long literal can exhaust without introducing a single name.

Example

Avoid: one body that reads, parses, validates, and reports

fn load(path: &Path) -> Result<Config, Error> {
    let raw = fs::read_to_string(path)?;
    let document = toml::from_str::<Document>(&raw)?;
    let registry = document.registry.unwrap_or_default();
    let registry_url = Url::parse(&registry)?;
    let store_dir = document.store_dir.map(PathBuf::from);
    let store_dir_exists = store_dir.as_deref().is_some_and(Path::exists);
    let concurrency = document.concurrency.unwrap_or(16);
    let concurrency_clamped = concurrency.clamp(1, 64);
    // ... eight more
    Ok(Config { registry_url, store_dir, concurrency: concurrency_clamped, .. })
}

Prefer: one function per step

fn load(path: &Path) -> Result<Config, Error> {
    let document = read_document(path)?;
    Ok(Config {
        registry_url: parse_registry(&document)?,
        store_dir: resolve_store_dir(&document),
        concurrency: clamp_concurrency(&document),
    })
}
Configuration

Configure via dylint.toml under ["perfectionist::too_many_local_bindings"].

max_bindings : unsigned integer optional

The most distinct local names a function body may bind without being flagged. Defaults to 15.

exempt_tests : boolean optional

Whether test code is left alone: functions inside a #[cfg(test)] module, #[test] functions, and everything in an integration-test or benchmark target. Defaults to false, so a test is held to the same limit as the code it exercises.

Source: src/rules/too_many_local_bindings.rs

perfectionist::too_many_struct_fields↑ top

activestruct has more fields than the configured maximum

What it does

Counts the fields of every named or tuple struct and flags any with more than max_fields.

A struct produced by a macro expansion is not measured. Enum variants and unions are not measured.

Test code is measured like any other code; set exempt_tests to leave it alone.

Why restrict this?

This is a stylistic preference, not a correctness issue. A struct with many fields is a function with that many parameters in disguise: every constructor names them all, every reader of the type holds them all, and a change to one is a change to a type that everything depends on. Past a handful the fields fall into groups, and each group is a smaller struct with a name of its own, which the functions that only need that group can then take instead.

Some structs cannot be regrouped, because their shape is fixed from outside: one that mirrors a configuration file or a wire format has the fields the format has. Write #[expect(perfectionist::too_many_struct_fields, reason = "...")] at the site, and name the format in the reason.

Interaction with Clippy

clippy::struct_excessive_bools (pedantic) caps the bool fields alone, as a signal that a struct is really a state machine; clippy::too_many_arguments caps a function's parameters. Neither caps a struct's fields as a whole.

Example

Avoid:

struct Client {
    base_url: Url,
    token: Option<String>,
    username: Option<String>,
    password: Option<String>,
    ca_file: Option<PathBuf>,
    cert_file: Option<PathBuf>,
    key_file: Option<PathBuf>,
    retries: u32,
    retry_factor: f64,
    retry_min_timeout: Duration,
    retry_max_timeout: Duration,
    timeout: Duration,
}

Prefer:

struct Client {
    base_url: Url,
    auth: Auth,
    tls: Tls,
    retry: RetryPolicy,
    timeout: Duration,
}

struct Auth {
    token: Option<String>,
    username: Option<String>,
    password: Option<String>,
}

struct Tls {
    ca_file: Option<PathBuf>,
    cert_file: Option<PathBuf>,
    key_file: Option<PathBuf>,
}

struct RetryPolicy {
    retries: u32,
    factor: f64,
    min_timeout: Duration,
    max_timeout: Duration,
}
Configuration

Configure via dylint.toml under ["perfectionist::too_many_struct_fields"].

max_fields : unsigned integer optional

The most fields a struct may have without being flagged. Defaults to 10.

exempt_tests : boolean optional

Whether test code is left alone: structs inside a #[cfg(test)] module, structs inside a #[test] function, and everything in an integration-test or benchmark target. Defaults to false, so a test fixture is held to the same limit as the code it exercises.

Source: src/rules/too_many_struct_fields.rs

perfectionist::uncombined_self_import↑ top

inactivea module import and an adjacent item import from it can be combined through self

What it does

Folds two adjacent use statements — one importing a module and one importing an item from that same module — into a single use module::{self, item};. The rule is inactive by default; a project opts in via [perfectionist].enable.

Why restrict this?

This is a stylistic preference, not a correctness issue. A project that wants a module and the items it re-exports to travel under one use enables this rule so the grouping is applied uniformly rather than decided case by case:

[perfectionist]
enable = ["uncombined_self_import"]

The autofix is MaybeIncorrect whenever it narrows the namespaces an import brings into scope: folding a bare use module; into {self, ...} imports only the module, while the bare form imports every namespace named module (type, value, macro) — a difference that matters only in the rare case where a value or macro shares the module's name in the same parent.

Interaction with Clippy

clippy::unnecessary_self_imports (restriction, off by default) is the opposite policy: it flags a sole use module::{self}; and unwraps it to use module;. It matches only the single-item {self} form, so it never touches the multi-item {self, item} groups this rule produces. The two are mirror images — pick one direction and do not enable both.

Example

Avoid:

use foo::bar;
use foo::bar::Baz;

Prefer:

use foo::bar::{self, Baz};

Configuration: none.

Source: src/rules/uncombined_self_import.rs

perfectionist::unicode_ellipsis_in_comments↑ top

activeU+2026 HORIZONTAL ELLIPSIS in non-doc comments; prefer ...

What it does

Forbids U+2026 HORIZONTAL ELLIPSIS (…) in regular // and /* */ comments. Doc comments (///, //!) are covered by a sibling lint.

Why restrict this?

This is a stylistic preference, not a correctness issue. ASCII ... survives every encoding round-trip, every terminal, every grep invocation, and every git diff viewer without rendering as ? or a tofu box. The Unicode form usually arrives by accident from autocorrect.

Example

Avoid:

// TODO: handle the empty-tree case…

Prefer:

// TODO: handle the empty-tree case...
Configuration

Configure via dylint.toml under ["perfectionist::unicode_ellipsis_in_comments"].

extra_flagged_chars : [single-character string] optional

Extra characters to flag alongside U+2026. Useful for catching near-relatives such as U+22EF MIDLINE HORIZONTAL ELLIPSIS (⋯) or U+2025 TWO DOT LEADER (‥) that the same autocorrect pipelines occasionally insert. Empty by default.

scan_line_comments : boolean optional

Scan // line comments. Defaults to true.

scan_block_comments : boolean optional

Scan /* ... */ block comments. Defaults to true.

Source: src/rules/unicode_ellipsis_in_comments.rs

perfectionist::unicode_ellipsis_in_docs↑ top

activeU+2026 HORIZONTAL ELLIPSIS in doc comments; prefer ...

What it does

Forbids U+2026 HORIZONTAL ELLIPSIS (…) in doc comments — /// and //! line forms and the /** */ / /*! */ block forms. Prefer the three-ASCII-dot form .... Regular // and /* */ comments are covered by a sibling lint (perfectionist::unicode_ellipsis_in_comments).

Why restrict this?

This is a stylistic preference, not a correctness issue. ASCII ... survives every encoding round-trip, every terminal, every copy-paste, every grep invocation, and every git diff viewer without rendering as ? or a tofu box. The visual difference between … and ... is small enough that the Unicode form usually arrives by accident — autocorrect, an IDE smart-quote setting — rather than as a deliberate choice in technical writing.

Example

Avoid:

/// Walk the tree, collecting sizes…

Prefer:

/// Walk the tree, collecting sizes...
Configuration

Configure via dylint.toml under ["perfectionist::unicode_ellipsis_in_docs"].

extra_flagged_chars : [single-character string] optional

Extra characters to flag alongside U+2026. Useful for catching near-relatives such as U+22EF MIDLINE HORIZONTAL ELLIPSIS (⋯) or U+2025 TWO DOT LEADER (‥) that the same autocorrect pipelines occasionally insert. Empty by default.

scan_code_spans : boolean optional

Whether to also flag a character inside an inline code span (`...`). Defaults to false: code spans often quote example text where the ellipsis is meaningful, so they are left alone unless this is set to true. Code blocks — fenced (``` ... ```), ~~~-fenced, four-space indented, and the doc-test code they hold — are always skipped regardless of this knob.

Source: src/rules/unicode_ellipsis_in_docs.rs

perfectionist::unicode_ellipsis_in_panic_messages↑ top

activeU+2026 HORIZONTAL ELLIPSIS in panic / assertion / expect messages; prefer ...

What it does

Forbids U+2026 HORIZONTAL ELLIPSIS (…) in the message of a panic-family or assertion-style macro — panic! and assert_eq! among them — and in the panic-message argument of an Option / Result method such as expect. Prefer the three-ASCII-dot form .... The built-in macro and method sets are listed on the extra_macros and extra_methods knobs, which extend them; ignore_macros and ignore_methods narrow them.

Why restrict this?

This is a stylistic preference, not a correctness issue. Panic and assertion messages surface in stderr, CI logs, crash reporters, and on terminals whose locale or encoding may not be UTF-8. ASCII ... renders identically everywhere.

Interaction with Clippy

clippy::non_ascii_literal (restriction, off by default) also catches U+2026 in a panic-family message, but only as a side effect of forbidding all non-ASCII in string and char literals: it cannot be narrowed to the ellipsis, offers no ... autofix, and does not reach the sibling perfectionist::unicode_ellipsis_in_comments / unicode_ellipsis_in_docs cases (comments and doc comments are not literals). Use this rule for the targeted fix, or non_ascii_literal for a blanket ASCII-only-literals policy.

Example

Avoid:

panic!("could not parse manifest…");
let manifest = load().expect("config missing…");

Prefer:

panic!("could not parse manifest...");
let manifest = load().expect("config missing...");

Custom macros

The extra_macros configuration accepts any macro name, but the lint's per-macro knowledge of which argument is the message only covers the built-in panic / assertion macros. A custom macro added through this knob is treated as if its first argument were the message; an assert_eq!-shaped wrapper would therefore also scan its value-position literals. Adding per-macro skip counts would require extending the configuration schema, which this rule deliberately does not do.

Configuration

Configure via dylint.toml under ["perfectionist::unicode_ellipsis_in_panic_messages"].

extra_macros : [string] optional

Additional macros whose call site should be scanned for the flagged characters. Empty by default, and merged with the built-in set: panic, unimplemented, todo, unreachable, assert, assert_eq, assert_ne, and the debug_ variant of each of those that has one (debug_unreachable, debug_assert, debug_assert_eq, debug_assert_ne). Use this to add project-specific assertion-shaped macros without having to re-state the standard ones.

ignore_macros : [string] optional

Macros to drop from the scanned set, even if they appear in the built-in defaults or in extra_macros. Empty by default; checked after the merge with the built-ins, so this knob always wins. Use it when a project deliberately uses … in one of the default macros.

extra_methods : [string] optional

Additional method names on Option / Result whose first argument is the panic message. Merged with the built-in defaults (expect, expect_err); empty by default. Use this to add project-specific expect-shaped wrappers without having to re-state the standard pair.

ignore_methods : [string] optional

Methods to drop from the scanned set, even if they appear in the built-in defaults or in extra_methods. Empty by default; checked after the merge with the built-ins, so this knob always wins.

extra_flagged_chars : [single-character string] optional

Extra characters to flag alongside U+2026. Useful for catching near-relatives such as U+22EF MIDLINE HORIZONTAL ELLIPSIS (⋯) or U+2025 TWO DOT LEADER (‥) that the same autocorrect pipelines occasionally insert. Empty by default.

Source: src/rules/unicode_ellipsis_in_panic_messages.rs

perfectionist::unknown_perfectionist_lints↑ top

activelint-control attribute references a perfectionist::* lint that this plugin does not register

What it does

Flags lint-control attributes (allow, warn, deny, forbid, expect, including under cfg_attr) whose lint name starts with perfectionist:: but does not name a lint this plugin actually registers.

Why is this bad?

Typos and stale references in #[allow(perfectionist::...)] silently neutralise the suppression they were written for. rustc's own unknown_lints covers tool-prefixed names inconsistently; this rule fills the gap and offers a "did you mean" hint against the registered set.

Example

Bad:

#[allow(perfectionist::unicode_ellipsis_in_comment)] // typo
fn legacy() {}

Good:

#[allow(perfectionist::unicode_ellipsis_in_comments)]
fn legacy() {}

Configuration: none.

Source: src/rules/unknown_perfectionist_lints.rs

perfectionist::unordered_derives↑ top

inactivetrait names in a #[derive(...)] list are not in the configured order

What it does

Enforces a project-wide ordering of trait names inside a single #[derive(...)] list. The style knob selects the ordering:

Trait matching is by the final path segment, so serde::Deserialize is matched as Deserialize. The lint does not police how derives are partitioned across multiple #[derive(...)] lines — that's a layout decision left to the author.

A cfg-gated derive written as #[cfg_attr(<cfg>, derive(...))] is checked the same way as a bare #[derive(...)]; the cfg predicate is left untouched.

Why restrict this?

This is a stylistic preference, not a correctness issue. The trait order inside #[derive(...)] has no semantic effect: #[derive(Debug, Clone)] and #[derive(Clone, Debug)] produce identical impls. A project-wide convention makes derive lists scan uniformly across the codebase. cargo fmt does not reorder derives, so this lint is the only mechanism for enforcing one.

The opinion is opt-in: a project that doesn't want to commit to a single ordering shouldn't have to set anything. The rule is therefore inactive by default — enable it per crate by adding to dylint.toml:

[perfectionist]
enable = ["unordered_derives"]

Example

Style: Alphabetical (default)

Avoid:

#[derive(Debug, Clone, Copy)]
struct Point;

Prefer:

#[derive(Clone, Copy, Debug)]
struct Point;

Style: Prefix then alphabetical

Avoid:

#[derive(Clone, Debug, Serialize, Copy)]
struct Point;

Prefer: (default prefix: Debug, Default, Clone, Copy, ..., then the remaining traits alphabetically)

#[derive(Debug, Clone, Copy, Serialize)]
struct Point;
Configuration

Configure via dylint.toml under ["perfectionist::unordered_derives"].

style : Style optional

Ordering policy. Defaults to alphabetical; set prefix_then_alphabetical to pin a configured prefix list of traits ahead of the alphabetised tail.

prefix : [string] optional

Trait names that must appear first under the prefix_then_alphabetical style, in the order they should appear. Ignored under other styles. Matched by the final path segment, so a configured "Debug" matches both Debug and std::fmt::Debug written in the source.

Types

Style enum

"alphabetical" (Rust: Alphabetical)

Every trait name must appear in ASCII-case-insensitive alphabetical order.

"prefix_then_alphabetical" (Rust: PrefixThenAlphabetical)

Traits listed in the configured prefix come first, in the listed order; remaining traits are sorted alphabetically after.

Source: src/rules/unordered_derives.rs

perfectionist::unpinned_repo_ref↑ top

activerepository URL references a branch or tag instead of a commit SHA

What it does

Flags URLs that reference a file or directory inside a hosted git repository (GitHub, GitLab, Bitbucket, Codeberg / Gitea, sourcehut, etc.) when the ref in the URL is a branch or tag rather than a commit SHA. Projects that deliberately link to version-shaped refs can opt into accepting those patterns via allow_version_patterns. Scans doc comments and regular comments by default; string literals are opt-in via scan_string_literals.

This rule only concerns whether the ref is mutable, not the length of an accepted SHA. perfectionist::bare_url separately ensures the URL is wrapped; the two layer rather than overlap.

Why restrict this?

This is a stylistic preference, not a correctness issue. A branch ref such as /blob/main/... resolves to whatever that branch currently points at, so the linked content can change — or disappear — without warning after the link is written. A tag ref is steadier but still not pinned: a tag can be removed, and a version-shaped name is not even guaranteed to be a tag (a branch named v1.2.3 is valid Git). A commit SHA is the only ref that always denotes the exact content the author linked to.

Example

Avoid:

/// See <https://github.com/owner/repo/blob/main/src/lib.rs>.

Prefer:

/// See <https://github.com/owner/repo/blob/8c1f6e2/src/lib.rs>.
Configuration

Configure via dylint.toml under ["perfectionist::unpinned_repo_ref"].

scan_doc_comments : boolean optional

Scan doc comments (///, //!, /** */, /*! */). Defaults to true.

scan_regular_comments : boolean optional

Scan regular comments (//, /* */). Defaults to true.

scan_string_literals : boolean optional

Scan string literals ("...", r"..."). Defaults to false.

sha_recognition_length : unsigned integer optional

Minimum hex length for a ref to be recognised as a commit SHA. A pure-hex ref shorter than this is treated as a branch and rejected. Defaults to 4 (Git's own minimum SHA length), which trades a small false-negative window (branch names like dead, face, beef) for fewer false positives on branch names that merely look hex-ish. Set to 1 to treat any pure-hex ref as a SHA.

allow_version_patterns : boolean optional

Whether refs shaped like version patterns (1.2.3, v1.2.3, or either form with a non-empty -suffix of ASCII letters, ASCII digits, ., -, and _) are accepted without a commit SHA. Defaults to false; tags can move, and a version-shaped branch name is valid Git, so projects must opt in to this convenience explicitly.

hosts : [HostEntry] optional

Hostnames to scan, each mapped to the forge kind that fixes its URL shape. Defaults to the common public forges: github.com / gitee.com (github-shape), gitlab.com, bitbucket.org, codeberg.org (gitea-shape), and git.sr.ht. Register a self-hosted instance by adding an entry with the matching kind. Supplying this field replaces the built-in list rather than extending it.

skip_hosts : [string] optional

Hostnames to skip, as *-glob patterns compared case-insensitively against the URL host. A host matching any pattern here is ignored even when it also appears in hosts. Defaults to [].

Types

HostEntry struct

One row of the hosts table: a hostname (or glob) and the forge kind whose URL shape applies to it.

hostname : string

Hostname to match, compared case-insensitively. A * wildcard matches any run of characters, so gitlab.*.example.com covers every subdomain in one entry.

kind : ForgeKind

Forge kind whose URL shape applies to this hostname: one of github (also covers gitee), gitlab, bitbucket, gitea (also covers Codeberg / Forgejo), or sourcehut.

Source: src/rules/unpinned_repo_ref.rs

perfectionist::wildcard_imports↑ top

activeglob (*) import in a module body, outside the prelude and root-re-export exceptions

What it does

Flags glob (*) use statements — use foo::bar::*; — in module bodies. These exceptions are enabled by default, and each can be turned off individually:

The case the rule is most concerned with is use super::*; inside a #[cfg(test)] mod tests block; explicit imports must replace it. A project that wants a stricter posture disables either or both exceptions, or names extra always-allowed paths, in dylint.toml.

Why restrict this?

This is a stylistic preference, not a correctness issue. A glob import compiles and runs correctly; the project simply prefers explicit imports. Naming each imported item keeps a module's dependencies visible at the top of the file, stops an upstream addition from silently shadowing a local name, and makes a grep for where a name comes from land on a real use. The glob form is idiomatic for preludes and for root re-exports, so those are exempt by default.

Why not clippy::wildcard_imports?

Clippy's wildcard_imports (an allow-by-default pedantic lint) flags the same glob uses and, by default, exempts prelude imports, pub use re-exports, and use super::*; inside any module whose name contains test — overlapping this rule's exceptions. But all of those exemptions, together with the allowed-wildcard-imports path list, are coupled behind a single warn-on-all-wildcard-imports boolean: left at its default (false) the test-module super::*; carve-out is on, so use super::*; inside mod tests is not flagged; set to true, every exemption drops at once, including the prelude one. There is no setting that keeps prelude::* exempt while flagging use super::*; in a #[cfg(test)] mod tests block — which is exactly this rule's headline case. This rule decouples them: the prelude and root_reexport exceptions toggle independently (with a configurable prelude_segment_names and the allowed_paths escape hatch), and there is no test-module carve-out, so the test super::*; glob is flagged by default. Reach for clippy::wildcard_imports if its coarser, all-or-nothing exemption model is enough; reach for this rule when the test-module distinction matters. Paired with perfectionist::named_prelude_imports it expresses the project's full posture: preludes must be glob-imported, and globs are allowed only for preludes.

Example

Avoid:

#[cfg(test)]
mod tests {
    use super::*;
}

Prefer:

#[cfg(test)]
mod tests {
    use super::{ParsedThing, parse_thing};
}

Not flagged: the prelude and root-re-export exceptions.

use rayon::prelude::*;
pub use submodule::*;
Configuration

Configure via dylint.toml under ["perfectionist::wildcard_imports"].

exempt_prelude : boolean optional

Whether a glob whose final non-glob path segment names a prelude module (use rayon::prelude::*;) is exempt. The recognised segment names come from prelude_segment_names. Defaults to true; set false to flag prelude globs too.

exempt_reexports : boolean optional

Whether a bare-pub re-export glob (pub use submodule::*;) at the top level of a module body is exempt. Defaults to true; set false to flag re-export globs too.

prelude_segment_names : [string] optional

Path segment names recognised as preludes for the prelude exception. Defaults to ["prelude"].

allowed_paths : [string] optional

Module paths whose glob import is never flagged, regardless of the exceptions above — the path before the ::* of a use <path>::*. Each entry is absolute: an extern-crate path with a leading :: ("::rayon::iter", which exempts both use rayon::iter::*; and use ::rayon::iter::*;), or a crate-root path written "crate::...". Matching is exact and syntactic: the entry must equal the path as written in the glob use, with no re-export or alias resolution. Defaults to [].

Source: src/rules/wildcard_imports.rs