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, yet per-line issues land
530 // later in the SAME signature (e.g. `UnusedParam` on a parameter
531 // several lines down). Track paren depth from the target line and
532 // cover every continuation line until the signature's parens close.
533 let target = offset as u32 + 1;
534 let mut paren_depth = paren_delta(trimmed);
535 let mut cont_idx = offset + 1;
536 while paren_depth > 0 {
537 let Some(cont_line) = raw_lines.get(cont_idx) else {
538 break;
539 };
540 extra_covered_lines.push(cont_idx as u32 + 1);
541 paren_depth += paren_delta(cont_line.trim());
542 cont_idx += 1;
543 }
544 // A "documents the following element" directive above a function or
545 // method covers its whole body in real Psalm/PHPStan semantics, not
546 // just the signature line — extend coverage to every line spanned by
547 // the function's own `{ ... }`, if it has one (abstract/interface
548 // methods don't).
549 if skip_comments && is_function_like(trimmed) {
550 if let Some(body_lines) = function_body_lines(raw_lines, offset) {
551 extra_covered_lines.extend(body_lines);
552 }
553 }
554 return (target, extra_covered_lines);
555 }
556 (idx as u32 + 2, extra_covered_lines)
557}
558
559/// Whether a trimmed line is itself a function/method DECLARATION (after
560/// skipping any leading visibility/`static`/`abstract`/`final` modifiers, the
561/// next word is `function`). Used to decide whether a "documents the
562/// following element" suppression directive's target has a body worth
563/// extending coverage into.
564///
565/// Deliberately does not match on `function` appearing anywhere in the line —
566/// a directive whose target statement merely CONTAINS a closure (`return
567/// function () {…};`, `$cb = function () {…};`, an `array_map(function ()
568/// {…}, …)` call) annotates that statement, not the closure's own nested
569/// scope, so it must not be treated as covering the closure's body.
570fn is_function_like(trimmed: &str) -> bool {
571 const MODIFIERS: &[&str] = &[
572 "public",
573 "private",
574 "protected",
575 "static",
576 "abstract",
577 "final",
578 ];
579 for word in trimmed.split_whitespace() {
580 let lower = word.to_ascii_lowercase();
581 if MODIFIERS.contains(&lower.as_str()) {
582 continue;
583 }
584 return lower == "function" || lower.starts_with("function(");
585 }
586 false
587}
588
589/// Physical line numbers (1-based, excluding `start_idx`'s own line) spanned
590/// by a function/method body starting at 0-based `start_idx`, found by
591/// scanning forward for a balanced `{`...`}` pair. Returns `None` if a bare
592/// `;` terminates the declaration before any `{` appears — an abstract or
593/// interface method, which has no body to extend suppression coverage into.
594/// No quote-awareness, same conservative scope as `paren_delta`/`bracket_delta`:
595/// a stray unmatched `{`/`}` inside a plain string literal (as opposed to
596/// `"{$expr}"` interpolation, which is always balanced) could throw off depth
597/// tracking — an accepted rare edge case.
598fn function_body_lines(raw_lines: &[&str], start_idx: usize) -> Option<Vec<u32>> {
599 let mut depth: i32 = 0;
600 let mut opened = false;
601 let mut covered = Vec::new();
602 for (offset, line) in raw_lines.iter().enumerate().skip(start_idx) {
603 for c in line.chars() {
604 match c {
605 '{' => {
606 depth += 1;
607 opened = true;
608 }
609 '}' => depth -= 1,
610 ';' if !opened && depth == 0 => return None,
611 _ => {}
612 }
613 }
614 if offset != start_idx {
615 covered.push(offset as u32 + 1);
616 }
617 if opened && depth <= 0 {
618 return Some(covered);
619 }
620 }
621 None
622}
623
624/// Net count of `(` minus `)` in `s` — no quote-awareness, matching
625/// `bracket_delta`'s same conservative scope.
626fn paren_delta(s: &str) -> i32 {
627 s.chars().fold(0i32, |depth, c| match c {
628 '(' => depth + 1,
629 ')' => depth - 1,
630 _ => depth,
631 })
632}
633
634/// Net count of `[` minus `]` in `s` — no quote-awareness, matching the
635/// same conservative scope `is_attribute_only`'s plain `ends_with(']')`
636/// already accepts (real attribute argument text containing a bracket
637/// inside a string literal is out of scope here).
638fn bracket_delta(s: &str) -> i32 {
639 s.chars().fold(0i32, |depth, c| match c {
640 '[' => depth + 1,
641 ']' => depth - 1,
642 _ => depth,
643 })
644}
645
646/// Whether a trimmed line is purely a comment (no PHP code). `#[` is treated as
647/// a PHP 8 attribute (code), not a `#` comment.
648fn is_comment_only(trimmed: &str) -> bool {
649 trimmed.starts_with("//")
650 || trimmed.starts_with("/*")
651 || trimmed.starts_with('*')
652 || (trimmed.starts_with('#') && !trimmed.starts_with("#["))
653}
654
655/// A single-line PHP 8 attribute (`#[Foo]`, `#[Foo(bar: 1)]`) with no other
656/// code on the same line — a trailing same-line comment (`#[Foo] // note`,
657/// `#[Foo] /* note */`) is stripped first, so `]` needn't end the line.
658/// Doesn't attempt multi-line
659/// bracket-depth tracking for a `#[` that spans several lines — only the
660/// common single-line case, mirroring the scope this suppression logic
661/// already accepts elsewhere (e.g. no nested-comment tracking either).
662fn is_attribute_only(trimmed: &str) -> bool {
663 if !trimmed.starts_with("#[") {
664 return false;
665 }
666 // Skip the leading `#` (part of the attribute marker, not a comment)
667 // before scanning for a trailing comment introducer — the same
668 // string-literal-aware scan `extract_comment` uses for a suppression
669 // directive's own line, applied here to the attribute's line instead.
670 let rest = &trimmed[1..];
671 let body = match find_comment_introducer(rest) {
672 Some(pos) => rest[..pos].trim_end(),
673 None => rest,
674 };
675 body.ends_with(']')
676}
677
678/// Directive keyword table, ordered longest-first so that, e.g.,
679/// `@mir-ignore-next-line` is matched before the `@mir-ignore` prefix.
680///
681/// Each entry is `(keyword, scope, force_all)`. `force_all` makes the directive
682/// suppress every kind regardless of trailing tokens (PHPStan semantics).
683const KEYWORDS: &[(&str, Scope, bool)] = &[
684 ("@mir-ignore-next-line", Scope::NextLine, false),
685 ("@mir-suppress-next-line", Scope::NextLine, false),
686 ("@phpstan-ignore-next-line", Scope::NextLine, true),
687 ("@mir-ignore-line", Scope::SameLine, false),
688 ("@mir-suppress-line", Scope::SameLine, false),
689 ("@phpstan-ignore-line", Scope::SameLine, true),
690 ("@mir-ignore-file", Scope::File, false),
691 ("@mir-suppress-file", Scope::File, false),
692 // Bare forms (scope resolved below from comment position).
693 ("@mir-ignore", Scope::NextLine, false),
694 ("@mir-suppress", Scope::NextLine, false),
695 ("@psalm-suppress", Scope::NextLine, false),
696 ("@suppress", Scope::NextLine, false),
697 ("@phpstan-ignore", Scope::NextLine, true),
698];
699
700/// Bare directives (no `-line`/`-next-line`/`-file` suffix) resolve their scope
701/// from where the comment sits: a trailing comment annotates its own line, a
702/// standalone comment annotates the statement that follows it.
703const BARE_KEYWORDS: &[&str] = &[
704 "@mir-ignore",
705 "@mir-suppress",
706 "@psalm-suppress",
707 "@suppress",
708 "@phpstan-ignore",
709];
710
711/// Like `parse_directive` (which is parse_directive_with_tracking discarding the tracking flag),
712/// but also returns whether named suppression tracking
713/// should be applied (true for `@psalm-suppress`, `@mir-suppress`, `@suppress`
714/// and `@mir-ignore` forms; false for `@phpstan-*` which are blanket suppressors
715/// not tied to specific issue kinds).
716fn parse_directive_with_tracking(raw: &str) -> Option<(Directive, bool)> {
717 let comment = extract_comment(raw)?;
718
719 // A comment can carry more than one directive-shaped substring (e.g. a
720 // user's own `@mir-ignore-line Foo` followed later by an unrelated
721 // `@phpstan-ignore-next-line`) — pick whichever keyword actually appears
722 // FIRST in the text, not the first entry `KEYWORDS` happens to define,
723 // so an earlier table entry never shadows a later-defined keyword that
724 // the author actually wrote earlier in the comment.
725 let mut best: Option<(usize, &str, Scope, bool)> = None;
726 for &(keyword, scope, force_all) in KEYWORDS {
727 let Some(pos) = find_ci(comment.content, keyword) else {
728 continue;
729 };
730 // Reject keyword matches that are really a prefix of a longer token
731 // (e.g. `@mir-ignore` inside `@mir-ignore-line`).
732 let after = &comment.content[pos + keyword.len()..];
733 if after
734 .chars()
735 .next()
736 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-')
737 {
738 continue;
739 }
740 let is_leftmost = match best {
741 None => true,
742 Some((best_pos, ..)) => pos < best_pos,
743 };
744 if is_leftmost {
745 best = Some((pos, keyword, scope, force_all));
746 }
747 }
748 let (pos, keyword, scope, force_all) = best?;
749 let after = &comment.content[pos + keyword.len()..];
750
751 let is_bare = BARE_KEYWORDS.contains(&keyword);
752
753 // Bare forms: a trailing comment suppresses its own line.
754 let scope = if is_bare && comment.has_code_before {
755 Scope::SameLine
756 } else {
757 scope
758 };
759
760 // The "documents the following element" forms (bare `@psalm-suppress`,
761 // `@mir-ignore`, …) also skip past a SEPARATE comment sitting between the
762 // directive's own comment and the declaration. PHPStan's explicit
763 // `*-next-line` and bare `@phpstan-ignore` keep literal
764 // next-non-blank-line semantics there — but the directive's own
765 // enclosing block comment (a multi-line docblock's remaining tag lines
766 // and closing `*/`) is always exited first regardless, via
767 // `next_code_line`'s `in_open_block_comment` handling, since a line still
768 // inside that comment can never be the target either way.
769 let skip_comments = scope == Scope::NextLine && is_bare && !force_all;
770
771 let kinds = if force_all {
772 KindSet::All
773 } else {
774 parse_kinds(after)
775 };
776
777 // Track named suppressions only for non-phpstan forms (phpstan forms
778 // always suppress all kinds, so they can never be "unused for a specific kind").
779 let track_named = !keyword.starts_with("@phpstan");
780
781 Some((
782 Directive {
783 scope,
784 kinds,
785 skip_comments,
786 },
787 track_named,
788 ))
789}
790
791/// Case-insensitive `str::find` for an ASCII directive keyword. Kind names
792/// are already matched case-insensitively (`KindSet::matches`); the keyword
793/// itself was still exact-case, so `@MIR-IGNORE`/`@Psalm-Suppress` silently
794/// matched nothing. `to_ascii_lowercase` never changes a string's byte
795/// length (only ASCII bytes are touched), so the returned index stays valid
796/// against the original, non-lowercased `haystack`.
797fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
798 haystack
799 .to_ascii_lowercase()
800 .find(&needle.to_ascii_lowercase())
801}
802
803struct Comment<'a> {
804 /// Text from the comment introducer onward (still includes `*/`, `*`, etc.).
805 content: &'a str,
806 /// Whether non-whitespace code precedes the comment on the same line.
807 has_code_before: bool,
808}
809
810/// Isolate the comment portion of a physical line, if any. Handles `//`, `#`
811/// and `/* … */` introducers, block-comment continuation lines (` * …`) and
812/// bare directive lines inside block comments (`@psalm-suppress …`).
813fn extract_comment(raw: &str) -> Option<Comment<'_>> {
814 let trimmed = raw.trim_start();
815
816 // Block-comment continuation or a bare directive line: no code precedes it.
817 if trimmed.starts_with('*') {
818 return Some(Comment {
819 content: trimmed.trim_start_matches('*'),
820 has_code_before: false,
821 });
822 }
823 if trimmed.starts_with('@') {
824 return Some(Comment {
825 content: trimmed,
826 has_code_before: false,
827 });
828 }
829
830 // Earliest single-line / block introducer on the line, skipping any
831 // that fall inside a quoted string literal (`$x = "// not a
832 // comment";` carries no comment). Only a same-line string is handled
833 // (a bounded, quote-toggle scan); a heredoc/multi-line string body
834 // needs cross-line state this line-at-a-time scanner doesn't have.
835 let pos = find_comment_introducer(raw)?;
836 let has_code_before = !raw[..pos].trim().is_empty();
837 Some(Comment {
838 content: &raw[pos..],
839 has_code_before,
840 })
841}
842
843/// Byte offset of the earliest `//`, `#`, or `/*` comment introducer in
844/// `raw`, ignoring any that fall inside a single- or double-quoted string
845/// literal (with backslash-escape awareness). Returns `None` if the line
846/// has no real comment introducer at all.
847fn find_comment_introducer(raw: &str) -> Option<usize> {
848 let bytes = raw.as_bytes();
849 let mut in_squote = false;
850 let mut in_dquote = false;
851 let mut i = 0;
852 while i < bytes.len() {
853 let c = bytes[i];
854 if in_squote {
855 i += if c == b'\\' && i + 1 < bytes.len() {
856 2
857 } else {
858 if c == b'\'' {
859 in_squote = false;
860 }
861 1
862 };
863 continue;
864 }
865 if in_dquote {
866 i += if c == b'\\' && i + 1 < bytes.len() {
867 2
868 } else {
869 if c == b'"' {
870 in_dquote = false;
871 }
872 1
873 };
874 continue;
875 }
876 match c {
877 b'\'' => in_squote = true,
878 b'"' => in_dquote = true,
879 b'/' if bytes.get(i + 1) == Some(&b'/') || bytes.get(i + 1) == Some(&b'*') => {
880 return Some(i);
881 }
882 // `#[` is a PHP 8 attribute opener, not a `#` line comment (see
883 // `is_comment_only`'s identical carve-out) — keep scanning past
884 // it instead of stopping here, so a REAL trailing comment after
885 // the attribute (`#[Attr] // note`) is still found.
886 b'#' if bytes.get(i + 1) == Some(&b'[') => {}
887 b'#' => return Some(i),
888 _ => {}
889 }
890 i += 1;
891 }
892 None
893}
894
895/// Collect issue kind names/codes following a directive keyword. Kinds are
896/// comma-separated (`Foo, Bar`); only the FIRST whitespace-delimited word of
897/// each comma-separated segment can be a kind name — every further word in
898/// that same segment is treated as free-text prose (e.g. a trailing
899/// explanation like `UndefinedClass because of a vendor stub`), not another
900/// kind, since a real multi-kind list always uses a comma to continue. Once a
901/// segment has trailing words after its kind name, everything from there on
902/// — including later comma-separated segments — is that same free-text
903/// prose, so a comma inside the prose (`Foo because of X, not fully typed`)
904/// must not be mistaken for the start of another kind. Stops entirely at the
905/// block-comment terminator. An empty result means "all kinds".
906fn parse_kinds(rest: &str) -> KindSet {
907 let mut set = FxHashSet::default();
908 'segments: for segment in rest.split(',') {
909 let mut tokens = segment
910 .split([' ', '\t'])
911 .map(str::trim)
912 .filter(|t| !t.is_empty());
913 for token in tokens.by_ref() {
914 // End of the comment / docblock — stop scanning entirely.
915 if token.starts_with("*/") || token.starts_with('*') {
916 break 'segments;
917 }
918 // A kind name is alphanumeric (plus `_`); anything else is
919 // skipped, keeping the next token as the segment's candidate.
920 if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
921 set.insert(token.to_string());
922 if tokens.next().is_some() {
923 // Trailing prose follows in this segment — the rest of
924 // the comment, comma or not, is that same free text.
925 break 'segments;
926 }
927 continue 'segments;
928 }
929 }
930 }
931 if set.is_empty() {
932 KindSet::All
933 } else {
934 KindSet::Named(set)
935 }
936}
937
938#[cfg(test)]
939mod tests {
940 use super::*;
941
942 fn map(src: &str) -> SuppressionMap {
943 SuppressionMap::from_source(src)
944 }
945
946 #[test]
947 fn line_comment_above_statement_suppresses_next_line() {
948 // line 2 comment → suppress line 3
949 let m = map("<?php\n// @psalm-suppress UndefinedClass\nnew NoSuchClass();\n");
950 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
951 assert!(!m.is_suppressed(2, "UndefinedClass", "MIR0000"));
952 }
953
954 #[test]
955 fn trailing_comment_suppresses_own_line() {
956 let m = map("<?php\nnew NoSuchClass(); // @mir-ignore UndefinedClass\n");
957 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
958 }
959
960 #[test]
961 fn single_line_docblock_above_statement() {
962 let m = map("<?php\n/** @psalm-suppress UndefinedClass */\nnew NoSuchClass();\n");
963 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
964 }
965
966 #[test]
967 fn phpstan_ignore_next_line_suppresses_all() {
968 let m = map("<?php\n// @phpstan-ignore-next-line\nnew NoSuchClass();\n");
969 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
970 assert!(m.is_suppressed(3, "AnyOtherKind", "MIR9999"));
971 }
972
973 #[test]
974 fn ignore_line_targets_own_line() {
975 let m = map("<?php\nnew NoSuchClass(); // @mir-ignore-line\n");
976 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
977 }
978
979 #[test]
980 fn next_line_skips_blank_lines() {
981 let m = map("<?php\n/** @psalm-suppress UndefinedClass */\n\n\nnew NoSuchClass();\n");
982 assert!(m.is_suppressed(5, "UndefinedClass", "MIR0000"));
983 }
984
985 #[test]
986 fn multiline_docblock_skips_to_declaration() {
987 // line 2: /**, line 3: * @psalm-suppress, line 4: */, line 5: declaration.
988 let src =
989 "<?php\n/**\n * @psalm-suppress UnusedMethod\n */\nprivate function a(): void {}\n";
990 let m = map(src);
991 assert!(m.is_suppressed(5, "UnusedMethod", "MIR0000"));
992 }
993
994 #[test]
995 fn phpstan_next_line_is_literal_not_comment_skipping() {
996 // PHPStan's -next-line targets the next non-blank line even if it's a
997 // comment; it does not hunt for the next code line.
998 let m = map("<?php\n// @phpstan-ignore-next-line\n// unrelated comment\nfoo();\n");
999 assert!(m.is_suppressed(3, "X", "MIR0000"));
1000 assert!(!m.is_suppressed(4, "X", "MIR0000"));
1001 }
1002
1003 #[test]
1004 fn named_kind_does_not_suppress_other_kinds() {
1005 let m = map("<?php\n// @mir-ignore UndefinedClass\nfoo();\n");
1006 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
1007 assert!(!m.is_suppressed(3, "UndefinedFunction", "MIR0001"));
1008 }
1009
1010 #[test]
1011 fn match_by_code() {
1012 let m = map("<?php\n// @mir-ignore MIR1400\nfoo();\n");
1013 assert!(m.is_suppressed(3, "ParseError", "MIR1400"));
1014 }
1015
1016 #[test]
1017 fn file_scope_suppresses_every_line() {
1018 let m = map("<?php // @mir-ignore-file UndefinedClass\nfoo();\nbar();\n");
1019 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
1020 assert!(m.is_suppressed(99, "UndefinedClass", "MIR0000"));
1021 assert!(!m.is_suppressed(2, "UndefinedFunction", "MIR0001"));
1022 }
1023
1024 #[test]
1025 fn multiple_kinds_one_directive() {
1026 let m = map("<?php\n// @psalm-suppress UndefinedClass, NullMethodCall\nfoo();\n");
1027 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
1028 assert!(m.is_suppressed(3, "NullMethodCall", "MIR0001"));
1029 }
1030
1031 #[test]
1032 fn no_directive_is_empty() {
1033 let m = map("<?php\n$x = \"@psalm-suppress not a comment\";\nfoo();\n");
1034 // It's inside a string but after `//`? No `//` here, so not detected.
1035 assert!(m.is_empty());
1036 }
1037
1038 #[test]
1039 fn prefix_is_not_confused_with_longer_keyword() {
1040 // `@mir-ignore-next-line` must be parsed as next-line, not bare same-line.
1041 let m = map("<?php\nfoo(); // @mir-ignore-next-line\nbar();\n");
1042 assert!(m.is_suppressed(3, "AnyKind", "MIR0000"));
1043 assert!(!m.is_suppressed(2, "AnyKind", "MIR0000"));
1044 }
1045}