perfectionist lints

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

Index

LintDefaultDescription
bare_emailactivebare email address in comment or doc comment; wrap in <...> or prefix with mailto:
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
derive_orderinginactivetrait names in a #[derive(...)] list are not in the configured order
flat_module_patternactivesubmodule defined as module/mod.rs; prefer the flat module.rs layout
import_granularityactiveimport granularity does not match the configured import_granularity.style
lint_reason_from_commentactivetrailing comment on a lint-level attribute should be lifted into a reason = "..." field
lint_silence_reasonactive#[allow] / #[expect] attribute lacks an explanatory reason = "..." field
macro_argument_bindingactivemacro invocation passes an impure expression that should be bound to a let first
macro_trailing_commaactivemacro invocation does not follow rustfmt's vertical trailing-comma policy
non_exhaustive_errorinactiveerror-shaped type is missing #[non_exhaustive]
prefer_derive_more_over_thiserroractivethiserror import, derive, or attribute; this catalogue prefers derive_more::{Display, Error}
prefer_raw_stringactivestring literal contains only raw-expressible escapes; prefer the raw-string form
print_macro_splitactivesplittable print macro with an embedded-newline template exceeds the configured line width
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
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 ...
unit_test_file_layoutactiveunit-test code is in the wrong file or exceeds the inline-test budget
unknown_perfectionist_lintsactivelint-control attribute references a perfectionist::* lint that this plugin does not register

Rules

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

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

Use instead:

/// 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 wrapped forms above (<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_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

/// Closes #123 and supersedes #124.

Use instead (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 detected from the repository's host via [Forge::detect].

"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

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

Use instead:

/// 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::derive_ordering↑ 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. Two styles are configurable via style:

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.

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 = ["derive_ordering"]

Example

Under style = "alphabetical":

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

Use instead:

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

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

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/derive_ordering.rs

perfectionist::flat_module_pattern↑ top

activesubmodule defined as module/mod.rs; prefer the flat module.rs layout

What it does

Forbids the module/mod.rs layout for submodules. Each submodule should be defined by a sibling file named after the module (module.rs), with any nested children placed inside the module/ directory next to it.

Why restrict this?

This is a stylistic preference, not a correctness issue. The flat layout keeps the file name unique to its module, so editors, terminal tabs, and grep results identify the module without their parent directory. The mod.rs form produces dozens of identically-named tabs in editors that don't disambiguate by directory.

Example

// Bad
src/foo/mod.rs

// Good
src/foo.rs
src/foo/bar.rs

Configuration: none.

Source: src/rules/flat_module_pattern.rs

perfectionist::import_granularity↑ top

activeimport granularity does not match the configured import_granularity.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.

Globs (use foo::*) are governed by perfectionist::no_star_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

Under the default style = "module":

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

Use instead:

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

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

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.

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;.

Source: src/rules/import_granularity.rs

perfectionist::lint_reason_from_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

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

Use instead:

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

Configuration: none.

Source: src/rules/lint_reason_from_comment.rs

perfectionist::lint_silence_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.

Example

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

Use instead:

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

Configure via dylint.toml under ["perfectionist::lint_silence_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 10 is rejected at parse time, since an empty literal is already treated as a missing reason regardless of this knob.

Source: src/rules/lint_silence_reason.rs

perfectionist::macro_argument_binding↑ 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

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

Use instead:

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

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

mode : Mode optional

Eligibility mode.

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. Same matching rules as deny_extra. 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. Same matching rules as deny_extra.

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. The default is AllowAndDeny.

"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/macro_argument_binding.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

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

Use instead:

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::non_exhaustive_error↑ 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 = ["non_exhaustive_error"]

Example

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

Use instead:

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

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

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/non_exhaustive_error.rs

perfectionist::prefer_derive_more_over_thiserror↑ 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. Three 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.

Because the pass runs pre-expansion and does not consult name resolution, 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 #[allow(perfectionist::prefer_derive_more_over_thiserror)].

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

use thiserror::Error;

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

Use instead:

use derive_more::{Display, Error};

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

Configuration: none.

Source: src/rules/prefer_derive_more_over_thiserror.rs

perfectionist::prefer_raw_string↑ 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.

This includes literals passed as arguments to macros such as println!, format!, vec!, and assert!. Suppress per call site with #[allow(perfectionist::prefer_raw_string)] when the regular form is deliberately preferred.

Pattern-position literals (e.g. match s { "C:\\path" => ... }) are out of scope — the rule only visits expression literals.

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

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

Use instead:

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

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

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 10 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/prefer_raw_string.rs

perfectionist::print_macro_split↑ 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

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

Use instead:

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::print_macro_split"].

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.

Source: src/rules/print_macro_split.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.

Example

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

Use instead:

.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.

Example

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

Use instead:

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.

Example

const N: usize = 2;

Use instead:

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.

Example

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

Use instead:

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. Genuinely canonical cases — impl<T> From<T> for Wrapper<T> and friends, where the trait already imposes the role of T — can be silenced site-by-site with #[allow] or #[expect].

Example

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

Use instead:

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).

Example

let m = entry.metadata()?;

Use instead:

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.

Example

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

Use instead:

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::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

// TODO: handle the empty-tree case…

Use instead:

// 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

/// Walk the tree, collecting sizes…

Use instead:

/// 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!, unimplemented!, todo!, unreachable!, assert!, assert_eq!, assert_ne!, debug_assert*!) and in the expect / expect_err argument on Option and Result. Prefer the three-ASCII-dot form ....

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.

Example

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

Use instead:

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 requires extending the configuration schema and is out of scope for the initial rule.

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. Merged with the built-in defaults (the standard panic and assertion macros — panic, unimplemented, todo, unreachable, debug_unreachable, and the assert* family); empty by default. 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::unit_test_file_layout↑ top

activeunit-test code is in the wrong file or exceeds the inline-test budget

What it does

Enforces where a crate's unit-test code lives. Two independent axes are checked:

  1. External-file layout. An external #[cfg(test)] mod <name>; must resolve to the canonical on-disk location. By default that is the nested <parent>/<name>.rs form (tests of src/foo.rs live in src/foo/tests.rs); for such a file the flattened sibling src/foo_tests.rs and the skipped-intermediate src/tests.rs are flagged. A directory-owning parent (lib.rs / main.rs / mod.rs) is the exception: its children already live beside it, so mod tests; in src/lib.rs canonically resolves to src/tests.rs (not src/lib/tests.rs) and is not flagged — matching where Cargo loads it. The sibling style also accepts the flattened form; any skips the layout check.
  2. Inline footprint. 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.

The module identifier is irrelevant to the layout rule; only the file's position relative to its parent matters.

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 layout 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. Both source projects keep large test suites out of the production file, so the file an editor tab, a grep hit, or a diff shows is production code rather than a wall of fixtures; and they put the extracted file in a predictable place so a reader always knows where a module's tests are. The thresholds and the nested-vs-sibling choice are deliberately configurable because the exact budget and directory shape vary by project.

Example

// Bad (external_layout = "nested")
src/foo.rs         declares  #[cfg(test)] mod tests;
src/foo_tests.rs   holds the test code

// Good
src/foo.rs         declares  #[cfg(test)] mod tests;
src/foo/tests.rs   holds the test code
Configuration

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

inline_style : InlineStyle optional

How inline test modules are 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.

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).

