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.iter().any(|k| {
64                k.eq_ignore_ascii_case(name)
65                    || k.eq_ignore_ascii_case(code)
66                    || alias_targets(k)
67                        .iter()
68                        .any(|t| t.eq_ignore_ascii_case(name))
69            }),
70        }
71    }
72
73    fn merge(&mut self, other: KindSet) {
74        match (self, other) {
75            // Already broadest possible.
76            (KindSet::All, _) => {}
77            (slot @ KindSet::Named(_), KindSet::All) => *slot = KindSet::All,
78            (KindSet::Named(a), KindSet::Named(b)) => a.extend(b),
79        }
80    }
81}
82
83/// Third-party (Psalm/PHPStan) issue identifiers that don't match any
84/// `IssueKind::name()` verbatim because mir models the same underlying
85/// diagnostic under a different name. Without this table, a directive like
86/// `@psalm-suppress PossiblyNullReference` neither suppresses the mir issue
87/// it was meant to silence (`KindSet::matches` finds no name match) nor
88/// counts as used (`unused_named`'s `kind_matches` finds no match either) —
89/// so the real issue still fires AND the suppression itself gets flagged
90/// `UnusedSuppress`, doubling the noise instead of silencing it.
91///
92/// Each entry maps one third-party name to every mir `IssueKind::name()` it
93/// should be treated as matching. Looked up case-insensitively by both
94/// `KindSet::matches` and `unused_named`, so it applies uniformly whether the
95/// suppression ends up used for line-suppression or unused-detection.
96const ISSUE_NAME_ALIASES: &[(&str, &[&str])] = &[
97    // Psalm's `PossiblyNullReference` covers member access (property fetch OR
98    // method call) on a possibly-null receiver; mir splits that into two
99    // separate kinds by access form.
100    (
101        "PossiblyNullReference",
102        &["PossiblyNullPropertyFetch", "PossiblyNullMethodCall"],
103    ),
104    // Psalm's check for a property that isn't definitely assigned by every
105    // constructor path; mir's equivalent is named for the resulting state
106    // rather than the constructor-focused cause.
107    (
108        "PropertyNotSetInConstructor",
109        &["PropertyPossiblyUninitialized"],
110    ),
111    // Psalm reports a read of an undeclared key on a closed shape as
112    // `InvalidArrayOffset`; mir names it `NonExistentArrayOffset`.
113    ("InvalidArrayOffset", &["NonExistentArrayOffset"]),
114];
115
116/// Mir `IssueKind` names that `name` (a third-party identifier) should also
117/// be treated as matching, per [`ISSUE_NAME_ALIASES`]. Empty if `name` isn't
118/// a known third-party alias.
119fn alias_targets(name: &str) -> &'static [&'static str] {
120    ISSUE_NAME_ALIASES
121        .iter()
122        .find(|(alias, _)| alias.eq_ignore_ascii_case(name))
123        .map_or(&[], |(_, targets)| *targets)
124}
125
126/// Where a directive applies, relative to the comment's own line.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128enum Scope {
129    /// The comment's own physical line.
130    SameLine,
131    /// The next code line (next non-blank physical line).
132    NextLine,
133    /// Every line in the file.
134    File,
135}
136
137struct Directive {
138    scope: Scope,
139    kinds: KindSet,
140    /// For [`Scope::NextLine`]: whether to skip intervening comment lines (not
141    /// just blanks) when locating the target. Set for "documents the following
142    /// element" forms (`@psalm-suppress`, bare `@mir-ignore`, …) so a directive
143    /// inside a multi-line docblock still lands on the declaration it annotates,
144    /// past the closing `*/`.
145    skip_comments: bool,
146}
147
148/// A named (non-All) suppression, tracked for `UnusedSuppress` detection.
149/// `report_line` is where an `UnusedSuppress` issue is emitted if this
150/// directive never matches anything; `covered_lines` is every line an
151/// issue of `kind` may legitimately land on to count as "used" — normally
152/// just `report_line` itself, but a multi-line signature's continuation
153/// lines (where a per-parameter issue like `UnusedParam` actually fires)
154/// are included too, since the directive covers those the same way
155/// `SuppressionMap::lines` already does.
156#[derive(Debug, Clone)]
157pub struct NamedSuppression {
158    pub report_line: u32,
159    pub covered_lines: Vec<u32>,
160    pub kind: String,
161    /// Where the kind name is written: 1-based line, 0-based char columns.
162    pub tag_line: u32,
163    pub col_start: u16,
164    pub col_end: u16,
165}
166
167/// Per-file map of suppressed lines, built from source comments.
168#[derive(Debug, Default)]
169pub struct SuppressionMap {
170    /// 1-based line number → kinds suppressed on that line.
171    lines: FxHashMap<u32, KindSet>,
172    /// Whole-file suppression, if any directive requested it.
173    file: Option<KindSet>,
174    /// Named (non-All) suppressions, for `UnusedSuppress` detection. Only
175    /// `@psalm-suppress X` / `@suppress X` / `@mir-suppress X` forms populate
176    /// this — blanket `@phpstan-ignore*` suppressions are intentionally excluded.
177    pub named_suppressions: Vec<NamedSuppression>,
178}
179
180impl SuppressionMap {
181    /// No directives — used to skip work for files with no suppression comments.
182    pub fn is_empty(&self) -> bool {
183        self.lines.is_empty() && self.file.is_none()
184    }
185
186    /// Whether an issue of `name`/`code` reported at 1-based `line` is suppressed.
187    pub fn is_suppressed(&self, line: u32, name: &str, code: &str) -> bool {
188        if let Some(file) = &self.file {
189            if file.matches(name, code) {
190                return true;
191            }
192        }
193        self.lines.get(&line).is_some_and(|k| k.matches(name, code))
194    }
195
196    /// Scan `source` for suppression directives.
197    pub fn from_source(source: &str) -> Self {
198        if !contains_ascii_case(source, "ignore") && !contains_ascii_case(source, "suppress") {
199            return SuppressionMap::default();
200        }
201
202        let raw_lines: Vec<&str> = source.lines().collect();
203        let in_heredoc_body = heredoc_body_mask(&raw_lines);
204        let block_comment_open = block_comment_open_mask(&raw_lines);
205        let mut map = SuppressionMap::default();
206
207        for (idx, raw) in raw_lines.iter().enumerate() {
208            // A `#`/`//`-looking line INSIDE a heredoc/nowdoc body (embedded
209            // shell/SQL/etc.) is not a real comment — without this, a line
210            // like `# @mir-ignore-file UndefinedClass` in an embedded script
211            // is indistinguishable from a genuine suppression directive.
212            if in_heredoc_body[idx] {
213                continue;
214            }
215            let Some((directive, track_named)) = parse_directive_with_tracking(raw) else {
216                continue;
217            };
218            match directive.scope {
219                Scope::File => match &mut map.file {
220                    Some(existing) => existing.merge(directive.kinds),
221                    None => map.file = Some(directive.kinds),
222                },
223                Scope::SameLine => {
224                    let line_no = idx as u32 + 1;
225                    if track_named {
226                        if let KindSet::Named(ref names) = directive.kinds {
227                            for name in names {
228                                let (col_start, col_end) = kind_columns(raw, name);
229                                map.named_suppressions.push(NamedSuppression {
230                                    report_line: line_no,
231                                    covered_lines: vec![line_no],
232                                    kind: name.clone(),
233                                    tag_line: line_no,
234                                    col_start,
235                                    col_end,
236                                });
237                            }
238                        }
239                    }
240                    insert_line(&mut map.lines, line_no, directive.kinds);
241                }
242                Scope::NextLine => {
243                    let (target, extra_covered_lines) = next_code_line(
244                        &raw_lines,
245                        idx,
246                        directive.skip_comments,
247                        block_comment_open[idx],
248                    );
249                    if track_named {
250                        if let KindSet::Named(ref names) = directive.kinds {
251                            // A continuation line of `target`'s own multi-line
252                            // signature (a later parameter, e.g.) is where a
253                            // per-parameter issue like `UnusedParam` actually
254                            // fires — track it alongside `target` as a single
255                            // suppression that's "used" if EITHER line has a
256                            // matching issue, so a genuinely-used directive
257                            // whose issue lands on a continuation line isn't
258                            // misreported as unused.
259                            let mut covered_lines = extra_covered_lines.clone();
260                            covered_lines.push(target);
261                            for name in names {
262                                let (col_start, col_end) = kind_columns(raw, name);
263                                map.named_suppressions.push(NamedSuppression {
264                                    report_line: target,
265                                    covered_lines: covered_lines.clone(),
266                                    kind: name.clone(),
267                                    tag_line: idx as u32 + 1,
268                                    col_start,
269                                    col_end,
270                                });
271                            }
272                        }
273                    }
274                    // An attribute line skipped en route to `target`, or a
275                    // continuation line of `target`'s own multi-line
276                    // signature, stays covered by the same directive — an
277                    // issue about the attribute itself (e.g.
278                    // `UndefinedAttributeClass`) or about a later parameter
279                    // (e.g. `UnusedParam`) fires there, not at `target`.
280                    for extra_line in extra_covered_lines {
281                        insert_line(&mut map.lines, extra_line, directive.kinds.clone());
282                    }
283                    insert_line(&mut map.lines, target, directive.kinds);
284                }
285            }
286        }
287
288        map
289    }
290
291    /// Returns unused named suppressions: those that did not match any issue
292    /// in `all_issues`.
293    ///
294    /// `pre_suppressed` is the subset of `all_issues` that arrived already
295    /// suppressed (via the `IssueBuffer` mechanism in collector/body analysis).
296    /// These may be emitted at a different line than the suppression target
297    /// (e.g. `InvalidDocblock` at a docblock-start line vs. the following
298    /// declaration line), so they are matched within a 30-line window before
299    /// the target.
300    pub fn unused_named(
301        &self,
302        all_issues: &[&mir_issues::Issue],
303        pre_suppressed: &[&mir_issues::Issue],
304    ) -> Vec<&NamedSuppression> {
305        self.named_suppressions
306            .iter()
307            .filter(|ns| {
308                let target_line = ns.report_line;
309                let kind = &ns.kind;
310                // Compare case-insensitively, same as `KindSet::matches` — a
311                // directive can name a kind in any casing, and may also name
312                // a third-party (Psalm/PHPStan) identifier for a check mir
313                // models under a different name (`alias_targets`).
314                let kind_matches = |issue: &&mir_issues::Issue| {
315                    let display_name = issue.kind.display_name();
316                    display_name.eq_ignore_ascii_case(kind.as_str())
317                        || issue.kind.code().eq_ignore_ascii_case(kind.as_str())
318                        || alias_targets(kind.as_str())
319                            .iter()
320                            .any(|t| t.eq_ignore_ascii_case(display_name))
321                };
322                // Normal case: a SuppressionMap-suppressed issue at the report
323                // line itself, OR any other line this same directive covers
324                // (a multi-line signature's continuation lines, where a
325                // per-parameter issue like `UnusedParam` actually fires).
326                let at_covered_line = all_issues.iter().any(|issue| {
327                    ns.covered_lines.contains(&issue.location.line) && kind_matches(issue)
328                });
329                if at_covered_line {
330                    return false; // suppression IS used
331                }
332                // Docblock case: collector-emitted issues (like `InvalidDocblock`)
333                // land at the docblock-start line, which precedes the declaration
334                // that the suppression targets. Allow a 100-line look-back so a
335                // `@psalm-suppress InvalidDocblock` in an unusually long
336                // multi-line docblock (heavy @template/@method/@property lists)
337                // is still recognised as used even though its issue line !=
338                // target_line — 30 lines was too easy to exceed for such a
339                // docblock, wrongly reporting a real, used suppression as unused.
340                let min_line = target_line.saturating_sub(100);
341                let covered_by_pre_suppressed = pre_suppressed.iter().any(|issue| {
342                    issue.location.line >= min_line
343                        && issue.location.line < target_line
344                        && kind_matches(issue)
345                });
346                !covered_by_pre_suppressed
347            })
348            .collect()
349    }
350}
351
352fn contains_ascii_case(haystack: &str, needle: &str) -> bool {
353    haystack
354        .as_bytes()
355        .windows(needle.len())
356        .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
357}
358
359fn insert_line(lines: &mut FxHashMap<u32, KindSet>, line: u32, kinds: KindSet) {
360    match lines.get_mut(&line) {
361        Some(existing) => existing.merge(kinds),
362        None => {
363            lines.insert(line, kinds);
364        }
365    }
366}
367
368/// Per-line mask: `true` for a physical line that falls INSIDE a heredoc or
369/// nowdoc body (between the `<<<IDENT` opener line and its closing `IDENT`
370/// line), `false` everywhere else — including the opener line itself, which
371/// still carries real PHP code (`$x = <<<EOT`) before the body starts.
372///
373/// Deliberately simple, matching this file's existing line-based scanning:
374/// doesn't defend against a `<<<` that itself appears inside an unrelated
375/// string literal on the same line (a separate, rarer gap) — only real
376/// heredoc/nowdoc openers are expected to actually appear this way in
377/// practice.
378fn heredoc_body_mask(raw_lines: &[&str]) -> Vec<bool> {
379    let mut mask = vec![false; raw_lines.len()];
380    let mut closing_ident: Option<&str> = None;
381    for (idx, line) in raw_lines.iter().enumerate() {
382        if let Some(ident) = closing_ident {
383            mask[idx] = true;
384            if is_heredoc_closing_line(line, ident) {
385                closing_ident = None;
386            }
387            continue;
388        }
389        closing_ident = find_heredoc_opener(line);
390    }
391    mask
392}
393
394/// If `line` opens a heredoc (`<<<IDENT`) or nowdoc (`<<<'IDENT'`/`<<<"IDENT"`),
395/// return the closing identifier to watch for.
396fn find_heredoc_opener(line: &str) -> Option<&str> {
397    let pos = line.find("<<<")?;
398    let rest = line[pos + 3..].trim_start();
399    let (quote, rest) = match rest.as_bytes().first() {
400        Some(b'\'') => (Some(b'\''), &rest[1..]),
401        Some(b'"') => (Some(b'"'), &rest[1..]),
402        _ => (None, rest),
403    };
404    let end = rest
405        .find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
406        .unwrap_or(rest.len());
407    let ident = &rest[..end];
408    if ident.is_empty() {
409        return None;
410    }
411    if let Some(q) = quote {
412        if rest.as_bytes().get(end) != Some(&q) {
413            return None;
414        }
415    }
416    Some(ident)
417}
418
419/// Whether `line` is the closing line of a heredoc/nowdoc body started with
420/// `ident` — optional leading whitespace (PHP 7.3+ flexible heredoc
421/// indentation), then the identifier as a whole word (not a prefix of a
422/// longer identifier).
423fn is_heredoc_closing_line(line: &str, ident: &str) -> bool {
424    let Some(rest) = line.trim_start().strip_prefix(ident) else {
425        return false;
426    };
427    rest.chars()
428        .next()
429        .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_')
430}
431
432/// Per-line mask: `true` when a `/* … */` block comment (most commonly a
433/// docblock) is still open at the END of that physical line — its closing
434/// `*/` hasn't been seen yet. Lets a `NextLine` directive found on one of a
435/// docblock's own continuation lines tell whether the lines immediately
436/// following it are still the SAME comment (further tag lines, then its own
437/// closing `*/`) rather than real code or a separate, later comment.
438///
439/// Deliberately simple, matching this file's other line-based scanners: no
440/// quote-awareness, so a `/*`/`*/`-looking substring inside a string literal
441/// is out of scope.
442fn block_comment_open_mask(raw_lines: &[&str]) -> Vec<bool> {
443    let mut mask = vec![false; raw_lines.len()];
444    let mut open = false;
445    for (idx, line) in raw_lines.iter().enumerate() {
446        let bytes = line.as_bytes();
447        let mut i = 0;
448        while i < bytes.len() {
449            if open {
450                if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') {
451                    open = false;
452                    i += 2;
453                } else {
454                    i += 1;
455                }
456            } else if bytes[i] == b'/' && bytes.get(i + 1) == Some(&b'*') {
457                open = true;
458                i += 2;
459            } else {
460                i += 1;
461            }
462        }
463        mask[idx] = open;
464    }
465    mask
466}
467
468/// Locate a directive's target line strictly after `idx`, as a 1-based
469/// number, plus every single-line PHP 8 attribute (`#[Foo]`) line skipped
470/// along the way.
471///
472/// Always skips blank lines and an attribute line sitting between the
473/// directive and the declaration it modifies — real code, but never what a
474/// suppression directive means to target (the declaration that follows).
475/// The skipped attribute lines are returned too, not just discarded: an
476/// issue about the attribute ITSELF (e.g. `UndefinedAttributeClass`, "this
477/// attribute class does not exist") fires on the attribute's own line, not
478/// the final target, and must stay covered by the same directive as the
479/// declaration it annotates. When `skip_comments` is set, also skips
480/// comment-only lines (`//`, `#`, `/* … */`, ` * …` docblock bodies and the
481/// closing `*/`) so a directive written inside a multi-line docblock lands
482/// on the declaration that follows it. Falls back to `idx + 2` when nothing
483/// qualifies, so the directive still has a deterministic target.
484///
485/// `in_open_block_comment` is whether the directive's OWN line sits inside a
486/// still-unterminated `/* … */` block comment — a multi-line docblock whose
487/// closing `*/` hasn't been seen yet. Those remaining lines (further tag
488/// lines, then the closing `*/`) can never be the target regardless of
489/// `skip_comments`, since they're still the directive's own comment, not a
490/// separate one — `skip_comments` only decides whether to also hunt past a
491/// DIFFERENT, later comment sitting between that closing `*/` and the
492/// declaration.
493fn next_code_line(
494    raw_lines: &[&str],
495    idx: usize,
496    skip_comments: bool,
497    in_open_block_comment: bool,
498) -> (u32, Vec<u32>) {
499    let mut extra_covered_lines = Vec::new();
500    // Net `[`/`]` depth of an in-progress multi-line attribute (`#[` opener
501    // whose closing `]` is on a later line) — 0 when not currently inside
502    // one. Every line consumed while this is positive is itself an
503    // attribute line, same treatment the single-line case already gets.
504    let mut multiline_attr_depth: i32 = 0;
505    let mut in_open_block_comment = in_open_block_comment;
506    for (offset, line) in raw_lines.iter().enumerate().skip(idx + 1) {
507        let trimmed = line.trim();
508        if in_open_block_comment {
509            if trimmed.contains("*/") {
510                in_open_block_comment = false;
511            }
512            continue;
513        }
514        if multiline_attr_depth > 0 {
515            extra_covered_lines.push(offset as u32 + 1);
516            multiline_attr_depth += bracket_delta(trimmed);
517            continue;
518        }
519        if trimmed.is_empty() {
520            continue;
521        }
522        if is_attribute_only(trimmed) {
523            extra_covered_lines.push(offset as u32 + 1);
524            continue;
525        }
526        // A `#[` opener with no matching `]` on the SAME line (net positive
527        // bracket delta) starts a multi-line attribute — track depth across
528        // subsequent lines instead of wrongly treating this opener line as
529        // the real target.
530        if trimmed.starts_with("#[") {
531            let delta = bracket_delta(trimmed);
532            if delta > 0 {
533                extra_covered_lines.push(offset as u32 + 1);
534                multiline_attr_depth = delta;
535                continue;
536            }
537        }
538        if skip_comments && is_comment_only(trimmed) {
539            continue;
540        }
541        // A declaration whose own signature spans multiple physical lines
542        // (one parameter per line is a common style) still has exactly one
543        // target line for suppression purposes, yet per-line issues land
544        // later in the SAME signature (e.g. `UnusedParam` on a parameter
545        // several lines down). Track paren depth from the target line and
546        // cover every continuation line until the signature's parens close.
547        let target = offset as u32 + 1;
548        let mut paren_depth = paren_delta(trimmed);
549        let mut cont_idx = offset + 1;
550        while paren_depth > 0 {
551            let Some(cont_line) = raw_lines.get(cont_idx) else {
552                break;
553            };
554            extra_covered_lines.push(cont_idx as u32 + 1);
555            paren_depth += paren_delta(cont_line.trim());
556            cont_idx += 1;
557        }
558        // A statement continued by an operator-leading line (`? a`, `: b`,
559        // `->c()`) or an open bracket is one statement; later issues on those
560        // lines belong to the same directive.
561        if skip_comments && !is_function_like(trimmed) && paren_depth <= 0 {
562            let mut depth = bracket_delta(trimmed);
563            let mut last = cont_idx - 1;
564            while depth > 0 || !ends_statement(raw_lines[last].trim()) {
565                let Some(next) = (last + 1..raw_lines.len())
566                    .find(|&i| !raw_lines[i].trim().is_empty())
567                    .filter(|&i| depth > 0 || continues_statement(raw_lines[i].trim()))
568                else {
569                    break;
570                };
571                let line = raw_lines[next].trim();
572                depth += paren_delta(line) + bracket_delta(line);
573                extra_covered_lines.extend(last as u32 + 2..=next as u32 + 1);
574                last = next;
575            }
576        }
577        // A "documents the following element" directive above a function or
578        // method covers its whole body in real Psalm/PHPStan semantics, not
579        // just the signature line — extend coverage to every line spanned by
580        // the function's own `{ ... }`, if it has one (abstract/interface
581        // methods don't).
582        if skip_comments && is_function_like(trimmed) {
583            if let Some(body_lines) = function_body_lines(raw_lines, offset) {
584                extra_covered_lines.extend(body_lines);
585            }
586        }
587        return (target, extra_covered_lines);
588    }
589    (idx as u32 + 2, extra_covered_lines)
590}
591
592/// Whether a trimmed line ends a statement or opens/closes a block.
593fn ends_statement(trimmed: &str) -> bool {
594    trimmed.ends_with([';', '{', '}'])
595}
596
597/// Whether a trimmed line starts with an operator that continues the
598/// previous line's expression.
599fn continues_statement(trimmed: &str) -> bool {
600    ["?", ":", "->", "&&", "||", ".", "+", "- ", "* "]
601        .iter()
602        .any(|op| trimmed.starts_with(op))
603        && !trimmed.starts_with("?>")
604}
605
606/// Whether a trimmed line is itself a function/method DECLARATION (after
607/// skipping any leading visibility/`static`/`abstract`/`final` modifiers, the
608/// next word is `function`). Used to decide whether a "documents the
609/// following element" suppression directive's target has a body worth
610/// extending coverage into.
611///
612/// Deliberately does not match on `function` appearing anywhere in the line —
613/// a directive whose target statement merely CONTAINS a closure (`return
614/// function () {…};`, `$cb = function () {…};`, an `array_map(function ()
615/// {…}, …)` call) annotates that statement, not the closure's own nested
616/// scope, so it must not be treated as covering the closure's body.
617fn is_function_like(trimmed: &str) -> bool {
618    const MODIFIERS: &[&str] = &[
619        "public",
620        "private",
621        "protected",
622        "static",
623        "abstract",
624        "final",
625    ];
626    for word in trimmed.split_whitespace() {
627        let lower = word.to_ascii_lowercase();
628        if MODIFIERS.contains(&lower.as_str()) {
629            continue;
630        }
631        return lower == "function" || lower.starts_with("function(");
632    }
633    false
634}
635
636/// Physical line numbers (1-based, excluding `start_idx`'s own line) spanned
637/// by a function/method body starting at 0-based `start_idx`, found by
638/// scanning forward for a balanced `{`...`}` pair. Returns `None` if a bare
639/// `;` terminates the declaration before any `{` appears — an abstract or
640/// interface method, which has no body to extend suppression coverage into.
641/// No quote-awareness, same conservative scope as `paren_delta`/`bracket_delta`:
642/// a stray unmatched `{`/`}` inside a plain string literal (as opposed to
643/// `"{$expr}"` interpolation, which is always balanced) could throw off depth
644/// tracking — an accepted rare edge case.
645fn function_body_lines(raw_lines: &[&str], start_idx: usize) -> Option<Vec<u32>> {
646    let mut depth: i32 = 0;
647    let mut opened = false;
648    let mut covered = Vec::new();
649    for (offset, line) in raw_lines.iter().enumerate().skip(start_idx) {
650        for c in line.chars() {
651            match c {
652                '{' => {
653                    depth += 1;
654                    opened = true;
655                }
656                '}' => depth -= 1,
657                ';' if !opened && depth == 0 => return None,
658                _ => {}
659            }
660        }
661        if offset != start_idx {
662            covered.push(offset as u32 + 1);
663        }
664        if opened && depth <= 0 {
665            return Some(covered);
666        }
667    }
668    None
669}
670
671/// Net count of `(` minus `)` in `s` — no quote-awareness, matching
672/// `bracket_delta`'s same conservative scope.
673fn paren_delta(s: &str) -> i32 {
674    s.chars().fold(0i32, |depth, c| match c {
675        '(' => depth + 1,
676        ')' => depth - 1,
677        _ => depth,
678    })
679}
680
681/// Net count of `[` minus `]` in `s` — no quote-awareness, matching the
682/// same conservative scope `is_attribute_only`'s plain `ends_with(']')`
683/// already accepts (real attribute argument text containing a bracket
684/// inside a string literal is out of scope here).
685fn bracket_delta(s: &str) -> i32 {
686    s.chars().fold(0i32, |depth, c| match c {
687        '[' => depth + 1,
688        ']' => depth - 1,
689        _ => depth,
690    })
691}
692
693/// Whether a trimmed line is purely a comment (no PHP code). `#[` is treated as
694/// a PHP 8 attribute (code), not a `#` comment.
695fn is_comment_only(trimmed: &str) -> bool {
696    trimmed.starts_with("//")
697        || trimmed.starts_with("/*")
698        || trimmed.starts_with('*')
699        || (trimmed.starts_with('#') && !trimmed.starts_with("#["))
700}
701
702/// A single-line PHP 8 attribute (`#[Foo]`, `#[Foo(bar: 1)]`) with no other
703/// code on the same line — a trailing same-line comment (`#[Foo] // note`,
704/// `#[Foo] /* note */`) is stripped first, so `]` needn't end the line.
705/// Doesn't attempt multi-line
706/// bracket-depth tracking for a `#[` that spans several lines — only the
707/// common single-line case, mirroring the scope this suppression logic
708/// already accepts elsewhere (e.g. no nested-comment tracking either).
709fn is_attribute_only(trimmed: &str) -> bool {
710    if !trimmed.starts_with("#[") {
711        return false;
712    }
713    // Skip the leading `#` (part of the attribute marker, not a comment)
714    // before scanning for a trailing comment introducer — the same
715    // string-literal-aware scan `extract_comment` uses for a suppression
716    // directive's own line, applied here to the attribute's line instead.
717    let rest = &trimmed[1..];
718    let body = match find_comment_introducer(rest) {
719        Some(pos) => rest[..pos].trim_end(),
720        None => rest,
721    };
722    body.ends_with(']')
723}
724
725/// Directive keyword table, ordered longest-first so that, e.g.,
726/// `@mir-ignore-next-line` is matched before the `@mir-ignore` prefix.
727///
728/// Each entry is `(keyword, scope, force_all)`. `force_all` makes the directive
729/// suppress every kind regardless of trailing tokens (PHPStan semantics).
730const KEYWORDS: &[(&str, Scope, bool)] = &[
731    ("@mir-ignore-next-line", Scope::NextLine, false),
732    ("@mir-suppress-next-line", Scope::NextLine, false),
733    ("@phpstan-ignore-next-line", Scope::NextLine, true),
734    ("@mir-ignore-line", Scope::SameLine, false),
735    ("@mir-suppress-line", Scope::SameLine, false),
736    ("@phpstan-ignore-line", Scope::SameLine, true),
737    ("@mir-ignore-file", Scope::File, false),
738    ("@mir-suppress-file", Scope::File, false),
739    // Bare forms (scope resolved below from comment position).
740    ("@mir-ignore", Scope::NextLine, false),
741    ("@mir-suppress", Scope::NextLine, false),
742    ("@psalm-suppress", Scope::NextLine, false),
743    ("@suppress", Scope::NextLine, false),
744    ("@phpstan-ignore", Scope::NextLine, true),
745];
746
747/// Bare directives (no `-line`/`-next-line`/`-file` suffix) resolve their scope
748/// from where the comment sits: a trailing comment annotates its own line, a
749/// standalone comment annotates the statement that follows it.
750const BARE_KEYWORDS: &[&str] = &[
751    "@mir-ignore",
752    "@mir-suppress",
753    "@psalm-suppress",
754    "@suppress",
755    "@phpstan-ignore",
756];
757
758/// Like `parse_directive` (which is parse_directive_with_tracking discarding the tracking flag),
759/// but also returns whether named suppression tracking
760/// should be applied (true for `@psalm-suppress`, `@mir-suppress`, `@suppress`
761/// and `@mir-ignore` forms; false for `@phpstan-*` which are blanket suppressors
762/// not tied to specific issue kinds).
763fn parse_directive_with_tracking(raw: &str) -> Option<(Directive, bool)> {
764    let comment = extract_comment(raw)?;
765
766    // A comment can carry more than one directive-shaped substring (e.g. a
767    // user's own `@mir-ignore-line Foo` followed later by an unrelated
768    // `@phpstan-ignore-next-line`) — pick whichever keyword actually appears
769    // FIRST in the text, not the first entry `KEYWORDS` happens to define,
770    // so an earlier table entry never shadows a later-defined keyword that
771    // the author actually wrote earlier in the comment.
772    let mut best: Option<(usize, &str, Scope, bool)> = None;
773    for &(keyword, scope, force_all) in KEYWORDS {
774        let Some(pos) = find_ci(comment.content, keyword) else {
775            continue;
776        };
777        // Reject keyword matches that are really a prefix of a longer token
778        // (e.g. `@mir-ignore` inside `@mir-ignore-line`).
779        let after = &comment.content[pos + keyword.len()..];
780        if after
781            .chars()
782            .next()
783            .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-')
784        {
785            continue;
786        }
787        let is_leftmost = match best {
788            None => true,
789            Some((best_pos, ..)) => pos < best_pos,
790        };
791        if is_leftmost {
792            best = Some((pos, keyword, scope, force_all));
793        }
794    }
795    let (pos, keyword, scope, force_all) = best?;
796    let after = &comment.content[pos + keyword.len()..];
797
798    let is_bare = BARE_KEYWORDS.contains(&keyword);
799
800    // Bare forms: a trailing comment suppresses its own line.
801    let scope = if is_bare && comment.has_code_before {
802        Scope::SameLine
803    } else {
804        scope
805    };
806
807    // The "documents the following element" forms (bare `@psalm-suppress`,
808    // `@mir-ignore`, …) also skip past a SEPARATE comment sitting between the
809    // directive's own comment and the declaration. PHPStan's explicit
810    // `*-next-line` and bare `@phpstan-ignore` keep literal
811    // next-non-blank-line semantics there — but the directive's own
812    // enclosing block comment (a multi-line docblock's remaining tag lines
813    // and closing `*/`) is always exited first regardless, via
814    // `next_code_line`'s `in_open_block_comment` handling, since a line still
815    // inside that comment can never be the target either way.
816    let skip_comments = scope == Scope::NextLine && is_bare && !force_all;
817
818    let kinds = if force_all {
819        KindSet::All
820    } else {
821        parse_kinds(after)
822    };
823
824    // Track named suppressions only for non-phpstan forms (phpstan forms
825    // always suppress all kinds, so they can never be "unused for a specific kind").
826    let track_named = !keyword.starts_with("@phpstan");
827
828    Some((
829        Directive {
830            scope,
831            kinds,
832            skip_comments,
833        },
834        track_named,
835    ))
836}
837
838/// Char columns of `kind` in the directive comment `raw`; the whole trimmed
839/// line when the name can't be located.
840fn kind_columns(raw: &str, kind: &str) -> (u16, u16) {
841    let from = find_ci(raw, "suppress")
842        .or_else(|| find_ci(raw, "ignore"))
843        .unwrap_or(0);
844    let is_word = |c: char| c.is_alphanumeric() || c == '_' || c == '\\';
845    let found = raw[from..].match_indices(kind).find(|&(i, _)| {
846        let before = raw[..from + i].chars().next_back();
847        let after = raw[from + i + kind.len()..].chars().next();
848        !before.is_some_and(is_word) && !after.is_some_and(is_word)
849    });
850    let (start, end) = match found {
851        Some((i, _)) => (from + i, from + i + kind.len()),
852        None => (raw.len() - raw.trim_start().len(), raw.trim_end().len()),
853    };
854    let cols = |byte: usize| raw[..byte].chars().count().min(u16::MAX as usize) as u16;
855    (cols(start), cols(end))
856}
857
858/// Case-insensitive `str::find` for an ASCII directive keyword. Kind names
859/// are already matched case-insensitively (`KindSet::matches`); the keyword
860/// itself was still exact-case, so `@MIR-IGNORE`/`@Psalm-Suppress` silently
861/// matched nothing. `to_ascii_lowercase` never changes a string's byte
862/// length (only ASCII bytes are touched), so the returned index stays valid
863/// against the original, non-lowercased `haystack`.
864fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
865    haystack
866        .to_ascii_lowercase()
867        .find(&needle.to_ascii_lowercase())
868}
869
870struct Comment<'a> {
871    /// Text from the comment introducer onward (still includes `*/`, `*`, etc.).
872    content: &'a str,
873    /// Whether non-whitespace code precedes the comment on the same line.
874    has_code_before: bool,
875}
876
877/// Isolate the comment portion of a physical line, if any. Handles `//`, `#`
878/// and `/* … */` introducers, block-comment continuation lines (` * …`) and
879/// bare directive lines inside block comments (`@psalm-suppress …`).
880fn extract_comment(raw: &str) -> Option<Comment<'_>> {
881    let trimmed = raw.trim_start();
882
883    // Block-comment continuation or a bare directive line: no code precedes it.
884    if trimmed.starts_with('*') {
885        return Some(Comment {
886            content: trimmed.trim_start_matches('*'),
887            has_code_before: false,
888        });
889    }
890    if trimmed.starts_with('@') {
891        return Some(Comment {
892            content: trimmed,
893            has_code_before: false,
894        });
895    }
896
897    // Earliest single-line / block introducer on the line, skipping any
898    // that fall inside a quoted string literal (`$x = "// not a
899    // comment";` carries no comment). Only a same-line string is handled
900    // (a bounded, quote-toggle scan); a heredoc/multi-line string body
901    // needs cross-line state this line-at-a-time scanner doesn't have.
902    let pos = find_comment_introducer(raw)?;
903    let has_code_before = !raw[..pos].trim().is_empty();
904    Some(Comment {
905        content: &raw[pos..],
906        has_code_before,
907    })
908}
909
910/// Byte offset of the earliest `//`, `#`, or `/*` comment introducer in
911/// `raw`, ignoring any that fall inside a single- or double-quoted string
912/// literal (with backslash-escape awareness). Returns `None` if the line
913/// has no real comment introducer at all.
914fn find_comment_introducer(raw: &str) -> Option<usize> {
915    let bytes = raw.as_bytes();
916    let mut in_squote = false;
917    let mut in_dquote = false;
918    let mut i = 0;
919    while i < bytes.len() {
920        let c = bytes[i];
921        if in_squote {
922            i += if c == b'\\' && i + 1 < bytes.len() {
923                2
924            } else {
925                if c == b'\'' {
926                    in_squote = false;
927                }
928                1
929            };
930            continue;
931        }
932        if in_dquote {
933            i += if c == b'\\' && i + 1 < bytes.len() {
934                2
935            } else {
936                if c == b'"' {
937                    in_dquote = false;
938                }
939                1
940            };
941            continue;
942        }
943        match c {
944            b'\'' => in_squote = true,
945            b'"' => in_dquote = true,
946            b'/' if bytes.get(i + 1) == Some(&b'/') || bytes.get(i + 1) == Some(&b'*') => {
947                return Some(i);
948            }
949            // `#[` is a PHP 8 attribute opener, not a `#` line comment (see
950            // `is_comment_only`'s identical carve-out) — keep scanning past
951            // it instead of stopping here, so a REAL trailing comment after
952            // the attribute (`#[Attr] // note`) is still found.
953            b'#' if bytes.get(i + 1) == Some(&b'[') => {}
954            b'#' => return Some(i),
955            _ => {}
956        }
957        i += 1;
958    }
959    None
960}
961
962/// Collect issue kind names/codes following a directive keyword. Kinds are
963/// comma-separated (`Foo, Bar`); only the FIRST whitespace-delimited word of
964/// each comma-separated segment can be a kind name — every further word in
965/// that same segment is treated as free-text prose (e.g. a trailing
966/// explanation like `UndefinedClass because of a vendor stub`), not another
967/// kind, since a real multi-kind list always uses a comma to continue. Once a
968/// segment has trailing words after its kind name, everything from there on
969/// — including later comma-separated segments — is that same free-text
970/// prose, so a comma inside the prose (`Foo because of X, not fully typed`)
971/// must not be mistaken for the start of another kind. Stops entirely at the
972/// block-comment terminator. An empty result means "all kinds".
973fn parse_kinds(rest: &str) -> KindSet {
974    let mut set = FxHashSet::default();
975    'segments: for segment in rest.split(',') {
976        let mut tokens = segment
977            .split([' ', '\t'])
978            .map(str::trim)
979            .filter(|t| !t.is_empty());
980        for token in tokens.by_ref() {
981            // End of the comment / docblock — stop scanning entirely.
982            if token.starts_with("*/") || token.starts_with('*') {
983                break 'segments;
984            }
985            // A kind name is alphanumeric (plus `_`); anything else is
986            // skipped, keeping the next token as the segment's candidate.
987            if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
988                set.insert(token.to_string());
989                if tokens.next().is_some() {
990                    // Trailing prose follows in this segment — the rest of
991                    // the comment, comma or not, is that same free text.
992                    break 'segments;
993                }
994                continue 'segments;
995            }
996        }
997    }
998    if set.is_empty() {
999        KindSet::All
1000    } else {
1001        KindSet::Named(set)
1002    }
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    fn map(src: &str) -> SuppressionMap {
1010        SuppressionMap::from_source(src)
1011    }
1012
1013    #[test]
1014    fn line_comment_above_statement_suppresses_next_line() {
1015        // line 2 comment → suppress line 3
1016        let m = map("<?php\n// @psalm-suppress UndefinedClass\nnew NoSuchClass();\n");
1017        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
1018        assert!(!m.is_suppressed(2, "UndefinedClass", "MIR0000"));
1019    }
1020
1021    #[test]
1022    fn trailing_comment_suppresses_own_line() {
1023        let m = map("<?php\nnew NoSuchClass(); // @mir-ignore UndefinedClass\n");
1024        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
1025    }
1026
1027    #[test]
1028    fn single_line_docblock_above_statement() {
1029        let m = map("<?php\n/** @psalm-suppress UndefinedClass */\nnew NoSuchClass();\n");
1030        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
1031    }
1032
1033    #[test]
1034    fn phpstan_ignore_next_line_suppresses_all() {
1035        let m = map("<?php\n// @phpstan-ignore-next-line\nnew NoSuchClass();\n");
1036        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
1037        assert!(m.is_suppressed(3, "AnyOtherKind", "MIR9999"));
1038    }
1039
1040    #[test]
1041    fn ignore_line_targets_own_line() {
1042        let m = map("<?php\nnew NoSuchClass(); // @mir-ignore-line\n");
1043        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
1044    }
1045
1046    #[test]
1047    fn next_line_skips_blank_lines() {
1048        let m = map("<?php\n/** @psalm-suppress UndefinedClass */\n\n\nnew NoSuchClass();\n");
1049        assert!(m.is_suppressed(5, "UndefinedClass", "MIR0000"));
1050    }
1051
1052    #[test]
1053    fn multiline_docblock_skips_to_declaration() {
1054        // line 2: /**, line 3: * @psalm-suppress, line 4: */, line 5: declaration.
1055        let src =
1056            "<?php\n/**\n * @psalm-suppress UnusedMethod\n */\nprivate function a(): void {}\n";
1057        let m = map(src);
1058        assert!(m.is_suppressed(5, "UnusedMethod", "MIR0000"));
1059    }
1060
1061    #[test]
1062    fn phpstan_next_line_is_literal_not_comment_skipping() {
1063        // PHPStan's -next-line targets the next non-blank line even if it's a
1064        // comment; it does not hunt for the next code line.
1065        let m = map("<?php\n// @phpstan-ignore-next-line\n// unrelated comment\nfoo();\n");
1066        assert!(m.is_suppressed(3, "X", "MIR0000"));
1067        assert!(!m.is_suppressed(4, "X", "MIR0000"));
1068    }
1069
1070    #[test]
1071    fn named_kind_does_not_suppress_other_kinds() {
1072        let m = map("<?php\n// @mir-ignore UndefinedClass\nfoo();\n");
1073        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
1074        assert!(!m.is_suppressed(3, "UndefinedFunction", "MIR0001"));
1075    }
1076
1077    #[test]
1078    fn match_by_code() {
1079        let m = map("<?php\n// @mir-ignore MIR1400\nfoo();\n");
1080        assert!(m.is_suppressed(3, "ParseError", "MIR1400"));
1081    }
1082
1083    #[test]
1084    fn file_scope_suppresses_every_line() {
1085        let m = map("<?php // @mir-ignore-file UndefinedClass\nfoo();\nbar();\n");
1086        assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
1087        assert!(m.is_suppressed(99, "UndefinedClass", "MIR0000"));
1088        assert!(!m.is_suppressed(2, "UndefinedFunction", "MIR0001"));
1089    }
1090
1091    #[test]
1092    fn multiple_kinds_one_directive() {
1093        let m = map("<?php\n// @psalm-suppress UndefinedClass, NullMethodCall\nfoo();\n");
1094        assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
1095        assert!(m.is_suppressed(3, "NullMethodCall", "MIR0001"));
1096    }
1097
1098    #[test]
1099    fn no_directive_is_empty() {
1100        let m = map("<?php\n$x = \"@psalm-suppress not a comment\";\nfoo();\n");
1101        // It's inside a string but after `//`? No `//` here, so not detected.
1102        assert!(m.is_empty());
1103    }
1104
1105    #[test]
1106    fn prefix_is_not_confused_with_longer_keyword() {
1107        // `@mir-ignore-next-line` must be parsed as next-line, not bare same-line.
1108        let m = map("<?php\nfoo(); // @mir-ignore-next-line\nbar();\n");
1109        assert!(m.is_suppressed(3, "AnyKind", "MIR0000"));
1110        assert!(!m.is_suppressed(2, "AnyKind", "MIR0000"));
1111    }
1112}