perfectionist 0.0.0-rc.18

Additional linting rules for Rust projects
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
use std::collections::{BTreeSet, HashSet};

use clippy_utils::diagnostics::{span_lint_and_help, span_lint_and_sugg};
use clippy_utils::is_from_proc_macro;
use clippy_utils::source::{indent_of, snippet_opt};
use rustc_ast::{AttrStyle, Attribute, Item, ItemKind, MetaItem, MetaItemInner, MetaItemKind};
use rustc_errors::Applicability;
use rustc_lint::{EarlyContext, EarlyLintPass, Lint, LintStore};
use rustc_session::{declare_tool_lint, impl_lint_pass};
use rustc_span::{Span, Symbol, sym};

use crate::common::{DefaultState, render_meta_path, resolve_string_set, resolved_state};

#[cfg(test)]
mod tests;

declare_tool_lint! {
    /// ### What it does
    ///
    /// Rewrites `#[allow(<lints>)]` to `#[expect(<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::*`. The
    /// `cfg_attr`-wrapped form (`#[cfg_attr(<cfg>, allow(...))]`) is
    /// rewritten in place, swapping only the inner `allow` identifier.
    ///
    /// When an attribute mixes a rewriteable lint with a
    /// non-rewriteable one (an exempt-list entry or an unknown lint),
    /// it is split: the non-rewriteable names stay under `#[allow]` and
    /// the rewriteable ones move to a new `#[expect]`, copying the
    /// `reason` field to each.
    ///
    /// 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.
    ///
    /// ### Example
    ///
    /// **Avoid:**
    ///
    /// ```rust,ignore
    /// #[allow(clippy::too_many_arguments, reason = "matches pnpm's signature")]
    /// fn build_fetcher(/* ... */) {}
    /// ```
    ///
    /// **Prefer:**
    ///
    /// ```rust,ignore
    /// #[expect(clippy::too_many_arguments, reason = "matches pnpm's signature")]
    /// fn build_fetcher(/* ... */) {}
    /// ```
    pub perfectionist::PREFER_EXPECT_OVER_ALLOW,
    Warn,
    "`#[allow]` for a deterministically-firing lint should be `#[expect]`",
    report_in_external_macro: false
}

/// Active by default. The rewrite is conservative — it only fires when
/// every named lint is known to fire deterministically — so a baseline
/// policy is not presumptuous. Read by [`register_pass`]; gen-docs picks
/// the constant up to render the rule's default state.
pub(crate) const DEFAULT_STATE: DefaultState = DefaultState::Active;

const CONFIG_KEY: &str = "perfectionist::prefer_expect_over_allow";

/// The lints `#[allow]` keeps because they cannot be relied on to fire
/// deterministically: the `cfg`-conditional `unused_*` and
/// reachability lints. Each can fire under one `cfg` arm and stay
/// silent under another, so a mechanical `expect` rewrite would break
/// the build in the silent arm.
const DEFAULT_EXEMPT_LINTS: &[&str] = &[
    "dead_code",
    "unused_imports",
    "unused_macros",
    "unused_variables",
    "unused_mut",
    "unused_assignments",
    "unused_must_use",
    "unreachable_code",
];

/// Clippy lint *group* names. A group fires only if some member lint
/// fires, so `#[expect(clippy::<group>)]` is unfulfilled wherever no
/// member triggers — the same non-determinism [`DEFAULT_EXEMPT_LINTS`]
/// guards against. Unlike rustc's bare groups (`unused`, etc.), these are
/// not in the [`LintStore`] snapshot (clippy is not loaded during a
/// `cargo dylint` run), so they are listed explicitly.
const CLIPPY_LINT_GROUPS: &[&str] = &[
    "all",
    "cargo",
    "complexity",
    "correctness",
    "deprecated",
    "nursery",
    "pedantic",
    "perf",
    "restriction",
    "style",
    "suspicious",
];

/// Rustdoc lint *group* names, treated like [`CLIPPY_LINT_GROUPS`].
const RUSTDOC_LINT_GROUPS: &[&str] = &["all"];

#[derive(Debug, serde::Deserialize)]
#[serde(default, deny_unknown_fields, rename_all = "snake_case")]
struct Config {
    /// 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.
    extra_exempt_lints: Vec<String>,
    /// 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).
    ignore_exempt_lints: Vec<String>,
    /// 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_outer_scopes: bool,
    /// 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.
    apply_to_tool_namespaces: bool,
}

