Skip to main content

mir_analyzer/
suppression.rs

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