mir_analyzer/suppression.rs
1//! Inline issue suppression via source comments.
2//!
3//! Lets users silence a single false positive without touching `mir.xml` or a
4//! baseline file. A [`SuppressionMap`] is built once per file from its source
5//! text and consulted as a final post-filter over the analyzer's issues
6//! (`batch.rs`), so it applies uniformly across every emitting pass —
7//! body analysis, the collector, class checks and dead-code detection.
8//!
9//! ## Recognised directives
10//!
11//! Native (preferred), matching the existing `@mir-check` convention:
12//!
13//! | Directive | Scope |
14//! |----------------------------|-----------------------------------------|
15//! | `@mir-ignore [Kind …]` | trailing comment → its line; otherwise the next code line |
16//! | `@mir-ignore-line [Kind …]` | the comment's own line |
17//! | `@mir-ignore-next-line [Kind …]` | the next physical line |
18//! | `@mir-ignore-file [Kind …]` | the whole file |
19//!
20//! `@mir-suppress*` is accepted as an alias of `@mir-ignore*`.
21//!
22//! Third-party aliases for drop-in compatibility:
23//!
24//! | Directive | Scope / kinds |
25//! |-----------------------------|----------------------------------------|
26//! | `@psalm-suppress Kind …` | like `@mir-ignore` (named kinds) |
27//! | `@suppress Kind …` | like `@mir-ignore` (named kinds) |
28//! | `@phpstan-ignore-line` | the comment's own line, all kinds |
29//! | `@phpstan-ignore-next-line` | the next line, all kinds |
30//! | `@phpstan-ignore …` | the next line, all kinds |
31//!
32//! When no `Kind` follows the directive, *all* issues on the target line are
33//! suppressed. Kinds may be given by name (`UndefinedClass`) or by code
34//! (`MIR0123`); multiple kinds are space- or comma-separated. PHPStan's
35//! `@phpstan-ignore*` forms always suppress every kind on their target, since
36//! PHPStan identifiers do not map onto mir's [`IssueKind`] names.
37//!
38//! [`IssueKind`]: mir_issues::IssueKind
39
40use rustc_hash::{FxHashMap, FxHashSet};
41
42/// Set of issue kinds a directive applies to.
43#[derive(Debug, Clone)]
44enum KindSet {
45 /// Every kind on the target.
46 All,
47 /// Specific kinds, matched against `IssueKind::name()` or `code()`.
48 Named(FxHashSet<String>),
49}
50
51impl KindSet {
52 fn matches(&self, name: &str, code: &str) -> bool {
53 match self {
54 KindSet::All => true,
55 // Kind names/codes are matched case-insensitively — a directive
56 // author writing `@mir-ignore undefinedclass` (or any other casing
57 // that doesn't exactly match `IssueKind::name()`'s PascalCase)
58 // must still suppress the issue instead of silently doing nothing.
59 // The set is small (a handful of kinds per directive at most), and
60 // preserving each entry's original casing (rather than
61 // lowercasing at parse time) keeps `UnusedSuppress`'s message
62 // quoting the text the author actually wrote.
63 KindSet::Named(set) => set
64 .iter()
65 .any(|k| k.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(code)),
66 }
67 }
68
69 fn merge(&mut self, other: KindSet) {
70 match (self, other) {
71 // Already broadest possible.
72 (KindSet::All, _) => {}
73 (slot @ KindSet::Named(_), KindSet::All) => *slot = KindSet::All,
74 (KindSet::Named(a), KindSet::Named(b)) => a.extend(b),
75 }
76 }
77}
78
79/// Where a directive applies, relative to the comment's own line.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81enum Scope {
82 /// The comment's own physical line.
83 SameLine,
84 /// The next code line (next non-blank physical line).
85 NextLine,
86 /// Every line in the file.
87 File,
88}
89
90struct Directive {
91 scope: Scope,
92 kinds: KindSet,
93 /// For [`Scope::NextLine`]: whether to skip intervening comment lines (not
94 /// just blanks) when locating the target. Set for "documents the following
95 /// element" forms (`@psalm-suppress`, bare `@mir-ignore`, …) so a directive
96 /// inside a multi-line docblock still lands on the declaration it annotates,
97 /// past the closing `*/`.
98 skip_comments: bool,
99}
100
101/// Per-file map of suppressed lines, built from source comments.
102#[derive(Debug, Default)]
103pub struct SuppressionMap {
104 /// 1-based line number → kinds suppressed on that line.
105 lines: FxHashMap<u32, KindSet>,
106 /// Whole-file suppression, if any directive requested it.
107 file: Option<KindSet>,
108 /// Named (non-All) suppressions with their target lines, for
109 /// `UnusedSuppress` detection. Each entry is `(target_line, kind_name)`.
110 /// Only `@psalm-suppress X` / `@suppress X` / `@mir-suppress X` forms populate
111 /// this — blanket `@phpstan-ignore*` suppressions are intentionally excluded.
112 pub named_suppressions: Vec<(u32, String)>,
113}
114
115impl SuppressionMap {
116 /// No directives — used to skip work for files with no suppression comments.
117 pub fn is_empty(&self) -> bool {
118 self.lines.is_empty() && self.file.is_none()
119 }
120
121 /// Whether an issue of `name`/`code` reported at 1-based `line` is suppressed.
122 pub fn is_suppressed(&self, line: u32, name: &str, code: &str) -> bool {
123 if let Some(file) = &self.file {
124 if file.matches(name, code) {
125 return true;
126 }
127 }
128 self.lines.get(&line).is_some_and(|k| k.matches(name, code))
129 }
130
131 /// Scan `source` for suppression directives.
132 pub fn from_source(source: &str) -> Self {
133 let raw_lines: Vec<&str> = source.lines().collect();
134 let mut map = SuppressionMap::default();
135
136 for (idx, raw) in raw_lines.iter().enumerate() {
137 let Some((directive, track_named)) = parse_directive_with_tracking(raw) else {
138 continue;
139 };
140 match directive.scope {
141 Scope::File => match &mut map.file {
142 Some(existing) => existing.merge(directive.kinds),
143 None => map.file = Some(directive.kinds),
144 },
145 Scope::SameLine => {
146 let line_no = idx as u32 + 1;
147 if track_named {
148 if let KindSet::Named(ref names) = directive.kinds {
149 for name in names {
150 map.named_suppressions.push((line_no, name.clone()));
151 }
152 }
153 }
154 insert_line(&mut map.lines, line_no, directive.kinds);
155 }
156 Scope::NextLine => {
157 let target = next_code_line(&raw_lines, idx, directive.skip_comments);
158 if track_named {
159 if let KindSet::Named(ref names) = directive.kinds {
160 for name in names {
161 map.named_suppressions.push((target, name.clone()));
162 }
163 }
164 }
165 insert_line(&mut map.lines, target, directive.kinds);
166 }
167 }
168 }
169
170 map
171 }
172
173 /// Returns unused named suppressions: those that did not match any issue
174 /// in `all_issues`. The returned vec contains `(target_line, kind_name)`.
175 ///
176 /// `pre_suppressed` is the subset of `all_issues` that arrived already
177 /// suppressed (via the `IssueBuffer` mechanism in collector/body analysis).
178 /// These may be emitted at a different line than the suppression target
179 /// (e.g. `InvalidDocblock` at a docblock-start line vs. the following
180 /// declaration line), so they are matched within a 30-line window before
181 /// the target.
182 pub fn unused_named(
183 &self,
184 all_issues: &[&mir_issues::Issue],
185 pre_suppressed: &[&mir_issues::Issue],
186 ) -> Vec<(u32, String)> {
187 self.named_suppressions
188 .iter()
189 .filter(|(target_line, kind)| {
190 // Compare case-insensitively, same as `KindSet::matches` —
191 // a directive can name a kind in any casing.
192 let kind_matches = |issue: &&mir_issues::Issue| {
193 issue.kind.name().eq_ignore_ascii_case(kind.as_str())
194 || issue.kind.code().eq_ignore_ascii_case(kind.as_str())
195 };
196 // Normal case: SuppressionMap-suppressed issue at the exact target line.
197 let at_target = all_issues
198 .iter()
199 .any(|issue| issue.location.line == *target_line && kind_matches(issue));
200 if at_target {
201 return false; // suppression IS used
202 }
203 // Docblock case: collector-emitted issues (like `InvalidDocblock`)
204 // land at the docblock-start line, which precedes the declaration
205 // that the suppression targets. Allow a 30-line look-back so a
206 // `@psalm-suppress InvalidDocblock` in a multi-line docblock is
207 // recognised as used even though its issue line != target_line.
208 let min_line = target_line.saturating_sub(30);
209 let covered_by_pre_suppressed = pre_suppressed.iter().any(|issue| {
210 issue.location.line >= min_line
211 && issue.location.line < *target_line
212 && kind_matches(issue)
213 });
214 !covered_by_pre_suppressed
215 })
216 .cloned()
217 .collect()
218 }
219}
220
221fn insert_line(lines: &mut FxHashMap<u32, KindSet>, line: u32, kinds: KindSet) {
222 match lines.get_mut(&line) {
223 Some(existing) => existing.merge(kinds),
224 None => {
225 lines.insert(line, kinds);
226 }
227 }
228}
229
230/// Locate a directive's target line strictly after `idx`, as a 1-based number.
231///
232/// Always skips blank lines. When `skip_comments` is set, also skips
233/// comment-only lines (`//`, `#`, `/* … */`, ` * …` docblock bodies and the
234/// closing `*/`) so a directive written inside a multi-line docblock lands on
235/// the declaration that follows it. Falls back to `idx + 2` when nothing
236/// qualifies, so the directive still has a deterministic target.
237fn next_code_line(raw_lines: &[&str], idx: usize, skip_comments: bool) -> u32 {
238 for (offset, line) in raw_lines.iter().enumerate().skip(idx + 1) {
239 let trimmed = line.trim();
240 if trimmed.is_empty() {
241 continue;
242 }
243 if skip_comments && is_comment_only(trimmed) {
244 continue;
245 }
246 return offset as u32 + 1;
247 }
248 idx as u32 + 2
249}
250
251/// Whether a trimmed line is purely a comment (no PHP code). `#[` is treated as
252/// a PHP 8 attribute (code), not a `#` comment.
253fn is_comment_only(trimmed: &str) -> bool {
254 trimmed.starts_with("//")
255 || trimmed.starts_with("/*")
256 || trimmed.starts_with('*')
257 || (trimmed.starts_with('#') && !trimmed.starts_with("#["))
258}
259
260/// Directive keyword table, ordered longest-first so that, e.g.,
261/// `@mir-ignore-next-line` is matched before the `@mir-ignore` prefix.
262///
263/// Each entry is `(keyword, scope, force_all)`. `force_all` makes the directive
264/// suppress every kind regardless of trailing tokens (PHPStan semantics).
265const KEYWORDS: &[(&str, Scope, bool)] = &[
266 ("@mir-ignore-next-line", Scope::NextLine, false),
267 ("@mir-suppress-next-line", Scope::NextLine, false),
268 ("@phpstan-ignore-next-line", Scope::NextLine, true),
269 ("@mir-ignore-line", Scope::SameLine, false),
270 ("@mir-suppress-line", Scope::SameLine, false),
271 ("@phpstan-ignore-line", Scope::SameLine, true),
272 ("@mir-ignore-file", Scope::File, false),
273 ("@mir-suppress-file", Scope::File, false),
274 // Bare forms (scope resolved below from comment position).
275 ("@mir-ignore", Scope::NextLine, false),
276 ("@mir-suppress", Scope::NextLine, false),
277 ("@psalm-suppress", Scope::NextLine, false),
278 ("@suppress", Scope::NextLine, false),
279 ("@phpstan-ignore", Scope::NextLine, true),
280];
281
282/// Bare directives (no `-line`/`-next-line`/`-file` suffix) resolve their scope
283/// from where the comment sits: a trailing comment annotates its own line, a
284/// standalone comment annotates the statement that follows it.
285const BARE_KEYWORDS: &[&str] = &[
286 "@mir-ignore",
287 "@mir-suppress",
288 "@psalm-suppress",
289 "@suppress",
290 "@phpstan-ignore",
291];
292
293/// Like `parse_directive` (which is parse_directive_with_tracking discarding the tracking flag),
294/// but also returns whether named suppression tracking
295/// should be applied (true for `@psalm-suppress`, `@mir-suppress`, `@suppress`
296/// and `@mir-ignore` forms; false for `@phpstan-*` which are blanket suppressors
297/// not tied to specific issue kinds).
298fn parse_directive_with_tracking(raw: &str) -> Option<(Directive, bool)> {
299 let comment = extract_comment(raw)?;
300
301 for &(keyword, scope, force_all) in KEYWORDS {
302 let Some(pos) = comment.content.find(keyword) else {
303 continue;
304 };
305 // Reject keyword matches that are really a prefix of a longer token
306 // (e.g. `@mir-ignore` inside `@mir-ignore-line`).
307 let after = &comment.content[pos + keyword.len()..];
308 if after
309 .chars()
310 .next()
311 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-')
312 {
313 continue;
314 }
315
316 let is_bare = BARE_KEYWORDS.contains(&keyword);
317
318 // Bare forms: a trailing comment suppresses its own line.
319 let scope = if is_bare && comment.has_code_before {
320 Scope::SameLine
321 } else {
322 scope
323 };
324
325 // The "documents the following element" forms (bare `@psalm-suppress`,
326 // `@mir-ignore`, …) skip past intervening comment lines — e.g. the
327 // closing `*/` of a multi-line docblock — to reach the declaration.
328 // PHPStan's explicit `*-next-line` and bare `@phpstan-ignore` keep their
329 // literal next-non-blank-line semantics.
330 let skip_comments = scope == Scope::NextLine && is_bare && !force_all;
331
332 let kinds = if force_all {
333 KindSet::All
334 } else {
335 parse_kinds(after)
336 };
337
338 // Track named suppressions only for non-phpstan forms (phpstan forms
339 // always suppress all kinds, so they can never be "unused for a specific kind").
340 let track_named = !keyword.starts_with("@phpstan");
341
342 return Some((
343 Directive {
344 scope,
345 kinds,
346 skip_comments,
347 },
348 track_named,
349 ));
350 }
351
352 None
353}
354
355struct Comment<'a> {
356 /// Text from the comment introducer onward (still includes `*/`, `*`, etc.).
357 content: &'a str,
358 /// Whether non-whitespace code precedes the comment on the same line.
359 has_code_before: bool,
360}
361
362/// Isolate the comment portion of a physical line, if any. Handles `//`, `#`
363/// and `/* … */` introducers, block-comment continuation lines (` * …`) and
364/// bare directive lines inside block comments (`@psalm-suppress …`).
365fn extract_comment(raw: &str) -> Option<Comment<'_>> {
366 let trimmed = raw.trim_start();
367
368 // Block-comment continuation or a bare directive line: no code precedes it.
369 if trimmed.starts_with('*') {
370 return Some(Comment {
371 content: trimmed.trim_start_matches('*'),
372 has_code_before: false,
373 });
374 }
375 if trimmed.starts_with('@') {
376 return Some(Comment {
377 content: trimmed,
378 has_code_before: false,
379 });
380 }
381
382 // Earliest single-line / block introducer on the line.
383 let pos = [raw.find("//"), raw.find('#'), raw.find("/*")]
384 .into_iter()
385 .flatten()
386 .min()?;
387 let has_code_before = !raw[..pos].trim().is_empty();
388 Some(Comment {
389 content: &raw[pos..],
390 has_code_before,
391 })
392}
393
394/// Collect issue kind names/codes following a directive keyword. Stops at the
395/// block-comment terminator and ignores non-identifier tokens. An empty result
396/// means "all kinds".
397fn parse_kinds(rest: &str) -> KindSet {
398 let mut set = FxHashSet::default();
399 for token in rest.split([' ', '\t', ',']) {
400 let token = token.trim();
401 if token.is_empty() {
402 continue;
403 }
404 // End of the comment / docblock — stop scanning.
405 if token.starts_with("*/") || token.starts_with('*') {
406 break;
407 }
408 // A kind name is alphanumeric (plus `_`); anything else (a PHPStan
409 // identifier like `argument.type`, prose, etc.) is skipped.
410 if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
411 set.insert(token.to_string());
412 }
413 }
414 if set.is_empty() {
415 KindSet::All
416 } else {
417 KindSet::Named(set)
418 }
419}
420
421#[cfg(test)]
422mod tests {
423 use super::*;
424
425 fn map(src: &str) -> SuppressionMap {
426 SuppressionMap::from_source(src)
427 }
428
429 #[test]
430 fn line_comment_above_statement_suppresses_next_line() {
431 // line 2 comment → suppress line 3
432 let m = map("<?php\n// @psalm-suppress UndefinedClass\nnew NoSuchClass();\n");
433 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
434 assert!(!m.is_suppressed(2, "UndefinedClass", "MIR0000"));
435 }
436
437 #[test]
438 fn trailing_comment_suppresses_own_line() {
439 let m = map("<?php\nnew NoSuchClass(); // @mir-ignore UndefinedClass\n");
440 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
441 }
442
443 #[test]
444 fn single_line_docblock_above_statement() {
445 let m = map("<?php\n/** @psalm-suppress UndefinedClass */\nnew NoSuchClass();\n");
446 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
447 }
448
449 #[test]
450 fn phpstan_ignore_next_line_suppresses_all() {
451 let m = map("<?php\n// @phpstan-ignore-next-line\nnew NoSuchClass();\n");
452 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
453 assert!(m.is_suppressed(3, "AnyOtherKind", "MIR9999"));
454 }
455
456 #[test]
457 fn ignore_line_targets_own_line() {
458 let m = map("<?php\nnew NoSuchClass(); // @mir-ignore-line\n");
459 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
460 }
461
462 #[test]
463 fn next_line_skips_blank_lines() {
464 let m = map("<?php\n/** @psalm-suppress UndefinedClass */\n\n\nnew NoSuchClass();\n");
465 assert!(m.is_suppressed(5, "UndefinedClass", "MIR0000"));
466 }
467
468 #[test]
469 fn multiline_docblock_skips_to_declaration() {
470 // line 2: /**, line 3: * @psalm-suppress, line 4: */, line 5: declaration.
471 let src =
472 "<?php\n/**\n * @psalm-suppress UnusedMethod\n */\nprivate function a(): void {}\n";
473 let m = map(src);
474 assert!(m.is_suppressed(5, "UnusedMethod", "MIR0000"));
475 }
476
477 #[test]
478 fn phpstan_next_line_is_literal_not_comment_skipping() {
479 // PHPStan's -next-line targets the next non-blank line even if it's a
480 // comment; it does not hunt for the next code line.
481 let m = map("<?php\n// @phpstan-ignore-next-line\n// unrelated comment\nfoo();\n");
482 assert!(m.is_suppressed(3, "X", "MIR0000"));
483 assert!(!m.is_suppressed(4, "X", "MIR0000"));
484 }
485
486 #[test]
487 fn named_kind_does_not_suppress_other_kinds() {
488 let m = map("<?php\n// @mir-ignore UndefinedClass\nfoo();\n");
489 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
490 assert!(!m.is_suppressed(3, "UndefinedFunction", "MIR0001"));
491 }
492
493 #[test]
494 fn match_by_code() {
495 let m = map("<?php\n// @mir-ignore MIR1400\nfoo();\n");
496 assert!(m.is_suppressed(3, "ParseError", "MIR1400"));
497 }
498
499 #[test]
500 fn file_scope_suppresses_every_line() {
501 let m = map("<?php // @mir-ignore-file UndefinedClass\nfoo();\nbar();\n");
502 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
503 assert!(m.is_suppressed(99, "UndefinedClass", "MIR0000"));
504 assert!(!m.is_suppressed(2, "UndefinedFunction", "MIR0001"));
505 }
506
507 #[test]
508 fn multiple_kinds_one_directive() {
509 let m = map("<?php\n// @psalm-suppress UndefinedClass, NullMethodCall\nfoo();\n");
510 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
511 assert!(m.is_suppressed(3, "NullMethodCall", "MIR0001"));
512 }
513
514 #[test]
515 fn no_directive_is_empty() {
516 let m = map("<?php\n$x = \"@psalm-suppress not a comment\";\nfoo();\n");
517 // It's inside a string but after `//`? No `//` here, so not detected.
518 assert!(m.is_empty());
519 }
520
521 #[test]
522 fn prefix_is_not_confused_with_longer_keyword() {
523 // `@mir-ignore-next-line` must be parsed as next-line, not bare same-line.
524 let m = map("<?php\nfoo(); // @mir-ignore-next-line\nbar();\n");
525 assert!(m.is_suppressed(3, "AnyKind", "MIR0000"));
526 assert!(!m.is_suppressed(2, "AnyKind", "MIR0000"));
527 }
528}