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.contains(name) || set.contains(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, Default)]
pub struct SuppressionMap {
lines: FxHashMap<u32, KindSet>,
file: Option<KindSet>,
}
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 mut map = SuppressionMap::default();
for (idx, raw) in raw_lines.iter().enumerate() {
let Some(directive) = parse_directive(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;
insert_line(&mut map.lines, line_no, directive.kinds);
}
Scope::NextLine => {
let target = next_code_line(&raw_lines, idx, directive.skip_comments);
insert_line(&mut map.lines, target, directive.kinds);
}
}
}
map
}
}
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 next_code_line(raw_lines: &[&str], idx: usize, skip_comments: bool) -> u32 {
for (offset, line) in raw_lines.iter().enumerate().skip(idx + 1) {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if skip_comments && is_comment_only(trimmed) {
continue;
}
return offset as u32 + 1;
}
idx as u32 + 2
}
fn is_comment_only(trimmed: &str) -> bool {
trimmed.starts_with("//")
|| trimmed.starts_with("/*")
|| trimmed.starts_with('*')
|| (trimmed.starts_with('#') && !trimmed.starts_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(raw: &str) -> Option<Directive> {
let comment = extract_comment(raw)?;
for &(keyword, scope, force_all) in KEYWORDS {
let Some(pos) = comment.content.find(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_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)
};
return Some(Directive {
scope,
kinds,
skip_comments,
});
}
None
}
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 = [raw.find("//"), raw.find('#'), raw.find("/*")]
.into_iter()
.flatten()
.min()?;
let has_code_before = !raw[..pos].trim().is_empty();
Some(Comment {
content: &raw[pos..],
has_code_before,
})
}
fn parse_kinds(rest: &str) -> KindSet {
let mut set = FxHashSet::default();
for token in rest.split([' ', '\t', ',']) {
let token = token.trim();
if token.is_empty() {
continue;
}
if token.starts_with("*/") || token.starts_with('*') {
break;
}
if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
set.insert(token.to_string());
}
}
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"));
}
}