Skip to main content

wowlab_tidy/languages/rust/rules/hygiene/
allow_reason.rs

1use crate::{Example, FileCtx, Violation, infra::parse, violation};
2
3#[rustfmt::skip]
4const EXAMPLES: &[Example] = &[
5    Example {
6        label: "allow without comment",
7        code: "#[allow(dead_code)]",
8        pass: false,
9    },
10    Example {
11        label: "allow with inline comment",
12        code: "#[allow(dead_code)] // webhook response fields",
13        pass: true,
14    },
15    Example {
16        label: "allow with preceding comment",
17        code: "// DBC fields use PascalCase\n#[allow(non_snake_case)]",
18        pass: true,
19    },
20    Example {
21        label: "allow with reason argument",
22        code: "#[allow(dead_code, reason = \"webhook response fields\")]",
23        pass: true,
24    },
25    Example {
26        label: "expect without reason",
27        code: "#[expect(clippy::unused_async)]",
28        pass: false,
29    },
30    Example {
31        label: "expect with reason argument",
32        code: "#[expect(clippy::unused_async, reason = \"API fixed, will use I/O later\")]",
33        pass: true,
34    },
35    Example {
36        label: "multiline allow with reason argument",
37        code: "#[allow(\n    dead_code,\n    reason = \"webhook response fields\",\n)]",
38        pass: true,
39    },
40    Example {
41        label: "multiline expect with reason argument",
42        code: "#[expect(\n    clippy::cast_precision_loss,\n    reason = \"sample count enters the f64 domain\",\n)]",
43        pass: true,
44    },
45    Example {
46        label: "multiline module expect with reason argument",
47        code: "#![expect(\n    clippy::doc_markdown,\n    reason = \"generated documentation is external\",\n)]",
48        pass: true,
49    },
50    Example {
51        label: "multiline expect without reason",
52        code: "#[expect(\n    clippy::cast_precision_loss,\n    clippy::cast_sign_loss,\n)]",
53        pass: false,
54    },
55    Example {
56        label: "module-level allow without comment",
57        code: "#![allow(clippy::too_many_arguments)]",
58        pass: false,
59    },
60    Example {
61        label: "module-level allow with comment",
62        code: "// spell table has many parameters by design\n#![allow(clippy::too_many_arguments)]",
63        pass: true,
64    },
65    Example {
66        label: "non-allow attr",
67        code: "#[derive(Debug)]",
68        pass: true,
69    },
70    Example {
71        label: "deny attr",
72        code: "#[deny(unused)]",
73        pass: true,
74    },
75];
76
77crate::line_rule!(
78    allow_reason,
79    "Require a `reason = \"...\"` or comment explaining why `#[allow(...)]`/`#[expect(...)]` is used.",
80    "Unexplained lint overrides hide the intent behind suppressing a warning, making it unclear if the suppression is still needed.",
81);
82
83fn is_expect_attr(trimmed: &str) -> bool {
84    trimmed.starts_with("#[expect(") || trimmed.starts_with("#![expect(")
85}
86
87fn code_before_line_comment(line: &str) -> &str {
88    let bytes = line.as_bytes();
89    let mut index = 0;
90    let mut in_string = false;
91    let mut escaped = false;
92
93    while index < bytes.len() {
94        let Some(&byte) = bytes.get(index) else {
95            break;
96        };
97
98        if in_string {
99            if escaped {
100                escaped = false;
101            } else if byte == b'\\' {
102                escaped = true;
103            } else if byte == b'"' {
104                in_string = false;
105            }
106        } else if byte == b'"' {
107            in_string = true;
108        } else if byte == b'/' && bytes.get(index + 1) == Some(&b'/') {
109            return line.get(..index).unwrap_or(line);
110        }
111
112        index += 1;
113    }
114
115    line
116}
117
118fn contains_reason(attribute_line: &str) -> bool {
119    attribute_line.contains("reason = \"") || attribute_line.contains("reason=\"")
120}
121
122fn attribute_span_has_reason(lines: &[&str], start: usize) -> bool {
123    let mut depth = 0_u32;
124    let mut entered_attribute = false;
125
126    for line in lines.iter().skip(start) {
127        let code = code_before_line_comment(line);
128
129        if contains_reason(code) {
130            return true;
131        }
132
133        let mut in_string = false;
134        let mut escaped = false;
135
136        for byte in code.bytes() {
137            if in_string {
138                if escaped {
139                    escaped = false;
140                } else if byte == b'\\' {
141                    escaped = true;
142                } else if byte == b'"' {
143                    in_string = false;
144                }
145
146                continue;
147            }
148
149            match byte {
150                b'"' => in_string = true,
151                b'(' => {
152                    entered_attribute = true;
153                    depth += 1;
154                }
155                b')' if entered_attribute => {
156                    depth = depth.saturating_sub(1);
157
158                    if depth == 0 {
159                        return false;
160                    }
161                }
162                _ => {}
163            }
164        }
165    }
166
167    false
168}
169
170fn check_allow_reason(ctx: &FileCtx<'_>) -> Vec<Violation> {
171    let mut out = Vec::new();
172
173    for (i, line) in ctx.lines.iter().enumerate() {
174        let trimmed = line.trim();
175
176        if !parse::is_allow_attr(trimmed) && !is_expect_attr(trimmed) {
177            continue;
178        }
179
180        if attribute_span_has_reason(ctx.lines, i) {
181            continue;
182        }
183
184        if parse::has_inline_comment(trimmed) {
185            continue;
186        }
187
188        if i.checked_sub(1)
189            .and_then(|previous| ctx.lines.get(previous))
190            .is_some_and(|previous| parse::is_comment(previous.trim()))
191        {
192            continue;
193        }
194
195        out.push(violation(
196            ctx.rel,
197            i + 1,
198            "lint override without a `reason = \"...\"` or comment explaining why",
199        ));
200    }
201
202    out
203}
204
205crate::tidy_test!(check_allow_reason, {
206    crate::example_tests!(EXAMPLES, check_allow_reason);
207});