Skip to main content

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
194                        .kind
195                        .display_name()
196                        .eq_ignore_ascii_case(kind.as_str())
197                        || issue.kind.code().eq_ignore_ascii_case(kind.as_str())
198                };
199                // Normal case: SuppressionMap-suppressed issue at the exact target line.
200                let at_target = all_issues
201                    .iter()
202                    .any(|issue| issue.location.line == *target_line && kind_matches(issue));
203                if at_target {
204                    return false; // suppression IS used
205                }
206                // Docblock case: collector-emitted issues (like `InvalidDocblock`)
207                // land at the docblock-start line, which precedes the declaration
208                // that the suppression targets. Allow a 30-line look-back so a
209                // `@psalm-suppress InvalidDocblock` in a multi-line docblock is
210                // recognised as used even though its issue line != target_line.
211                let min_line = target_line.saturating_sub(30);
212                let covered_by_pre_suppressed = pre_suppressed.iter().any(|issue| {
213                    issue.location.line >= min_line
214                        && issue.location.line < *target_line
215                        && kind_matches(issue)
216                });
217                !covered_by_pre_suppressed
218            })
219            .cloned()
220            .collect()
221    }
222}
223
224fn insert_line(lines: &mut FxHashMap<u32, KindSet>, line: u32, kinds: KindSet) {
225    match lines.get_mut(&line) {
226        Some(existing) => existing.merge(kinds),
227        None => {
228            lines.insert(line, kinds);
229        }
230    }
231}
232
233/// Locate a directive's target line strictly after `idx`, as a 1-based number.
234///
235/// Always skips blank lines. When `skip_comments` is set, also skips
236/// comment-only lines (`//`, `#`, `/* … */`, ` * …` docblock bodies and the
237/// closing `*/`) so a directive written inside a multi-line docblock lands on
238/// the declaration that follows it. Falls back to `idx + 2` when nothing
239/// qualifies, so the directive still has a deterministic target.
240fn next_code_line(raw_lines: &[&str], idx: usize, skip_comments: bool) -> u32 {
241    for (offset, line) in raw_lines.iter().enumerate().skip(idx + 1) {
242        let trimmed = line.trim();
243        if trimmed.is_empty() {
244            continue;
245        }
246        if skip_comments && is_comment_only(trimmed) {
247            continue;
248        }
249        return offset as u32 + 1;
250    }
251    idx as u32 + 2
252}
253
254/// Whether a trimmed line is purely a comment (no PHP code). `#[` is treated as
255/// a PHP 8 attribute (code), not a `#` comment.
256fn is_comment_only(trimmed: &str) -> bool {
257    trimmed.starts_with("//")
258        || trimmed.starts_with("/*")
259        || trimmed.starts_with('*')
260        || (trimmed.starts_with('#') && !trimmed.starts_with("#["))
261}
262
263/// Directive keyword table, ordered longest-first so that, e.g.,
264/// `@mir-ignore-next-line` is matched before the `@mir-ignore` prefix.
265///
266/// Each entry is `(keyword, scope, force_all)`. `force_all` makes the directive
267/// suppress every kind regardless of trailing tokens (PHPStan semantics).
268const KEYWORDS: &[(&str, Scope, bool)] = &[
269    ("@mir-ignore-next-line", Scope::NextLine, false),
270    ("@mir-suppress-next-line", Scope::NextLine, false),
271    ("@phpstan-ignore-next-line", Scope::NextLine, true),
272    ("@mir-ignore-line", Scope::SameLine, false),
273    ("@mir-suppress-line", Scope::SameLine, false),
274    ("@phpstan-ignore-line", Scope::SameLine, true),
275    ("@mir-ignore-file", Scope::File, false),
276    ("@mir-suppress-file", Scope::File, false),
277    // Bare forms (scope resolved below from comment position).
278    ("@mir-ignore", Scope::NextLine, false),
279    ("@mir-suppress", Scope::NextLine, false),
280    ("@psalm-suppress", Scope::NextLine, false),
281    ("@suppress", Scope::NextLine, false),
282    ("@phpstan-ignore", Scope::NextLine, true),
283];
284
285/// Bare directives (no `-line`/`-next-line`/`-file` suffix) resolve their scope
286/// from where the comment sits: a trailing comment annotates its own line, a
287/// standalone comment annotates the statement that follows it.
288const BARE_KEYWORDS: &[&str] = &[
289    "@mir-ignore",
290    "@mir-suppress",
291    "@psalm-suppress",
292    "@suppress",
293    "@phpstan-ignore",
294];
295
296/// Like `parse_directive` (which is parse_directive_with_tracking discarding the tracking flag),
297/// but also returns whether named suppression tracking
298/// should be applied (true for `@psalm-suppress`, `@mir-suppress`, `@suppress`
299/// and `@mir-ignore` forms; false for `@phpstan-*` which are blanket suppressors
300/// not tied to specific issue kinds).
301fn parse_directive_with_tracking(raw: &str) -> Option<(Directive, bool)> {
302    let comment = extract_comment(raw)?;
303
304    for &(keyword, scope, force_all) in KEYWORDS {
305        let Some(pos) = comment.content.find(keyword) else {
306            continue;
307        };
308        // Reject keyword matches that are really a prefix of a longer token
309        // (e.g. `@mir-ignore` inside `@mir-ignore-line`).
310        let after = &comment.content[pos + keyword.len()..];
311        if after
312            .chars()
313            .next()
314            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-')
315        {
316            continue;
317        }
318
319        let is_bare = BARE_KEYWORDS.contains(&keyword);
320
321        // Bare forms: a trailing comment suppresses its own line.
322        let scope = if is_bare && comment.has_code_before {
323            Scope::SameLine
324        } else {
325            scope
326        };
327
328        // The "documents the following element" forms (bare `@psalm-suppress`,
329        // `@mir-ignore`, …) skip past intervening comment lines — e.g. the
330        // closing `*/` of a multi-line docblock — to reach the declaration.
331        // PHPStan's explicit `*-next-line` and bare `@phpstan-ignore` keep their
332        // literal next-non-blank-line semantics.
333        let skip_comments = scope == Scope::NextLine && is_bare && !force_all;
334
335        let kinds = if force_all {
336            KindSet::All
337        } else {
338            parse_kinds(after)
339        };
340
341        // Track named suppressions only for non-phpstan forms (phpstan forms
342        // always suppress all kinds, so they can never be "unused for a specific kind").
343        let track_named = !keyword.starts_with("@phpstan");
344
345        return Some((
346            Directive {
347                scope,
348                kinds,
349                skip_comments,
350            },
351            track_named,
352        ));
353    }
354
355    None
356}
357
358struct Comment<'a> {
359    /// Text from the comment introducer onward (still includes `*/`, `*`, etc.).
360    content: &'a str,
361    /// Whether non-whitespace code precedes the comment on the same line.
362    has_code_before: bool,
363}
364
365/// Isolate the comment portion of a physical line, if any. Handles `//`, `#`
366/// and `/* … */` introducers, block-comment continuation lines (` * …`) and
367/// bare directive lines inside block comments (`@psalm-suppress …`).
368fn extract_comment(raw: &str) -> Option<Comment<'_>> {
369    let trimmed = raw.trim_start();
370
371    // Block-comment continuation or a bare directive line: no code precedes it.
372    if trimmed.starts_with('*') {
373        return Some(Comment {
374            content: trimmed.trim_start_matches('*'),
375            has_code_before: false,
376        });
377    }
378    if trimmed.starts_with('@') {
379        return Some(Comment {
380            content: trimmed,
381            has_code_before: false,
382        });
383    }
384
385    // Earliest single-line / block introducer on the line.
386    let pos = [raw.find("//"), raw.find('#'), raw.find("/*")]
387        .into_iter()
388        .flatten()
389        .min()?;
390    let has_code_before = !raw[..pos].trim().is_empty();
391    Some(Comment {
392        content: &raw[pos..],
393        has_code_before,
394    })
395}
396
397/// Collect issue kind names/codes following a directive keyword. Stops at the
398/// block-comment terminator and ignores non-identifier tokens. An empty result
399/// means "all kinds".
400fn parse_kinds(rest: &str) -> KindSet {
401    let mut set = FxHashSet::default();
402    for token in rest.split([' ', '\t', ',']) {
403        let token = token.trim();
404        if token.is_empty() {
405            continue;
406        }
407        // End of the comment / docblock — stop scanning.
408        if token.starts_with("*/") || token.starts_with('*') {
409            break;
410        }
411        // A kind name is alphanumeric (plus `_`); anything else (a PHPStan
412        // identifier like `argument.type`, prose, etc.) is skipped.
413        if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
414            set.insert(token.to_string());
415        }
416    }
417    if set.is_empty() {
418        KindSet::All
419    } else {
420        KindSet::Named(set)
421    }
422}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    fn map(src: &str) -> SuppressionMap {
429        SuppressionMap::from_source(src)
430    }
431
432    #[test]
433    fn line_comment_above_statement_suppresses_next_line() {
434        // line 2 comment → suppress line 3
435        let m = map("<?php\n// @psalm-suppress UndefinedClass\nnew NoSuchClass();\n");
436        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
437        assert!(!m.is_suppressed(2, "UndefinedClass", "MIR0000"));
438    }
439
440    #[test]
441    fn trailing_comment_suppresses_own_line() {
442        let m = map("<?php\nnew NoSuchClass(); // @mir-ignore UndefinedClass\n");
443        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
444    }
445
446    #[test]
447    fn single_line_docblock_above_statement() {
448        let m = map("<?php\n/** @psalm-suppress UndefinedClass */\nnew NoSuchClass();\n");
449        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
450    }
451
452    #[test]
453    fn phpstan_ignore_next_line_suppresses_all() {
454        let m = map("<?php\n// @phpstan-ignore-next-line\nnew NoSuchClass();\n");
455        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
456        assert!(m.is_suppressed(3, "AnyOtherKind", "MIR9999"));
457    }
458
459    #[test]
460    fn ignore_line_targets_own_line() {
461        let m = map("<?php\nnew NoSuchClass(); // @mir-ignore-line\n");
462        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
463    }
464
465    #[test]
466    fn next_line_skips_blank_lines() {
467        let m = map("<?php\n/** @psalm-suppress UndefinedClass */\n\n\nnew NoSuchClass();\n");
468        assert!(m.is_suppressed(5, "UndefinedClass", "MIR0000"));
469    }
470
471    #[test]
472    fn multiline_docblock_skips_to_declaration() {
473        // line 2: /**, line 3: * @psalm-suppress, line 4: */, line 5: declaration.
474        let src =
475            "<?php\n/**\n * @psalm-suppress UnusedMethod\n */\nprivate function a(): void {}\n";
476        let m = map(src);
477        assert!(m.is_suppressed(5, "UnusedMethod", "MIR0000"));
478    }
479
480    #[test]
481    fn phpstan_next_line_is_literal_not_comment_skipping() {
482        // PHPStan's -next-line targets the next non-blank line even if it's a
483        // comment; it does not hunt for the next code line.
484        let m = map("<?php\n// @phpstan-ignore-next-line\n// unrelated comment\nfoo();\n");
485        assert!(m.is_suppressed(3, "X", "MIR0000"));
486        assert!(!m.is_suppressed(4, "X", "MIR0000"));
487    }
488
489    #[test]
490    fn named_kind_does_not_suppress_other_kinds() {
491        let m = map("<?php\n// @mir-ignore UndefinedClass\nfoo();\n");
492        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
493        assert!(!m.is_suppressed(3, "UndefinedFunction", "MIR0001"));
494    }
495
496    #[test]
497    fn match_by_code() {
498        let m = map("<?php\n// @mir-ignore MIR1400\nfoo();\n");
499        assert!(m.is_suppressed(3, "ParseError", "MIR1400"));
500    }
501
502    #[test]
503    fn file_scope_suppresses_every_line() {
504        let m = map("<?php // @mir-ignore-file UndefinedClass\nfoo();\nbar();\n");
505        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
506        assert!(m.is_suppressed(99, "UndefinedClass", "MIR0000"));
507        assert!(!m.is_suppressed(2, "UndefinedFunction", "MIR0001"));
508    }
509
510    #[test]
511    fn multiple_kinds_one_directive() {
512        let m = map("<?php\n// @psalm-suppress UndefinedClass, NullMethodCall\nfoo();\n");
513        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
514        assert!(m.is_suppressed(3, "NullMethodCall", "MIR0001"));
515    }
516
517    #[test]
518    fn no_directive_is_empty() {
519        let m = map("<?php\n$x = \"@psalm-suppress not a comment\";\nfoo();\n");
520        // It's inside a string but after `//`? No `//` here, so not detected.
521        assert!(m.is_empty());
522    }
523
524    #[test]
525    fn prefix_is_not_confused_with_longer_keyword() {
526        // `@mir-ignore-next-line` must be parsed as next-line, not bare same-line.
527        let m = map("<?php\nfoo(); // @mir-ignore-next-line\nbar();\n");
528        assert!(m.is_suppressed(3, "AnyKind", "MIR0000"));
529        assert!(!m.is_suppressed(2, "AnyKind", "MIR0000"));
530    }
531}