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