impl Default for Config {
    fn default() -> Self {
        Self {
            extra_exempt_lints: Vec::new(),
            ignore_exempt_lints: Vec::new(),
            apply_to_outer_scopes: false,
            apply_to_tool_namespaces: true,
        }
    }
}

pub struct PreferExpectOverAllow {
    exempt_lints: BTreeSet<String>,
    apply_to_outer_scopes: bool,
    apply_to_tool_namespaces: bool,
    /// Snapshot of every built-in rustc lint name (no tool prefix) that
    /// was registered in the [`LintStore`] when the pass was installed.
    /// A bare lint name in an `#[allow]` is "built-in, deterministic"
    /// only if it is in this set; anything else is an unknown name that
    /// might belong to a procedural plugin that fires conditionally, so
    /// it is left under `#[allow]`. Lint *group* names (`unused`,
    /// `nonstandard_style`, and similar) are deliberately absent — a
    /// group fires if *any* member fires, the same non-deterministic
    /// shape the exempt list guards against, so groups are never
    /// rewritten.
    builtin_lints: HashSet<String>,
    /// Spans of attributes that sit on a `mod` item, collected from
    /// [`EarlyLintPass::check_item`] so [`EarlyLintPass::check_attribute`]
    /// can recognise the module-scope case and skip it unless
    /// `apply_to_outer_scopes` is set. `check_item` for an item runs
    /// before that item's own attributes are visited, so the span is
    /// already recorded by the time `check_attribute` consults it.
    module_attr_spans: HashSet<Span>,
}

impl PreferExpectOverAllow {
    fn new(builtin_lints: HashSet<String>) -> Self {
        let config: Config = dylint_linting::config_or_default(CONFIG_KEY);
        Self {
            exempt_lints: resolve_string_set(
                DEFAULT_EXEMPT_LINTS,
                config.extra_exempt_lints,
                config.ignore_exempt_lints,
            ),
            apply_to_outer_scopes: config.apply_to_outer_scopes,
            apply_to_tool_namespaces: config.apply_to_tool_namespaces,
            builtin_lints,
            module_attr_spans: HashSet::new(),
        }
    }
}

impl_lint_pass!(PreferExpectOverAllow => [PREFER_EXPECT_OVER_ALLOW]);

pub fn register_lint(lint_store: &mut LintStore) {
    lint_store.register_lints(&[PREFER_EXPECT_OVER_ALLOW]);
}

pub fn register_pass(lint_store: &mut LintStore) {
    if let DefaultState::Inactive = resolved_state("prefer_expect_over_allow", DEFAULT_STATE) {
        return;
    }
    let builtin_lints = collect_builtin_lint_names(lint_store);
    lint_store
        .register_early_pass(move || Box::new(PreferExpectOverAllow::new(builtin_lints.clone())));
}

/// Collect every registered lint whose printed name carries no tool
/// prefix — the built-in rustc lints. `clippy::*` / `rustdoc::*` /
/// `perfectionist::*` lints are recognised structurally by their path
/// prefix instead, so they are filtered out here.
fn collect_builtin_lint_names(lint_store: &LintStore) -> HashSet<String> {
    lint_store
        .get_lints()
        .iter()
        .map(|lint: &&Lint| lint.name_lower())
        .filter(|name| !name.contains("::"))
        .collect()
}

const ALLOW: Symbol = sym::allow;

impl EarlyLintPass for PreferExpectOverAllow {
    /// Record the spans of a module item's own attributes so
    /// [`Self::check_attribute`] can identify the module-scope case.
    fn check_item(&mut self, _: &EarlyContext<'_>, item: &Item) {
        if matches!(item.kind, ItemKind::Mod(..)) {
            for attribute in &item.attrs {
                self.module_attr_spans.insert(attribute.span);
            }
        }
    }

    /// `check_attribute` runs once per syntactic attribute, after macro
    /// expansion. A `#[cfg_attr(<cfg>, allow(...))]` whose condition
    /// holds is expanded into a synthesised `allow(...)` attribute whose
    /// span covers just the inner `allow(...)` text in the source (no
    /// `#[ ]` wrapper); a `cfg_attr` whose condition fails is dropped
    /// entirely. Both the bare and the `cfg_attr`-derived forms arrive
    /// here as an attribute named `allow`; the two are told apart by
    /// whether the attribute's source snippet still carries its `#[`
    /// delimiter, which decides how the split rewrite is rendered.
    fn check_attribute(&mut self, lint_context: &EarlyContext<'_>, attribute: &Attribute) {
        if !attribute.has_name(ALLOW) {
            return;
        }
        if is_from_proc_macro(lint_context, attribute) {
            return;
        }
        if !self.scope_is_eligible(attribute.style, attribute.span) {
            return;
        }
        let Some(ident_span) = attr_path_ident_span(attribute) else {
            return;
        };
        let Some(args) = attribute.meta_item_list() else {
            return;
        };
        self.check_allow(
            lint_context,
            ident_span,
            attribute.span,
            attribute.style,
            &args,
        );
    }
}

