1use rustc_hash::{FxHashMap, FxHashSet};
41
42#[derive(Debug, Clone)]
44enum KindSet {
45 All,
47 Named(FxHashSet<String>),
49}
50
51impl KindSet {
52 fn matches(&self, name: &str, code: &str) -> bool {
53 match self {
54 KindSet::All => true,
55 KindSet::Named(set) => set.contains(name) || set.contains(code),
56 }
57 }
58
59 fn merge(&mut self, other: KindSet) {
60 match (self, other) {
61 (KindSet::All, _) => {}
63 (slot @ KindSet::Named(_), KindSet::All) => *slot = KindSet::All,
64 (KindSet::Named(a), KindSet::Named(b)) => a.extend(b),
65 }
66 }
67}
68
69#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum Scope {
72 SameLine,
74 NextLine,
76 File,
78}
79
80struct Directive {
81 scope: Scope,
82 kinds: KindSet,
83 skip_comments: bool,
89}
90
91#[derive(Debug, Default)]
93pub struct SuppressionMap {
94 lines: FxHashMap<u32, KindSet>,
96 file: Option<KindSet>,
98}
99
100impl SuppressionMap {
101 pub fn is_empty(&self) -> bool {
103 self.lines.is_empty() && self.file.is_none()
104 }
105
106 pub fn is_suppressed(&self, line: u32, name: &str, code: &str) -> bool {
108 if let Some(file) = &self.file {
109 if file.matches(name, code) {
110 return true;
111 }
112 }
113 self.lines.get(&line).is_some_and(|k| k.matches(name, code))
114 }
115
116 pub fn from_source(source: &str) -> Self {
118 let raw_lines: Vec<&str> = source.lines().collect();
119 let mut map = SuppressionMap::default();
120
121 for (idx, raw) in raw_lines.iter().enumerate() {
122 let Some(directive) = parse_directive(raw) else {
123 continue;
124 };
125 match directive.scope {
126 Scope::File => match &mut map.file {
127 Some(existing) => existing.merge(directive.kinds),
128 None => map.file = Some(directive.kinds),
129 },
130 Scope::SameLine => {
131 let line_no = idx as u32 + 1;
132 insert_line(&mut map.lines, line_no, directive.kinds);
133 }
134 Scope::NextLine => {
135 let target = next_code_line(&raw_lines, idx, directive.skip_comments);
136 insert_line(&mut map.lines, target, directive.kinds);
137 }
138 }
139 }
140
141 map
142 }
143}
144
145fn insert_line(lines: &mut FxHashMap<u32, KindSet>, line: u32, kinds: KindSet) {
146 match lines.get_mut(&line) {
147 Some(existing) => existing.merge(kinds),
148 None => {
149 lines.insert(line, kinds);
150 }
151 }
152}
153
154fn next_code_line(raw_lines: &[&str], idx: usize, skip_comments: bool) -> u32 {
162 for (offset, line) in raw_lines.iter().enumerate().skip(idx + 1) {
163 let trimmed = line.trim();
164 if trimmed.is_empty() {
165 continue;
166 }
167 if skip_comments && is_comment_only(trimmed) {
168 continue;
169 }
170 return offset as u32 + 1;
171 }
172 idx as u32 + 2
173}
174
175fn is_comment_only(trimmed: &str) -> bool {
178 trimmed.starts_with("//")
179 || trimmed.starts_with("/*")
180 || trimmed.starts_with('*')
181 || (trimmed.starts_with('#') && !trimmed.starts_with("#["))
182}
183
184const KEYWORDS: &[(&str, Scope, bool)] = &[
190 ("@mir-ignore-next-line", Scope::NextLine, false),
191 ("@mir-suppress-next-line", Scope::NextLine, false),
192 ("@phpstan-ignore-next-line", Scope::NextLine, true),
193 ("@mir-ignore-line", Scope::SameLine, false),
194 ("@mir-suppress-line", Scope::SameLine, false),
195 ("@phpstan-ignore-line", Scope::SameLine, true),
196 ("@mir-ignore-file", Scope::File, false),
197 ("@mir-suppress-file", Scope::File, false),
198 ("@mir-ignore", Scope::NextLine, false),
200 ("@mir-suppress", Scope::NextLine, false),
201 ("@psalm-suppress", Scope::NextLine, false),
202 ("@suppress", Scope::NextLine, false),
203 ("@phpstan-ignore", Scope::NextLine, true),
204];
205
206const BARE_KEYWORDS: &[&str] = &[
210 "@mir-ignore",
211 "@mir-suppress",
212 "@psalm-suppress",
213 "@suppress",
214 "@phpstan-ignore",
215];
216
217fn parse_directive(raw: &str) -> Option<Directive> {
218 let comment = extract_comment(raw)?;
219
220 for &(keyword, scope, force_all) in KEYWORDS {
221 let Some(pos) = comment.content.find(keyword) else {
222 continue;
223 };
224 let after = &comment.content[pos + keyword.len()..];
227 if after
228 .chars()
229 .next()
230 .is_some_and(|c| c.is_ascii_alphanumeric() || c == '-')
231 {
232 continue;
233 }
234
235 let is_bare = BARE_KEYWORDS.contains(&keyword);
236
237 let scope = if is_bare && comment.has_code_before {
239 Scope::SameLine
240 } else {
241 scope
242 };
243
244 let skip_comments = scope == Scope::NextLine && is_bare && !force_all;
250
251 let kinds = if force_all {
252 KindSet::All
253 } else {
254 parse_kinds(after)
255 };
256
257 return Some(Directive {
258 scope,
259 kinds,
260 skip_comments,
261 });
262 }
263
264 None
265}
266
267struct Comment<'a> {
268 content: &'a str,
270 has_code_before: bool,
272}
273
274fn extract_comment(raw: &str) -> Option<Comment<'_>> {
278 let trimmed = raw.trim_start();
279
280 if trimmed.starts_with('*') {
282 return Some(Comment {
283 content: trimmed.trim_start_matches('*'),
284 has_code_before: false,
285 });
286 }
287 if trimmed.starts_with('@') {
288 return Some(Comment {
289 content: trimmed,
290 has_code_before: false,
291 });
292 }
293
294 let pos = [raw.find("//"), raw.find('#'), raw.find("/*")]
296 .into_iter()
297 .flatten()
298 .min()?;
299 let has_code_before = !raw[..pos].trim().is_empty();
300 Some(Comment {
301 content: &raw[pos..],
302 has_code_before,
303 })
304}
305
306fn parse_kinds(rest: &str) -> KindSet {
310 let mut set = FxHashSet::default();
311 for token in rest.split([' ', '\t', ',']) {
312 let token = token.trim();
313 if token.is_empty() {
314 continue;
315 }
316 if token.starts_with("*/") || token.starts_with('*') {
318 break;
319 }
320 if token.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
323 set.insert(token.to_string());
324 }
325 }
326 if set.is_empty() {
327 KindSet::All
328 } else {
329 KindSet::Named(set)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 fn map(src: &str) -> SuppressionMap {
338 SuppressionMap::from_source(src)
339 }
340
341 #[test]
342 fn line_comment_above_statement_suppresses_next_line() {
343 let m = map("<?php\n// @psalm-suppress UndefinedClass\nnew NoSuchClass();\n");
345 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
346 assert!(!m.is_suppressed(2, "UndefinedClass", "MIR0000"));
347 }
348
349 #[test]
350 fn trailing_comment_suppresses_own_line() {
351 let m = map("<?php\nnew NoSuchClass(); // @mir-ignore UndefinedClass\n");
352 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
353 }
354
355 #[test]
356 fn single_line_docblock_above_statement() {
357 let m = map("<?php\n/** @psalm-suppress UndefinedClass */\nnew NoSuchClass();\n");
358 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
359 }
360
361 #[test]
362 fn phpstan_ignore_next_line_suppresses_all() {
363 let m = map("<?php\n// @phpstan-ignore-next-line\nnew NoSuchClass();\n");
364 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
365 assert!(m.is_suppressed(3, "AnyOtherKind", "MIR9999"));
366 }
367
368 #[test]
369 fn ignore_line_targets_own_line() {
370 let m = map("<?php\nnew NoSuchClass(); // @mir-ignore-line\n");
371 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
372 }
373
374 #[test]
375 fn next_line_skips_blank_lines() {
376 let m = map("<?php\n/** @psalm-suppress UndefinedClass */\n\n\nnew NoSuchClass();\n");
377 assert!(m.is_suppressed(5, "UndefinedClass", "MIR0000"));
378 }
379
380 #[test]
381 fn multiline_docblock_skips_to_declaration() {
382 let src =
384 "<?php\n/**\n * @psalm-suppress UnusedMethod\n */\nprivate function a(): void {}\n";
385 let m = map(src);
386 assert!(m.is_suppressed(5, "UnusedMethod", "MIR0000"));
387 }
388
389 #[test]
390 fn phpstan_next_line_is_literal_not_comment_skipping() {
391 let m = map("<?php\n// @phpstan-ignore-next-line\n// unrelated comment\nfoo();\n");
394 assert!(m.is_suppressed(3, "X", "MIR0000"));
395 assert!(!m.is_suppressed(4, "X", "MIR0000"));
396 }
397
398 #[test]
399 fn named_kind_does_not_suppress_other_kinds() {
400 let m = map("<?php\n// @mir-ignore UndefinedClass\nfoo();\n");
401 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
402 assert!(!m.is_suppressed(3, "UndefinedFunction", "MIR0001"));
403 }
404
405 #[test]
406 fn match_by_code() {
407 let m = map("<?php\n// @mir-ignore MIR1400\nfoo();\n");
408 assert!(m.is_suppressed(3, "ParseError", "MIR1400"));
409 }
410
411 #[test]
412 fn file_scope_suppresses_every_line() {
413 let m = map("<?php // @mir-ignore-file UndefinedClass\nfoo();\nbar();\n");
414 assert!(m.is_suppressed(2, "UndefinedClass", "MIR0000"));
415 assert!(m.is_suppressed(99, "UndefinedClass", "MIR0000"));
416 assert!(!m.is_suppressed(2, "UndefinedFunction", "MIR0001"));
417 }
418
419 #[test]
420 fn multiple_kinds_one_directive() {
421 let m = map("<?php\n// @psalm-suppress UndefinedClass, NullMethodCall\nfoo();\n");
422 assert!(m.is_suppressed(3, "UndefinedClass", "MIR0000"));
423 assert!(m.is_suppressed(3, "NullMethodCall", "MIR0001"));
424 }
425
426 #[test]
427 fn no_directive_is_empty() {
428 let m = map("<?php\n$x = \"@psalm-suppress not a comment\";\nfoo();\n");
429 assert!(m.is_empty());
431 }
432
433 #[test]
434 fn prefix_is_not_confused_with_longer_keyword() {
435 let m = map("<?php\nfoo(); // @mir-ignore-next-line\nbar();\n");
437 assert!(m.is_suppressed(3, "AnyKind", "MIR0000"));
438 assert!(!m.is_suppressed(2, "AnyKind", "MIR0000"));
439 }
440}