external_layout : ExternalLayout optional

How external test files must be laid out on disk. Defaults to nested.

flag_unexpected_sibling : boolean optional

Under nested, also flag a flattened <parent>_<name>.rs sibling left on disk for a module whose nested file already exists. Defaults to true.

test_module_names : [string] optional

Module names the inline-style footprint is scoped to. Empty (the default) counts every inline test item — #[cfg(test)] mod blocks of any name, #[test] fns, and other #[cfg(test)] items. When non-empty, the budget is measured only over #[cfg(test)] mod <name> blocks whose <name> is listed; bare top-level test items (which have no module name) are then out of scope. Set this when a project keeps its inline tests in named modules and wants the budget to track those specifically.

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>;. Matches pacquet's strict policy.

"external_when_long" (Rust: ExternalWhenLong)

Inline test code is allowed up to the configured budget; beyond that it must move to a file. Matches parallel-disk-usage's guidance. The default.

ExternalLayout enum

How external test files must be laid out on disk (the external_layout knob).

"nested" (Rust: Nested)

src/foo.rs's mod bar; must resolve to src/foo/bar.rs.

"sibling" (Rust: Sibling)

Also accept the flattened src/foo_bar.rs form.

"any" (Rust: Any)

Accept whichever path Cargo loads; skip the layout check.

Source: src/rules/unit_test_file_layout.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

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

Use instead:

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

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

suggestion_distance : unsigned integer optional

Maximum Levenshtein edit distance between an unknown perfectionist::* name and a registered lint for the lint to emit a "did you mean" suggestion. Defaults to 2, which catches single-character typos and short transpositions without producing wild guesses. Set to 0 to disable suggestions entirely.

Source: src/rules/unknown_perfectionist_lints.rs