/// Where an `allow(...)` invocation lives, which decides how the
/// split-attribute autofix renders its replacement text.
enum Container {
    /// A standalone `#[allow(...)]` / `#![allow(...)]` attribute. The
    /// split rewrite replaces the whole attribute with two attributes.
    Bare { span: Span, style: AttrStyle },
    /// An `allow(...)` nested inside a `cfg_attr` argument list. The
    /// split rewrite replaces just the inner meta item with two
    /// comma-separated `allow(...), expect(...)` invocations, leaving
    /// the `cfg_attr` wrapper and its `cfg` condition untouched.
    CfgAttrInner { span: Span },
}

impl PreferExpectOverAllow {
    /// Whether an attribute at this scope is eligible for rewriting.
    /// Crate-root / module-body inner attributes (`#![...]`) and outer
    /// attributes on `mod` items are gated behind `apply_to_outer_scopes`.
    fn scope_is_eligible(&self, style: AttrStyle, span: Span) -> bool {
        if self.apply_to_outer_scopes {
            return true;
        }
        if let AttrStyle::Inner = style {
            return false;
        }
        !self.module_attr_spans.contains(&span)
    }

    /// Apply the rule to a single `allow(...)` invocation.
    ///
    /// `ident_span` covers the `allow` keyword (the anchor for the
    /// simple swap). `attr_span` / `attr_style` locate the whole
    /// attribute (used to render the split rewrite). `args` is the
    /// attribute's argument list.
    fn check_allow(
        &self,
        lint_context: &EarlyContext<'_>,
        ident_span: Span,
        attr_span: Span,
        attr_style: AttrStyle,
        args: &[MetaItemInner],
    ) {
        let mut rewriteable: Vec<String> = Vec::new();
        let mut kept: Vec<String> = Vec::new();
        let mut reason: Option<&MetaItem> = None;

        for arg in args {
            let MetaItemInner::MetaItem(meta) = arg else {
                continue;
            };
            match &meta.kind {
                MetaItemKind::Word => {
                    let name = render_meta_path(meta);
                    if self.is_rewriteable(meta, &name) {
                        rewriteable.push(name);
                    } else {
                        kept.push(name);
                    }
                }
                MetaItemKind::NameValue(_) if meta.has_name(sym::reason) => {
                    reason = Some(meta);
                }
                // `List` and other shapes are not lint names; ignore.
                _ => {}
            }
        }

        // Nothing deterministic to move — leave the attribute as-is.
        if rewriteable.is_empty() {
            return;
        }

        let reason_snippet = reason.and_then(|meta| snippet_opt(lint_context, meta.span));

        if kept.is_empty() {
            // Every named lint is rewriteable: a one-word swap.
            span_lint_and_sugg(
                lint_context,
                PREFER_EXPECT_OVER_ALLOW,
                ident_span,
                "this `#[allow]` can be `#[expect]` so the suppression self-cleans",
                "replace `allow` with `expect`",
                "expect".to_owned(),
                Applicability::MaybeIncorrect,
            );
            return;
        }

        // Mixed: split the rewriteable lints into a separate `#[expect]`.
        // The textual rewrite needs the source snippet to place the new
        // attribute (bare vs inside a `cfg_attr` arg list) and to copy
        // the `reason` verbatim. If either is unavailable — only
        // reachable for spans `is_from_proc_macro` somehow let through —
        // flag the site without an autofix rather than risk emitting a
        // rewrite that drops the reason or injects `#[..]` inside a
        // `cfg_attr`.
        let reason_recoverable = reason.is_none() || reason_snippet.is_some();
        match container_of(lint_context, attr_span, attr_style) {
            Some(container) if reason_recoverable => self.emit_split(
                lint_context,
                container,
                &kept,
                &rewriteable,
                reason_snippet.as_deref(),
            ),
            _ => emit_split_without_fix(lint_context, ident_span),
        }
    }

