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