use rustc_hash::{FxHashMap, FxHashSet};
#[derive(Debug, Clone)]
enum KindSet {
All,
Named(FxHashSet<String>),
}
impl KindSet {
fn matches(&self, name: &str, code: &str) -> bool {
match self {
KindSet::All => true,
KindSet::Named(set) => set
.iter()
.any(|k| k.eq_ignore_ascii_case(name) || k.eq_ignore_ascii_case(code)),
}
}
fn merge(&mut self, other: KindSet) {
match (self, other) {
(KindSet::All, _) => {}
(slot @ KindSet::Named(_), KindSet::All) => *slot = KindSet::All,
(KindSet::Named(a), KindSet::Named(b)) => a.extend(b),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Scope {
SameLine,
NextLine,
File,
}
struct Directive {
scope: Scope,
kinds: KindSet,
skip_comments: bool,
}
#[derive(Debug, Clone)]
pub struct NamedSuppression {
pub report_line: u32,
pub covered_lines: Vec<u32>,
pub kind: String,
}
#[derive(Debug, Default)]
pub struct SuppressionMap {
lines: FxHashMap<u32, KindSet>,
file: Option<KindSet>,
pub named_suppressions: Vec<NamedSuppression>,
}
impl SuppressionMap {
pub fn is_empty(&self) -> bool {
self.lines.is_empty() && self.file.is_none()
}
pub fn is_suppressed(&self, line: u32, name: &str, code: &str) -> bool {
if let Some(file) = &self.file {
if file.matches(name, code) {
return true;
}
}
self.lines.get(&line).is_some_and(|k| k.matches(name, code))
}
pub fn from_source(source: &str) -> Self {
let raw_lines: Vec<&str> = source.lines().collect();
let in_heredoc_body = heredoc_body_mask(&raw_lines);
let mut map = SuppressionMap::default();
for (idx, raw) in raw_lines.iter().enumerate() {
if in_heredoc_body[idx] {
continue;
}
let Some((directive, track_named)) = parse_directive_with_tracking(raw) else {
continue;
};
match directive.scope {
Scope::File => match &mut map.file {
Some(existing) => existing.merge(directive.kinds),
None => map.file = Some(directive.kinds),
},
Scope::SameLine => {
let line_no = idx as u32 + 1;
if track_named {
if let KindSet::Named(ref names) = directive.kinds {
for name in names {
map.named_suppressions.push(NamedSuppression {
report_line: line_no,
covered_lines: vec![line_no],
kind: name.clone(),
});
}
}
}
insert_line(&mut map.lines, line_no, directive.kinds);
}
Scope::NextLine => {
let (target, extra_covered_lines) =
next_code_line(&raw_lines, idx, directive.skip_comments);
if track_named {
if let KindSet::Named(ref names) = directive.kinds {
let mut covered_lines = extra_covered_lines.clone();
covered_lines.push(target);
for name in names {
map.named_suppressions.push(NamedSuppression {
report_line: target,
covered_lines: covered_lines.clone(),
kind: name.clone(),
});
}
}
}
for extra_line in extra_covered_lines {
insert_line(&mut map.lines, extra_line, directive.kinds.clone());
}
insert_line(&mut map.lines, target, directive.kinds);
}
}
}
map
}
pub fn unused_named(
&self,
all_issues: &[&mir_issues::Issue],
pre_suppressed: &[&mir_issues::Issue],
) -> Vec<(u32, String)> {
self.named_suppressions
.iter()
.filter(|ns| {
let target_line = ns.report_line;
let kind = &ns.kind;
let kind_matches = |issue: &&mir_issues::Issue| {
issue
.kind
.display_name()
.eq_ignore_ascii_case(kind.as_str())
|| issue.kind.code().eq_ignore_ascii_case(kind.as_str())
};
let at_covered_line = all_issues.iter().any(|issue| {
ns.covered_lines.contains(&issue.location.line) && kind_matches(issue)
});
if at_covered_line {
return false; }
let min_line = target_line.saturating_sub(100);
let covered_by_pre_suppressed = pre_suppressed.iter().any(|issue| {
issue.location.line >= min_line
&& issue.location.line < target_line
&& kind_matches(issue)
});
!covered_by_pre_suppressed
})
.map(|ns| (ns.report_line, ns.kind.clone()))
.collect()
}
}
fn insert_line(lines: &mut FxHashMap<u32, KindSet>, line: u32, kinds: KindSet) {
match lines.get_mut(&line) {
Some(existing) => existing.merge(kinds),
None => {
lines.insert(line, kinds);
}
}
}
fn heredoc_body_mask(raw_lines: &[&str]) -> Vec<bool> {
let mut mask = vec![false; raw_lines.len()];
let mut closing_ident: Option<&str> = None;
for (idx, line) in raw_lines.iter().enumerate() {
if let Some(ident) = closing_ident {
mask[idx] = true;
if is_heredoc_closing_line(line, ident) {
closing_ident = None;
}
continue;
}
closing_ident = find_heredoc_opener(line);
}
mask
}
fn find_heredoc_opener(line: &str) -> Option<&str> {
let pos = line.find("<<<")?;
let rest = line[pos + 3..].trim_start();
let (quote, rest) = match rest.as_bytes().first() {
Some(b'\'') => (Some(b'\''), &rest[1..]),
Some(b'"') => (Some(b'"'), &rest[1..]),
_ => (None, rest),
};
let end = rest
.find(|c: char| !c.is_ascii_alphanumeric() && c != '_')
.unwrap_or(rest.len());
let ident = &rest[..end];
if ident.is_empty() {
return None;
}
if let Some(q) = quote {
if rest.as_bytes().get(end) != Some(&q) {
return None;
}
}
Some(ident)
}
fn is_heredoc_closing_line(line: &str, ident: &str) -> bool {
let Some(rest) = line.trim_start().strip_prefix(ident) else {
return false;
};
rest.chars()
.next()
.is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_')
}
fn next_code_line(raw_lines: &[&str], idx: usize, skip_comments: bool) -> (u32, Vec<u32>) {
let mut extra_covered_lines = Vec::new();
let mut multiline_attr_depth: i32 = 0;
for (offset, line) in raw_lines.iter().enumerate().skip(idx + 1) {
let trimmed = line.trim();
if multiline_attr_depth > 0 {
extra_covered_lines.push(offset as u32 + 1);
multiline_attr_depth += bracket_delta(trimmed);
continue;
}
if trimmed.is_empty() {
continue;
}
if is_attribute_only(trimmed) {
extra_covered_lines.push(offset as u32 + 1);
continue;
}
if trimmed.starts_with("#[") {
let delta = bracket_delta(trimmed);
if delta > 0 {
extra_covered_lines.push(offset as u32 + 1);
multiline_attr_depth = delta;
continue;
}
}
if skip_comments && is_comment_only(trimmed) {
continue;
}
let target = offset as u32 + 1;
let mut paren_depth = paren_delta(trimmed);
let mut cont_idx = offset + 1;
while paren_depth > 0 {
let Some(cont_line) = raw_lines.get(cont_idx) else {
break;
};
extra_covered_lines.push(cont_idx as u32 + 1);
paren_depth += paren_delta(cont_line.trim());
cont_idx += 1;
}
if skip_comments && is_function_like(trimmed) {
if let Some(body_lines) = function_body_lines(raw_lines, offset) {
extra_covered_lines.extend(body_lines);
}
}
return (target, extra_covered_lines);
}
(idx as u32 + 2, extra_covered_lines)
}
fn is_function_like(trimmed: &str) -> bool {
const MODIFIERS: &[&str] = &[
"public",
"private",
"protected",
"static",
"abstract",
"final",
];
for word in trimmed.split_whitespace() {
let lower = word.to_ascii_lowercase();
if MODIFIERS.contains(&lower.as_str()) {
continue;
}
return lower == "function" || lower.starts_with("function(");
}
false
}
fn function_body_lines(raw_lines: &[&str], start_idx: usize) -> Option<Vec<u32>> {
let mut depth: i32 = 0;
let mut opened = false;
let mut covered = Vec::new();
for (offset, line) in raw_lines.iter().enumerate().skip(start_idx) {
for c in line.chars() {
match c {
'{' => {
depth += 1;
opened = true;
}
'}' => depth -= 1,
';' if !opened && depth == 0 => return None,
_ => {}
}
}
if offset != start_idx {
covered.push(offset as u32 + 1);
}
if opened && depth <= 0 {
return Some(covered);
}
}
None
}
fn paren_delta(s: &str) -> i32 {
s.chars().fold(0i32, |depth, c| match c {
'(' => depth + 1,
')' => depth - 1,
_ => depth,
})
}
fn bracket_delta(s: &str) -> i32 {
s.chars().fold(0i32, |depth, c| match c {
'[' => depth + 1,
']' => depth - 1,
_ => depth,
})
}
fn is_comment_only(trimmed: &str) -> bool {
trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with('*')
|| (trimmed.starts_with('#') && !trimmed.starts_with("#["))
}
fn is_attribute_only(trimmed: &str) -> bool {
if !trimmed.starts_with("#[") {
return false;
}
let rest = &trimmed[1..];
let body = match find_comment_introducer(rest) {
Some(pos) => rest[..pos].trim_end(),
None => rest,
};
body.ends_with(']')
}
const KEYWORDS: &[(&str, Scope, bool)] = &[
("@mir-ignore-next-line", Scope::NextLine, false),
("@mir-suppress-next-line", Scope::NextLine, false),
("@phpstan-ignore-next-line", Scope::NextLine, true),
("@mir-ignore-line", Scope::SameLine, false),
("@mir-suppress-line", Scope::SameLine, false),
("@phpstan-ignore-line", Scope::SameLine, true),
("@mir-ignore-file", Scope::File, false),
("@mir-suppress-file", Scope::File, false),
("@mir-ignore", Scope::NextLine, false),
("@mir-suppress", Scope::NextLine, false),
("@psalm-suppress", Scope::NextLine, false),
("@suppress", Scope::NextLine, false),
("@phpstan-ignore", Scope::NextLine, true),
];
const BARE_KEYWORDS: &[&str] = &[
"@mir-ignore",
"@mir-suppress",
"@psalm-suppress",
"@suppress",
"@phpstan-ignore",
];
fn parse_directive_with_tracking(raw: &str) -> Option<(Directive, bool)> {
let comment = extract_comment(raw)?;
let mut best: Option<(usize, &str, Scope, bool)> = None;
for &(keyword, scope, force_all) in KEYWORDS {
let Some(pos) = find_ci(comment.content, keyword) else {
continue;
};
let after = &comment.content[pos + keyword.len()..];
if after
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphanumeric() || c == '-')
{
continue;
}
let is_leftmost = match best {
None => true,
Some((best_pos, ..)) => pos < best_pos,
};
if is_leftmost {
best = Some((pos, keyword, scope, force_all));
}
}
let (pos, keyword, scope, force_all) = best?;
let after = &comment.content[pos + keyword.len()..];
let is_bare = BARE_KEYWORDS.contains(&keyword);
let scope = if is_bare && comment.has_code_before {
Scope::SameLine
} else {
scope
};
let skip_comments = scope == Scope::NextLine && is_bare && !force_all;
let kinds = if force_all {
KindSet::All
} else {
parse_kinds(after)
};
let track_named = !keyword.starts_with("@phpstan");
Some((
Directive {
scope,
kinds,
skip_comments,
},
track_named,
))
}
fn find_ci(haystack: &str, needle: &str) -> Option<usize> {
haystack
.to_ascii_lowercase()
.find(&needle.to_ascii_lowercase())
}
struct Comment<'a> {
content: &'a str,
has_code_before: bool,
}
fn extract_comment(raw: &str) -> Option<Comment<'_>> {
let trimmed = raw.trim_start();
if trimmed.starts_with('*') {
return Some(Comment {
content: trimmed.trim_start_matches('*'),
has_code_before: false,
});
}
if trimmed.starts_with('@') {
return Some(Comment {
content: trimmed,
has_code_before: false,
});
}
let pos = find_comment_introducer(raw)?;
let has_code_before = !raw[..pos].trim().is_empty();
Some(Comment {
content: &raw[pos..],
has_code_before,
})
}
fn find_comment_introducer(raw: &str) -> Option<usize> {
let bytes = raw.as_bytes();
let mut in_squote = false;
let mut in_dquote = false;
let mut i = 0;
while i < bytes.len() {
let c = bytes[i];
if in_squote {
i += if c == b'\\' && i + 1 < bytes.len() {
2
} else {
if c == b'\'' {
in_squote = false;
}
1
};
continue;
}
if in_dquote {
i += if c == b'\\' && i + 1 < bytes.len() {
2
} else {
if c == b'"' {
in_dquote = false;
}
1
};
continue;
}
match c {
b'\'' => in_squote = true,
b'"' => in_dquote = true,
b'/' if bytes.get(i + 1) == Some(&b'/') || bytes.get(i + 1) == Some(&b'*') => {
return Some(i);
}
b'#' if bytes.get(i + 1) == Some(&b'[') => {}
b'#' => return Some(i),
_ => {}
}
i += 1;
}
None
}
fn parse_kinds(rest: &str) -> KindSet {
let mut set = FxHashSet::default();
'segments: for segment in rest.split(',') {
let mut tokens = segment
.split([' ', '\t'])
.map(str::trim)
.filter(|t| !t.is_empty());
for token in tokens.by_ref() {
if token.starts_with("*/") || token.starts_with('*') {
break 'segments;
}
if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
set.insert(token.to_string());
if tokens.next().is_some() {
break 'segments;
}
continue 'segments;
}
}
}
if set.is_empty() {
KindSet::All
} else {
KindSet::Named(set)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn map(src: &str) -> SuppressionMap {
SuppressionMap::from_source(src)
}
#[test]
fn line_comment_above_statement_suppresses_next_line() {
let m = map("<?php\n// @psalm-suppress UndefinedClass\nnew NoSuchClass();\n");
assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
assert!(!m.is_suppressed(2, "UndefinedClass", "MIR0000"));
}
#[test]
fn trailing_comment_suppresses_own_line() {
let m = map("<?php\nnew NoSuchClass(); // @mir-ignore UndefinedClass\n");
assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
}
#[test]
fn single_line_docblock_above_statement() {
let m = map("<?php\n/** @psalm-suppress UndefinedClass */\nnew NoSuchClass();\n");
assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
}
#[test]
fn phpstan_ignore_next_line_suppresses_all() {
let m = map("<?php\n// @phpstan-ignore-next-line\nnew NoSuchClass();\n");
assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
assert!(m.is_suppressed(3, "AnyOtherKind", "MIR9999"));
}
#[test]
fn ignore_line_targets_own_line() {
let m = map("<?php\nnew NoSuchClass(); // @mir-ignore-line\n");
assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
}
#[test]
fn next_line_skips_blank_lines() {
let m = map("<?php\n/** @psalm-suppress UndefinedClass */\n\n\nnew NoSuchClass();\n");
assert!(m.is_suppressed(5, "UndefinedClass", "MIR0000"));
}
#[test]
fn multiline_docblock_skips_to_declaration() {
let src =
"<?php\n/**\n * @psalm-suppress UnusedMethod\n */\nprivate function a(): void {}\n";
let m = map(src);
assert!(m.is_suppressed(5, "UnusedMethod", "MIR0000"));
}
#[test]
fn phpstan_next_line_is_literal_not_comment_skipping() {
let m = map("<?php\n// @phpstan-ignore-next-line\n// unrelated comment\nfoo();\n");
assert!(m.is_suppressed(3, "X", "MIR0000"));
assert!(!m.is_suppressed(4, "X", "MIR0000"));
}
#[test]
fn named_kind_does_not_suppress_other_kinds() {
let m = map("<?php\n// @mir-ignore UndefinedClass\nfoo();\n");
assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
assert!(!m.is_suppressed(3, "UndefinedFunction", "MIR0001"));
}
#[test]
fn match_by_code() {
let m = map("<?php\n// @mir-ignore MIR1400\nfoo();\n");
assert!(m.is_suppressed(3, "ParseError", "MIR1400"));
}
#[test]
fn file_scope_suppresses_every_line() {
let m = map("<?php // @mir-ignore-file UndefinedClass\nfoo();\nbar();\n");
assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
assert!(m.is_suppressed(99, "UndefinedClass", "MIR0000"));
assert!(!m.is_suppressed(2, "UndefinedFunction", "MIR0001"));
}
#[test]
fn multiple_kinds_one_directive() {
let m = map("<?php\n// @psalm-suppress UndefinedClass, NullMethodCall\nfoo();\n");
assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
assert!(m.is_suppressed(3, "NullMethodCall", "MIR0001"));
}
#[test]
fn no_directive_is_empty() {
let m = map("<?php\n$x = \"@psalm-suppress not a comment\";\nfoo();\n");
assert!(m.is_empty());
}
#[test]
fn prefix_is_not_confused_with_longer_keyword() {
let m = map("<?php\nfoo(); // @mir-ignore-next-line\nbar();\n");
assert!(m.is_suppressed(3, "AnyKind", "MIR0000"));
assert!(!m.is_suppressed(2, "AnyKind", "MIR0000"));
}
}