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