    /// Whether a single lint name is one of the deterministically-firing
    /// kinds this rule rewrites. `name` is the rendered fully-namespaced
    /// form, matched against `exempt_lints`.
    fn is_rewriteable(&self, meta: &MetaItem, name: &str) -> bool {
        if self.exempt_lints.contains(name) {
            return false;
        }
        let segments = &meta.path.segments;
        if segments.len() <= 1 {
            // Bare name: rewriteable only if it is a registered built-in
            // lint. Unknown bare names (and lint groups) are left alone.
            return self.builtin_lints.contains(name);
        }
        // Tool-namespaced. `clippy` and `rustdoc` ship deterministic
        // lints and are always rewriteable — except their lint *groups*
        // (`clippy::pedantic`, `rustdoc::all`, etc.), which fire only if
        // some member fires, exactly the non-determinism the bare-name
        // branch excludes for rustc groups. Every other tool namespace
        // is gated behind `apply_to_tool_namespaces`.
        let tool = segments[0].ident.name.as_str();
        let lint = segments.last().map(|segment| segment.ident.name.as_str());
        match tool {
            "clippy" => lint.is_some_and(|lint| !CLIPPY_LINT_GROUPS.contains(&lint)),
            "rustdoc" => lint.is_some_and(|lint| !RUSTDOC_LINT_GROUPS.contains(&lint)),
            _ => self.apply_to_tool_namespaces,
        }
    }

    fn emit_split(
        &self,
        lint_context: &EarlyContext<'_>,
        container: Container,
        kept: &[String],
        rewriteable: &[String],
        reason: Option<&str>,
    ) {
        let allow_body = render_invocation("allow", kept, reason);
        let expect_body = render_invocation("expect", rewriteable, reason);
        let (span, replacement) = match container {
            Container::CfgAttrInner { span } => (span, format!("{allow_body}, {expect_body}")),
            Container::Bare { span, style } => {
                let hash = match style {
                    AttrStyle::Inner => "#!",
                    AttrStyle::Outer => "#",
                };
                let pad = " ".repeat(indent_of(lint_context, span).unwrap_or(0));
                (
                    span,
                    format!("{hash}[{allow_body}]\n{pad}{hash}[{expect_body}]"),
                )
            }
        };
        span_lint_and_sugg(
            lint_context,
            PREFER_EXPECT_OVER_ALLOW,
            span,
            "the deterministically-firing lints here can move to `#[expect]`",
            "split the deterministic lints into a separate `#[expect]`",
            replacement,
            Applicability::MaybeIncorrect,
        );
    }
}

/// Classify where an `allow` attribute lives, for the split rewrite. A
/// real bare `#[allow(...)]` / `#![allow(...)]` keeps its `#` delimiter
/// in the source snippet; a `cfg_attr`-synthesised `allow(...)` does
/// not, so its split must stay inside the `cfg_attr` argument list
/// rather than emit standalone `#[ ]` attributes. Returns `None` when
/// the snippet is unavailable, so the caller declines the autofix
/// instead of guessing the wrapper.
fn container_of(
    lint_context: &EarlyContext<'_>,
    span: Span,
    style: AttrStyle,
) -> Option<Container> {
    let snippet = snippet_opt(lint_context, span)?;
    Some(if snippet.trim_start().starts_with('#') {
        Container::Bare { span, style }
    } else {
        Container::CfgAttrInner { span }
    })
}

/// Flag a mixed `#[allow]` without a machine-applicable suggestion,
/// used when the source text needed to render a correct split rewrite
/// is unavailable.
fn emit_split_without_fix(lint_context: &EarlyContext<'_>, ident_span: Span) {
    span_lint_and_help(
        lint_context,
        PREFER_EXPECT_OVER_ALLOW,
        ident_span,
        "the deterministically-firing lints here can move to `#[expect]`",
        None,
        "split the deterministic lints out into a separate `#[expect]`",
    );
}

/// Render an `allow(...)` / `expect(...)` invocation from a list of
/// lint names and an optional verbatim `reason = "..."` snippet.
fn render_invocation(keyword: &str, names: &[String], reason: Option<&str>) -> String {
    let mut parts: Vec<&str> = names.iter().map(String::as_str).collect();
    if let Some(reason) = reason {
        parts.push(reason);
    }
    format!("{keyword}({})", parts.join(", "))
}

/// Span of the `allow` identifier in an attribute's path.
fn attr_path_ident_span(attribute: &Attribute) -> Option<Span> {
    let item = attribute.get_normal_item();
    item.path.segments.first().map(|segment| segment.ident.span)
}