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 in_heredoc_body = heredoc_body_mask(&raw_lines);
135        let mut map = SuppressionMap::default();
136
137        for (idx, raw) in raw_lines.iter().enumerate() {
138            // A `#`/`//`-looking line INSIDE a heredoc/nowdoc body (embedded
139            // shell/SQL/etc.) is not a real comment — without this, a line
140            // like `# @mir-ignore-file UndefinedClass` in an embedded script
141            // is indistinguishable from a genuine suppression directive.
142            if in_heredoc_body[idx] {
143                continue;
144            }
145            let Some((directive, track_named)) = parse_directive_with_tracking(raw) else {
146                continue;
147            };
148            match directive.scope {
149                Scope::File => match &mut map.file {
150                    Some(existing) => existing.merge(directive.kinds),
151                    None => map.file = Some(directive.kinds),
152                },
153                Scope::SameLine => {
154                    let line_no = idx as u32 + 1;
155                    if track_named {
156                        if let KindSet::Named(ref names) = directive.kinds {
157                            for name in names {
158                                map.named_suppressions.push((line_no, name.clone()));
159                            }
160                        }
161                    }
162                    insert_line(&mut map.lines, line_no, directive.kinds);
163                }
164                Scope::NextLine => {
165                    let (target, attribute_lines) =
166                        next_code_line(&raw_lines, idx, directive.skip_comments);
167                    if track_named {
168                        if let KindSet::Named(ref names) = directive.kinds {
169                            for name in names {
170                                map.named_suppressions.push((target, name.clone()));
171                            }
172                        }
173                    }
174                    // An attribute line skipped en route to `target` stays
175                    // covered by the same directive — an issue about the
176                    // attribute itself (e.g. `UndefinedAttributeClass`) fires
177                    // there, not at `target`.
178                    for attr_line in attribute_lines {
179                        insert_line(&mut map.lines, attr_line, directive.kinds.clone());
180                    }
181                    insert_line(&mut map.lines, target, directive.kinds);
182                }
183            }
184        }
185
186        map
187    }
188
189    /// Returns unused named suppressions: those that did not match any issue
190    /// in `all_issues`. The returned vec contains `(target_line, kind_name)`.
191    ///
192    /// `pre_suppressed` is the subset of `all_issues` that arrived already
193    /// suppressed (via the `IssueBuffer` mechanism in collector/body analysis).
194    /// These may be emitted at a different line than the suppression target
195    /// (e.g. `InvalidDocblock` at a docblock-start line vs. the following
196    /// declaration line), so they are matched within a 30-line window before
197    /// the target.
198    pub fn unused_named(
199        &self,
200        all_issues: &[&mir_issues::Issue],
201        pre_suppressed: &[&mir_issues::Issue],
202    ) -> Vec<(u32, String)> {
203        self.named_suppressions
204            .iter()
205            .filter(|(target_line, kind)| {
206                // Compare case-insensitively, same as `KindSet::matches` —
207                // a directive can name a kind in any casing.
208                let kind_matches = |issue: &&mir_issues::Issue| {
209                    issue
210                        .kind
211                        .display_name()
212                        .eq_ignore_ascii_case(kind.as_str())
213                        || issue.kind.code().eq_ignore_ascii_case(kind.as_str())
214                };
215                // Normal case: SuppressionMap-suppressed issue at the exact target line.
216                let at_target = all_issues
217                    .iter()
218                    .any(|issue| issue.location.line == *target_line && kind_matches(issue));
219                if at_target {
220                    return false; // suppression IS used
221                }
222                // Docblock case: collector-emitted issues (like `InvalidDocblock`)
223                // land at the docblock-start line, which precedes the declaration
224                // that the suppression targets. Allow a 100-line look-back so a
225                // `@psalm-suppress InvalidDocblock` in an unusually long
226                // multi-line docblock (heavy @template/@method/@property lists)
227                // is still recognised as used even though its issue line !=
228                // target_line — 30 lines was too easy to exceed for such a
229                // docblock, wrongly reporting a real, used suppression as unused.
230                let min_line = target_line.saturating_sub(100);
231                let covered_by_pre_suppressed = pre_suppressed.iter().any(|issue| {
232                    issue.location.line >= min_line
233                        && issue.location.line < *target_line
234                        && kind_matches(issue)
235                });
236                !covered_by_pre_suppressed
237            })
238            .cloned()
239            .collect()
240    }
241}
242
243fn insert_line(lines: &mut FxHashMap<u32, KindSet>, line: u32, kinds: KindSet) {
244    match lines.get_mut(&line) {
245        Some(existing) => existing.merge(kinds),
246        None => {
247            lines.insert(line, kinds);
248        }
249    }
250}
251
252/// Per-line mask: `true` for a physical line that falls INSIDE a heredoc or
253/// nowdoc body (between the `<<<IDENT` opener line and its closing `IDENT`
254/// line), `false` everywhere else — including the opener line itself, which
255/// still carries real PHP code (`$x = <<<EOT`) before the body starts.
256///
257/// Deliberately simple, matching this file's existing line-based scanning:
258/// doesn't defend against a `<<<` that itself appears inside an unrelated
259/// string literal on the same line (a separate, rarer gap) — only real
260/// heredoc/nowdoc openers are expected to actually appear this way in
261/// practice.
262fn heredoc_body_mask(raw_lines: &[&str]) -> Vec<bool> {
263    let mut mask = vec![false; raw_lines.len()];
264    let mut closing_ident: Option<&str> = None;
265    for (idx, line) in raw_lines.iter().enumerate() {
266        if let Some(ident) = closing_ident {
267            mask[idx] = true;
268            if is_heredoc_closing_line(line, ident) {
269                closing_ident = None;
270            }
271            continue;
272        }
273        closing_ident = find_heredoc_opener(line);
274    }
275    mask
276}
277
278/// If `line` opens a heredoc (`<<<IDENT`) or nowdoc (`<<<'IDENT'`/`<<<"IDENT"`),
279/// return the closing identifier to watch for.
280fn find_heredoc_opener(line: &str) -> Option<&str> {
281    let pos = line.find("<<<")?;
282    let rest = line[pos + 3..].trim_start();
283    let (quote, rest) = match rest.as_bytes().first() {
284        Some(b'\'') => (Some(b'\''), &rest[1..]),
285        Some(b'"') => (Some(b'"'), &rest[1..]),
286        _ => (None, rest),
287    };
288    let end = rest
289        .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
290        .unwrap_or(rest.len());
291    let ident = &rest[..end];
292    if ident.is_empty() {
293        return None;
294    }
295    if let Some(q) = quote {
296        if rest.as_bytes().get(end) != Some(&q) {
297            return None;
298        }
299    }
300    Some(ident)
301}
302
303/// Whether `line` is the closing line of a heredoc/nowdoc body started with
304/// `ident` — optional leading whitespace (PHP 7.3+ flexible heredoc
305/// indentation), then the identifier as a whole word (not a prefix of a
306/// longer identifier).
307fn is_heredoc_closing_line(line: &str, ident: &str) -> bool {
308    let Some(rest) = line.trim_start().strip_prefix(ident) else {
309        return false;
310    };
311    rest.chars()
312        .next()
313        .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_')
314}
315
316/// Locate a directive's target line strictly after `idx`, as a 1-based
317/// number, plus every single-line PHP 8 attribute (`#[Foo]`) line skipped
318/// along the way.
319///
320/// Always skips blank lines and an attribute line sitting between the
321/// directive and the declaration it modifies — real code, but never what a
322/// suppression directive means to target (the declaration that follows).
323/// The skipped attribute lines are returned too, not just discarded: an
324/// issue about the attribute ITSELF (e.g. `UndefinedAttributeClass`, "this
325/// attribute class does not exist") fires on the attribute's own line, not
326/// the final target, and must stay covered by the same directive as the
327/// declaration it annotates. When `skip_comments` is set, also skips
328/// comment-only lines (`//`, `#`, `/* … */`, ` * …` docblock bodies and the
329/// closing `*/`) so a directive written inside a multi-line docblock lands
330/// on the declaration that follows it. Falls back to `idx + 2` when nothing
331/// qualifies, so the directive still has a deterministic target.
332fn next_code_line(raw_lines: &[&str], idx: usize, skip_comments: bool) -> (u32, Vec<u32>) {
333    let mut attribute_lines = Vec::new();
334    // Net `[`/`]` depth of an in-progress multi-line attribute (`#[` opener
335    // whose closing `]` is on a later line) — 0 when not currently inside
336    // one. Every line consumed while this is positive is itself an
337    // attribute line, same treatment the single-line case already gets.
338    let mut multiline_attr_depth: i32 = 0;
339    for (offset, line) in raw_lines.iter().enumerate().skip(idx + 1) {
340        let trimmed = line.trim();
341        if multiline_attr_depth > 0 {
342            attribute_lines.push(offset as u32 + 1);
343            multiline_attr_depth += bracket_delta(trimmed);
344            continue;
345        }
346        if trimmed.is_empty() {
347            continue;
348        }
349        if is_attribute_only(trimmed) {
350            attribute_lines.push(offset as u32 + 1);
351            continue;
352        }
353        // A `#[` opener with no matching `]` on the SAME line (net positive
354        // bracket delta) starts a multi-line attribute — track depth across
355        // subsequent lines instead of wrongly treating this opener line as
356        // the real target.
357        if trimmed.starts_with("#[") {
358            let delta = bracket_delta(trimmed);
359            if delta > 0 {
360                attribute_lines.push(offset as u32 + 1);
361                multiline_attr_depth = delta;
362                continue;
363            }
364        }
365        if skip_comments && is_comment_only(trimmed) {
366            continue;
367        }
368        return (offset as u32 + 1, attribute_lines);
369    }
370    (idx as u32 + 2, attribute_lines)
371}
372
373/// Net count of `[` minus `]` in `s` — no quote-awareness, matching the
374/// same conservative scope `is_attribute_only`'s plain `ends_with(']')`
375/// already accepts (real attribute argument text containing a bracket
376/// inside a string literal is out of scope here).
377fn bracket_delta(s: &str) -> i32 {
378    s.chars().fold(0i32, |depth, c| match c {
379        '[' => depth + 1,
380        ']' => depth - 1,
381        _ => depth,
382    })
383}
384
385/// Whether a trimmed line is purely a comment (no PHP code). `#[` is treated as
386/// a PHP 8 attribute (code), not a `#` comment.
387fn is_comment_only(trimmed: &str) -> bool {
388    trimmed.starts_with("//")
389        || trimmed.starts_with("/*")
390        || trimmed.starts_with('*')
391        || (trimmed.starts_with('#') && !trimmed.starts_with("#["))
392}
393
394/// A single-line PHP 8 attribute (`#[Foo]`, `#[Foo(bar: 1)]`) with no other
395/// code on the same line — a trailing same-line comment (`#[Foo] // note`,
396/// `#[Foo] /* note */`) is stripped first so it doesn't re-defeat this
397/// check (it previously required `]` to be the line's last character,
398/// which a trailing comment always violates). Doesn't attempt multi-line
399/// bracket-depth tracking for a `#[` that spans several lines — only the
400/// common single-line case, mirroring the scope this suppression logic
401/// already accepts elsewhere (e.g. no nested-comment tracking either).
402fn is_attribute_only(trimmed: &str) -> bool {
403    if !trimmed.starts_with("#[") {
404        return false;
405    }
406    // Skip the leading `#` (part of the attribute marker, not a comment)
407    // before scanning for a trailing comment introducer — the same
408    // string-literal-aware scan `extract_comment` uses for a suppression
409    // directive's own line, applied here to the attribute's line instead.
410    let rest = &trimmed[1..];
411    let body = match find_comment_introducer(rest) {
412        Some(pos) => rest[..pos].trim_end(),
413        None => rest,
414    };
415    body.ends_with(']')
416}
417
418/// Directive keyword table, ordered longest-first so that, e.g.,
419/// `@mir-ignore-next-line` is matched before the `@mir-ignore` prefix.
420///
421/// Each entry is `(keyword, scope, force_all)`. `force_all` makes the directive
422/// suppress every kind regardless of trailing tokens (PHPStan semantics).
423const KEYWORDS: &[(&str, Scope, bool)] = &[
424    ("@mir-ignore-next-line", Scope::NextLine, false),
425    ("@mir-suppress-next-line", Scope::NextLine, false),
426    ("@phpstan-ignore-next-line", Scope::NextLine, true),
427    ("@mir-ignore-line", Scope::SameLine, false),
428    ("@mir-suppress-line", Scope::SameLine, false),
429    ("@phpstan-ignore-line", Scope::SameLine, true),
430    ("@mir-ignore-file", Scope::File, false),
431    ("@mir-suppress-file", Scope::File, false),
432    // Bare forms (scope resolved below from comment position).
433    ("@mir-ignore", Scope::NextLine, false),
434    ("@mir-suppress", Scope::NextLine, false),
435    ("@psalm-suppress", Scope::NextLine, false),
436    ("@suppress", Scope::NextLine, false),
437    ("@phpstan-ignore", Scope::NextLine, true),
438];
439
440/// Bare directives (no `-line`/`-next-line`/`-file` suffix) resolve their scope
441/// from where the comment sits: a trailing comment annotates its own line, a
442/// standalone comment annotates the statement that follows it.
443const BARE_KEYWORDS: &[&str] = &[
444    "@mir-ignore",
445    "@mir-suppress",
446    "@psalm-suppress",
447    "@suppress",
448    "@phpstan-ignore",
449];
450
451/// Like `parse_directive` (which is parse_directive_with_tracking discarding the tracking flag),
452/// but also returns whether named suppression tracking
453/// should be applied (true for `@psalm-suppress`, `@mir-suppress`, `@suppress`
454/// and `@mir-ignore` forms; false for `@phpstan-*` which are blanket suppressors
455/// not tied to specific issue kinds).
456fn parse_directive_with_tracking(raw: &str) -> Option<(Directive, bool)> {
457    let comment = extract_comment(raw)?;
458
459    // A comment can carry more than one directive-shaped substring (e.g. a
460    // user's own `@mir-ignore-line Foo` followed later by an unrelated
461    // `@phpstan-ignore-next-line`) — pick whichever keyword actually appears
462    // FIRST in the text, not the first entry `KEYWORDS` happens to define,
463    // so an earlier table entry never shadows a later-defined keyword that
464    // the author actually wrote earlier in the comment.
465    let mut best: Option<(usize, &str, Scope, bool)> = None;
466    for &(keyword, scope, force_all) in KEYWORDS {
467        let Some(pos) = find_ci(comment.content, keyword) else {
468            continue;
469        };
470        // Reject keyword matches that are really a prefix of a longer token
471        // (e.g. `@mir-ignore` inside `@mir-ignore-line`).
472        let after = &comment.content[pos + keyword.len()..];
473        if after
474            .chars()
475            .next()
476            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-')
477        {
478            continue;
479        }
480        let is_leftmost = match best {
481            None => true,
482            Some((best_pos, ..)) => pos < best_pos,
483        };
484        if is_leftmost {
485            best = Some((pos, keyword, scope, force_all));
486        }
487    }
488    let (pos, keyword, scope, force_all) = best?;
489    let after = &comment.content[pos + keyword.len()..];
490
491    let is_bare = BARE_KEYWORDS.contains(&keyword);
492
493    // Bare forms: a trailing comment suppresses its own line.
494    let scope = if is_bare && comment.has_code_before {
495        Scope::SameLine
496    } else {
497        scope
498    };
499
500    // The "documents the following element" forms (bare `@psalm-suppress`,
501    // `@mir-ignore`, …) skip past intervening comment lines — e.g. the
502    // closing `*/` of a multi-line docblock — to reach the declaration.
503    // PHPStan's explicit `*-next-line` and bare `@phpstan-ignore` keep their
504    // literal next-non-blank-line semantics.
505    let skip_comments = scope == Scope::NextLine && is_bare && !force_all;
506
507    let kinds = if force_all {
508        KindSet::All
509    } else {
510        parse_kinds(after)
511    };
512
513    // Track named suppressions only for non-phpstan forms (phpstan forms
514    // always suppress all kinds, so they can never be "unused for a specific kind").
515    let track_named = !keyword.starts_with("@phpstan");
516
517    Some((
518        Directive {
519            scope,
520            kinds,
521            skip_comments,
522        },
523        track_named,
524    ))
525}
526
527/// Case-insensitive `str::find` for an ASCII directive keyword. Kind names
528/// are already matched case-insensitively (`KindSet::matches`); the keyword
529/// itself was still exact-case, so `@MIR-IGNORE`/`@Psalm-Suppress` silently
530/// matched nothing. `to_ascii_lowercase` never changes a string's byte
531/// length (only ASCII bytes are touched), so the returned index stays valid
532/// against the original, non-lowercased `haystack`.
533fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
534    haystack
535        .to_ascii_lowercase()
536        .find(&needle.to_ascii_lowercase())
537}
538
539struct Comment<'a> {
540    /// Text from the comment introducer onward (still includes `*/`, `*`, etc.).
541    content: &'a str,
542    /// Whether non-whitespace code precedes the comment on the same line.
543    has_code_before: bool,
544}
545
546/// Isolate the comment portion of a physical line, if any. Handles `//`, `#`
547/// and `/* … */` introducers, block-comment continuation lines (` * …`) and
548/// bare directive lines inside block comments (`@psalm-suppress …`).
549fn extract_comment(raw: &str) -> Option<Comment<'_>> {
550    let trimmed = raw.trim_start();
551
552    // Block-comment continuation or a bare directive line: no code precedes it.
553    if trimmed.starts_with('*') {
554        return Some(Comment {
555            content: trimmed.trim_start_matches('*'),
556            has_code_before: false,
557        });
558    }
559    if trimmed.starts_with('@') {
560        return Some(Comment {
561            content: trimmed,
562            has_code_before: false,
563        });
564    }
565
566    // Earliest single-line / block introducer on the line, skipping any
567    // that fall inside a quoted string literal — `$x = "// not a
568    // comment";` previously matched `raw.find("//")` regardless, wrongly
569    // treating a plain string-literal statement as carrying a trailing
570    // suppression comment. Only a same-line string is handled here (a
571    // bounded, quote-toggle scan); a heredoc/multi-line string body stays
572    // unrecognized, same as before — that needs cross-line state this
573    // line-at-a-time scanner doesn't have.
574    let pos = find_comment_introducer(raw)?;
575    let has_code_before = !raw[..pos].trim().is_empty();
576    Some(Comment {
577        content: &raw[pos..],
578        has_code_before,
579    })
580}
581
582/// Byte offset of the earliest `//`, `#`, or `/*` comment introducer in
583/// `raw`, ignoring any that fall inside a single- or double-quoted string
584/// literal (with backslash-escape awareness). Returns `None` if the line
585/// has no real comment introducer at all.
586fn find_comment_introducer(raw: &str) -> Option<usize> {
587    let bytes = raw.as_bytes();
588    let mut in_squote = false;
589    let mut in_dquote = false;
590    let mut i = 0;
591    while i < bytes.len() {
592        let c = bytes[i];
593        if in_squote {
594            i += if c == b'\\' && i + 1 < bytes.len() {
595                2
596            } else {
597                if c == b'\'' {
598                    in_squote = false;
599                }
600                1
601            };
602            continue;
603        }
604        if in_dquote {
605            i += if c == b'\\' && i + 1 < bytes.len() {
606                2
607            } else {
608                if c == b'"' {
609                    in_dquote = false;
610                }
611                1
612            };
613            continue;
614        }
615        match c {
616            b'\'' => in_squote = true,
617            b'"' => in_dquote = true,
618            b'/' if bytes.get(i + 1) == Some(&b'/') || bytes.get(i + 1) == Some(&b'*') => {
619                return Some(i);
620            }
621            b'#' => return Some(i),
622            _ => {}
623        }
624        i += 1;
625    }
626    None
627}
628
629/// Collect issue kind names/codes following a directive keyword. Kinds are
630/// comma-separated (`Foo, Bar`); only the FIRST whitespace-delimited word of
631/// each comma-separated segment can be a kind name — every further word in
632/// that same segment is treated as free-text prose (e.g. a trailing
633/// explanation like `UndefinedClass because of a vendor stub`), not another
634/// kind, since a real multi-kind list always uses a comma to continue. Stops
635/// entirely at the block-comment terminator. An empty result means "all kinds".
636fn parse_kinds(rest: &str) -> KindSet {
637    let mut set = FxHashSet::default();
638    'segments: for segment in rest.split(',') {
639        for token in segment.split([' ', '\t']) {
640            let token = token.trim();
641            if token.is_empty() {
642                continue;
643            }
644            // End of the comment / docblock — stop scanning entirely.
645            if token.starts_with("*/") || token.starts_with('*') {
646                break 'segments;
647            }
648            // A kind name is alphanumeric (plus `_`); anything else is
649            // skipped, keeping the next token as the segment's candidate.
650            if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
651                set.insert(token.to_string());
652                continue 'segments;
653            }
654        }
655    }
656    if set.is_empty() {
657        KindSet::All
658    } else {
659        KindSet::Named(set)
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    fn map(src: &str) -> SuppressionMap {
668        SuppressionMap::from_source(src)
669    }
670
671    #[test]
672    fn line_comment_above_statement_suppresses_next_line() {
673        // line 2 comment → suppress line 3
674        let m = map("<?php\n// @psalm-suppress UndefinedClass\nnew NoSuchClass();\n");
675        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
676        assert!(!m.is_suppressed(2, "UndefinedClass", "MIR0000"));
677    }
678
679    #[test]
680    fn trailing_comment_suppresses_own_line() {
681        let m = map("<?php\nnew NoSuchClass(); // @mir-ignore UndefinedClass\n");
682        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
683    }
684
685    #[test]
686    fn single_line_docblock_above_statement() {
687        let m = map("<?php\n/** @psalm-suppress UndefinedClass */\nnew NoSuchClass();\n");
688        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
689    }
690
691    #[test]
692    fn phpstan_ignore_next_line_suppresses_all() {
693        let m = map("<?php\n// @phpstan-ignore-next-line\nnew NoSuchClass();\n");
694        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
695        assert!(m.is_suppressed(3, "AnyOtherKind", "MIR9999"));
696    }
697
698    #[test]
699    fn ignore_line_targets_own_line() {
700        let m = map("<?php\nnew NoSuchClass(); // @mir-ignore-line\n");
701        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
702    }
703
704    #[test]
705    fn next_line_skips_blank_lines() {
706        let m = map("<?php\n/** @psalm-suppress UndefinedClass */\n\n\nnew NoSuchClass();\n");
707        assert!(m.is_suppressed(5, "UndefinedClass", "MIR0000"));
708    }
709
710    #[test]
711    fn multiline_docblock_skips_to_declaration() {
712        // line 2: /**, line 3: * @psalm-suppress, line 4: */, line 5: declaration.
713        let src =
714            "<?php\n/**\n * @psalm-suppress UnusedMethod\n */\nprivate function a(): void {}\n";
715        let m = map(src);
716        assert!(m.is_suppressed(5, "UnusedMethod", "MIR0000"));
717    }
718
719    #[test]
720    fn phpstan_next_line_is_literal_not_comment_skipping() {
721        // PHPStan's -next-line targets the next non-blank line even if it's a
722        // comment; it does not hunt for the next code line.
723        let m = map("<?php\n// @phpstan-ignore-next-line\n// unrelated comment\nfoo();\n");
724        assert!(m.is_suppressed(3, "X", "MIR0000"));
725        assert!(!m.is_suppressed(4, "X", "MIR0000"));
726    }
727
728    #[test]
729    fn named_kind_does_not_suppress_other_kinds() {
730        let m = map("<?php\n// @mir-ignore UndefinedClass\nfoo();\n");
731        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
732        assert!(!m.is_suppressed(3, "UndefinedFunction", "MIR0001"));
733    }
734
735    #[test]
736    fn match_by_code() {
737        let m = map("<?php\n// @mir-ignore MIR1400\nfoo();\n");
738        assert!(m.is_suppressed(3, "ParseError", "MIR1400"));
739    }
740
741    #[test]
742    fn file_scope_suppresses_every_line() {
743        let m = map("<?php // @mir-ignore-file UndefinedClass\nfoo();\nbar();\n");
744        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
745        assert!(m.is_suppressed(99, "UndefinedClass", "MIR0000"));
746        assert!(!m.is_suppressed(2, "UndefinedFunction", "MIR0001"));
747    }
748
749    #[test]
750    fn multiple_kinds_one_directive() {
751        let m = map("<?php\n// @psalm-suppress UndefinedClass, NullMethodCall\nfoo();\n");
752        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
753        assert!(m.is_suppressed(3, "NullMethodCall", "MIR0001"));
754    }
755
756    #[test]
757    fn no_directive_is_empty() {
758        let m = map("<?php\n$x = \"@psalm-suppress not a comment\";\nfoo();\n");
759        // It's inside a string but after `//`? No `//` here, so not detected.
760        assert!(m.is_empty());
761    }
762
763    #[test]
764    fn prefix_is_not_confused_with_longer_keyword() {
765        // `@mir-ignore-next-line` must be parsed as next-line, not bare same-line.
766        let m = map("<?php\nfoo(); // @mir-ignore-next-line\nbar();\n");
767        assert!(m.is_suppressed(3, "AnyKind", "MIR0000"));
768        assert!(!m.is_suppressed(2, "AnyKind", "MIR0000"));
769    }
770}