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