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            KindSet::Named(set) => set.contains(name) || set.contains(code),
56        }
57    }
58
59    fn merge(&mut self, other: KindSet) {
60        match (self, other) {
61            // Already broadest possible.
62            (KindSet::All, _) => {}
63            (slot @ KindSet::Named(_), KindSet::All) => *slot = KindSet::All,
64            (KindSet::Named(a), KindSet::Named(b)) => a.extend(b),
65        }
66    }
67}
68
69/// Where a directive applies, relative to the comment's own line.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum Scope {
72    /// The comment's own physical line.
73    SameLine,
74    /// The next code line (next non-blank physical line).
75    NextLine,
76    /// Every line in the file.
77    File,
78}
79
80struct Directive {
81    scope: Scope,
82    kinds: KindSet,
83    /// For [`Scope::NextLine`]: whether to skip intervening comment lines (not
84    /// just blanks) when locating the target. Set for "documents the following
85    /// element" forms (`@psalm-suppress`, bare `@mir-ignore`, …) so a directive
86    /// inside a multi-line docblock still lands on the declaration it annotates,
87    /// past the closing `*/`.
88    skip_comments: bool,
89}
90
91/// Per-file map of suppressed lines, built from source comments.
92#[derive(Debug, Default)]
93pub struct SuppressionMap {
94    /// 1-based line number → kinds suppressed on that line.
95    lines: FxHashMap<u32, KindSet>,
96    /// Whole-file suppression, if any directive requested it.
97    file: Option<KindSet>,
98}
99
100impl SuppressionMap {
101    /// No directives — used to skip work for files with no suppression comments.
102    pub fn is_empty(&self) -> bool {
103        self.lines.is_empty() && self.file.is_none()
104    }
105
106    /// Whether an issue of `name`/`code` reported at 1-based `line` is suppressed.
107    pub fn is_suppressed(&self, line: u32, name: &str, code: &str) -> bool {
108        if let Some(file) = &self.file {
109            if file.matches(name, code) {
110                return true;
111            }
112        }
113        self.lines.get(&line).is_some_and(|k| k.matches(name, code))
114    }
115
116    /// Scan `source` for suppression directives.
117    pub fn from_source(source: &str) -> Self {
118        let raw_lines: Vec<&str> = source.lines().collect();
119        let mut map = SuppressionMap::default();
120
121        for (idx, raw) in raw_lines.iter().enumerate() {
122            let Some(directive) = parse_directive(raw) else {
123                continue;
124            };
125            match directive.scope {
126                Scope::File => match &mut map.file {
127                    Some(existing) => existing.merge(directive.kinds),
128                    None => map.file = Some(directive.kinds),
129                },
130                Scope::SameLine => {
131                    let line_no = idx as u32 + 1;
132                    insert_line(&mut map.lines, line_no, directive.kinds);
133                }
134                Scope::NextLine => {
135                    let target = next_code_line(&raw_lines, idx, directive.skip_comments);
136                    insert_line(&mut map.lines, target, directive.kinds);
137                }
138            }
139        }
140
141        map
142    }
143}
144
145fn insert_line(lines: &mut FxHashMap<u32, KindSet>, line: u32, kinds: KindSet) {
146    match lines.get_mut(&line) {
147        Some(existing) => existing.merge(kinds),
148        None => {
149            lines.insert(line, kinds);
150        }
151    }
152}
153
154/// Locate a directive's target line strictly after `idx`, as a 1-based number.
155///
156/// Always skips blank lines. When `skip_comments` is set, also skips
157/// comment-only lines (`//`, `#`, `/* … */`, ` * …` docblock bodies and the
158/// closing `*/`) so a directive written inside a multi-line docblock lands on
159/// the declaration that follows it. Falls back to `idx + 2` when nothing
160/// qualifies, so the directive still has a deterministic target.
161fn next_code_line(raw_lines: &[&str], idx: usize, skip_comments: bool) -> u32 {
162    for (offset, line) in raw_lines.iter().enumerate().skip(idx + 1) {
163        let trimmed = line.trim();
164        if trimmed.is_empty() {
165            continue;
166        }
167        if skip_comments && is_comment_only(trimmed) {
168            continue;
169        }
170        return offset as u32 + 1;
171    }
172    idx as u32 + 2
173}
174
175/// Whether a trimmed line is purely a comment (no PHP code). `#[` is treated as
176/// a PHP 8 attribute (code), not a `#` comment.
177fn is_comment_only(trimmed: &str) -> bool {
178    trimmed.starts_with("//")
179        || trimmed.starts_with("/*")
180        || trimmed.starts_with('*')
181        || (trimmed.starts_with('#') && !trimmed.starts_with("#["))
182}
183
184/// Directive keyword table, ordered longest-first so that, e.g.,
185/// `@mir-ignore-next-line` is matched before the `@mir-ignore` prefix.
186///
187/// Each entry is `(keyword, scope, force_all)`. `force_all` makes the directive
188/// suppress every kind regardless of trailing tokens (PHPStan semantics).
189const KEYWORDS: &[(&str, Scope, bool)] = &[
190    ("@mir-ignore-next-line", Scope::NextLine, false),
191    ("@mir-suppress-next-line", Scope::NextLine, false),
192    ("@phpstan-ignore-next-line", Scope::NextLine, true),
193    ("@mir-ignore-line", Scope::SameLine, false),
194    ("@mir-suppress-line", Scope::SameLine, false),
195    ("@phpstan-ignore-line", Scope::SameLine, true),
196    ("@mir-ignore-file", Scope::File, false),
197    ("@mir-suppress-file", Scope::File, false),
198    // Bare forms (scope resolved below from comment position).
199    ("@mir-ignore", Scope::NextLine, false),
200    ("@mir-suppress", Scope::NextLine, false),
201    ("@psalm-suppress", Scope::NextLine, false),
202    ("@suppress", Scope::NextLine, false),
203    ("@phpstan-ignore", Scope::NextLine, true),
204];
205
206/// Bare directives (no `-line`/`-next-line`/`-file` suffix) resolve their scope
207/// from where the comment sits: a trailing comment annotates its own line, a
208/// standalone comment annotates the statement that follows it.
209const BARE_KEYWORDS: &[&str] = &[
210    "@mir-ignore",
211    "@mir-suppress",
212    "@psalm-suppress",
213    "@suppress",
214    "@phpstan-ignore",
215];
216
217fn parse_directive(raw: &str) -> Option<Directive> {
218    let comment = extract_comment(raw)?;
219
220    for &(keyword, scope, force_all) in KEYWORDS {
221        let Some(pos) = comment.content.find(keyword) else {
222            continue;
223        };
224        // Reject keyword matches that are really a prefix of a longer token
225        // (e.g. `@mir-ignore` inside `@mir-ignore-line`).
226        let after = &comment.content[pos + keyword.len()..];
227        if after
228            .chars()
229            .next()
230            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-')
231        {
232            continue;
233        }
234
235        let is_bare = BARE_KEYWORDS.contains(&keyword);
236
237        // Bare forms: a trailing comment suppresses its own line.
238        let scope = if is_bare && comment.has_code_before {
239            Scope::SameLine
240        } else {
241            scope
242        };
243
244        // The "documents the following element" forms (bare `@psalm-suppress`,
245        // `@mir-ignore`, …) skip past intervening comment lines — e.g. the
246        // closing `*/` of a multi-line docblock — to reach the declaration.
247        // PHPStan's explicit `*-next-line` and bare `@phpstan-ignore` keep their
248        // literal next-non-blank-line semantics.
249        let skip_comments = scope == Scope::NextLine && is_bare && !force_all;
250
251        let kinds = if force_all {
252            KindSet::All
253        } else {
254            parse_kinds(after)
255        };
256
257        return Some(Directive {
258            scope,
259            kinds,
260            skip_comments,
261        });
262    }
263
264    None
265}
266
267struct Comment<'a> {
268    /// Text from the comment introducer onward (still includes `*/`, `*`, etc.).
269    content: &'a str,
270    /// Whether non-whitespace code precedes the comment on the same line.
271    has_code_before: bool,
272}
273
274/// Isolate the comment portion of a physical line, if any. Handles `//`, `#`
275/// and `/* … */` introducers, block-comment continuation lines (` * …`) and
276/// bare directive lines inside block comments (`@psalm-suppress …`).
277fn extract_comment(raw: &str) -> Option<Comment<'_>> {
278    let trimmed = raw.trim_start();
279
280    // Block-comment continuation or a bare directive line: no code precedes it.
281    if trimmed.starts_with('*') {
282        return Some(Comment {
283            content: trimmed.trim_start_matches('*'),
284            has_code_before: false,
285        });
286    }
287    if trimmed.starts_with('@') {
288        return Some(Comment {
289            content: trimmed,
290            has_code_before: false,
291        });
292    }
293
294    // Earliest single-line / block introducer on the line.
295    let pos = [raw.find("//"), raw.find('#'), raw.find("/*")]
296        .into_iter()
297        .flatten()
298        .min()?;
299    let has_code_before = !raw[..pos].trim().is_empty();
300    Some(Comment {
301        content: &raw[pos..],
302        has_code_before,
303    })
304}
305
306/// Collect issue kind names/codes following a directive keyword. Stops at the
307/// block-comment terminator and ignores non-identifier tokens. An empty result
308/// means "all kinds".
309fn parse_kinds(rest: &str) -> KindSet {
310    let mut set = FxHashSet::default();
311    for token in rest.split([' ', '\t', ',']) {
312        let token = token.trim();
313        if token.is_empty() {
314            continue;
315        }
316        // End of the comment / docblock — stop scanning.
317        if token.starts_with("*/") || token.starts_with('*') {
318            break;
319        }
320        // A kind name is alphanumeric (plus `_`); anything else (a PHPStan
321        // identifier like `argument.type`, prose, etc.) is skipped.
322        if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
323            set.insert(token.to_string());
324        }
325    }
326    if set.is_empty() {
327        KindSet::All
328    } else {
329        KindSet::Named(set)
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use super::*;
336
337    fn map(src: &str) -> SuppressionMap {
338        SuppressionMap::from_source(src)
339    }
340
341    #[test]
342    fn line_comment_above_statement_suppresses_next_line() {
343        // line 2 comment → suppress line 3
344        let m = map("<?php\n// @psalm-suppress UndefinedClass\nnew NoSuchClass();\n");
345        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
346        assert!(!m.is_suppressed(2, "UndefinedClass", "MIR0000"));
347    }
348
349    #[test]
350    fn trailing_comment_suppresses_own_line() {
351        let m = map("<?php\nnew NoSuchClass(); // @mir-ignore UndefinedClass\n");
352        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
353    }
354
355    #[test]
356    fn single_line_docblock_above_statement() {
357        let m = map("<?php\n/** @psalm-suppress UndefinedClass */\nnew NoSuchClass();\n");
358        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
359    }
360
361    #[test]
362    fn phpstan_ignore_next_line_suppresses_all() {
363        let m = map("<?php\n// @phpstan-ignore-next-line\nnew NoSuchClass();\n");
364        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
365        assert!(m.is_suppressed(3, "AnyOtherKind", "MIR9999"));
366    }
367
368    #[test]
369    fn ignore_line_targets_own_line() {
370        let m = map("<?php\nnew NoSuchClass(); // @mir-ignore-line\n");
371        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
372    }
373
374    #[test]
375    fn next_line_skips_blank_lines() {
376        let m = map("<?php\n/** @psalm-suppress UndefinedClass */\n\n\nnew NoSuchClass();\n");
377        assert!(m.is_suppressed(5, "UndefinedClass", "MIR0000"));
378    }
379
380    #[test]
381    fn multiline_docblock_skips_to_declaration() {
382        // line 2: /**, line 3: * @psalm-suppress, line 4: */, line 5: declaration.
383        let src =
384            "<?php\n/**\n * @psalm-suppress UnusedMethod\n */\nprivate function a(): void {}\n";
385        let m = map(src);
386        assert!(m.is_suppressed(5, "UnusedMethod", "MIR0000"));
387    }
388
389    #[test]
390    fn phpstan_next_line_is_literal_not_comment_skipping() {
391        // PHPStan's -next-line targets the next non-blank line even if it's a
392        // comment; it does not hunt for the next code line.
393        let m = map("<?php\n// @phpstan-ignore-next-line\n// unrelated comment\nfoo();\n");
394        assert!(m.is_suppressed(3, "X", "MIR0000"));
395        assert!(!m.is_suppressed(4, "X", "MIR0000"));
396    }
397
398    #[test]
399    fn named_kind_does_not_suppress_other_kinds() {
400        let m = map("<?php\n// @mir-ignore UndefinedClass\nfoo();\n");
401        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
402        assert!(!m.is_suppressed(3, "UndefinedFunction", "MIR0001"));
403    }
404
405    #[test]
406    fn match_by_code() {
407        let m = map("<?php\n// @mir-ignore MIR1400\nfoo();\n");
408        assert!(m.is_suppressed(3, "ParseError", "MIR1400"));
409    }
410
411    #[test]
412    fn file_scope_suppresses_every_line() {
413        let m = map("<?php // @mir-ignore-file UndefinedClass\nfoo();\nbar();\n");
414        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
415        assert!(m.is_suppressed(99, "UndefinedClass", "MIR0000"));
416        assert!(!m.is_suppressed(2, "UndefinedFunction", "MIR0001"));
417    }
418
419    #[test]
420    fn multiple_kinds_one_directive() {
421        let m = map("<?php\n// @psalm-suppress UndefinedClass, NullMethodCall\nfoo();\n");
422        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
423        assert!(m.is_suppressed(3, "NullMethodCall", "MIR0001"));
424    }
425
426    #[test]
427    fn no_directive_is_empty() {
428        let m = map("<?php\n$x = \"@psalm-suppress not a comment\";\nfoo();\n");
429        // It's inside a string but after `//`? No `//` here, so not detected.
430        assert!(m.is_empty());
431    }
432
433    #[test]
434    fn prefix_is_not_confused_with_longer_keyword() {
435        // `@mir-ignore-next-line` must be parsed as next-line, not bare same-line.
436        let m = map("<?php\nfoo(); // @mir-ignore-next-line\nbar();\n");
437        assert!(m.is_suppressed(3, "AnyKind", "MIR0000"));
438        assert!(!m.is_suppressed(2, "AnyKind", "MIR0000"));
439    }
440}