1use std::sync::atomic::{AtomicBool, Ordering};
2
3use rustc_hash::FxHashMap;
4
5pub use fallow_types::suppress::{IssueKind, Suppression};
7
8pub use fallow_extract::suppress::parse_suppressions_from_source;
10
11use crate::discover::FileId;
12use crate::extract::ModuleInfo;
13use crate::graph::ModuleGraph;
14use crate::results::{StaleSuppression, SuppressionOrigin};
15
16const NON_CORE_KINDS: &[IssueKind] = &[
22 IssueKind::Complexity,
24 IssueKind::CoverageGaps,
25 IssueKind::FeatureFlag,
26 IssueKind::CodeDuplication,
27 IssueKind::UnusedDependency,
29 IssueKind::UnusedDevDependency,
30 IssueKind::UnlistedDependency,
31 IssueKind::TypeOnlyDependency,
32 IssueKind::TestOnlyDependency,
33 IssueKind::StaleSuppression,
37];
38
39pub struct SuppressionContext<'a> {
48 by_file: FxHashMap<FileId, &'a [Suppression]>,
49 used: FxHashMap<FileId, Vec<AtomicBool>>,
50}
51
52impl<'a> SuppressionContext<'a> {
53 pub fn new(modules: &'a [ModuleInfo]) -> Self {
55 let by_file: FxHashMap<FileId, &[Suppression]> = modules
56 .iter()
57 .filter(|m| !m.suppressions.is_empty())
58 .map(|m| (m.file_id, m.suppressions.as_slice()))
59 .collect();
60
61 let used = by_file
62 .iter()
63 .map(|(&fid, supps)| {
64 (
65 fid,
66 std::iter::repeat_with(|| AtomicBool::new(false))
67 .take(supps.len())
68 .collect(),
69 )
70 })
71 .collect();
72
73 Self { by_file, used }
74 }
75
76 #[cfg(test)]
78 pub fn from_map(by_file: FxHashMap<FileId, &'a [Suppression]>) -> Self {
79 let used = by_file
80 .iter()
81 .map(|(&fid, supps)| {
82 (
83 fid,
84 std::iter::repeat_with(|| AtomicBool::new(false))
85 .take(supps.len())
86 .collect(),
87 )
88 })
89 .collect();
90 Self { by_file, used }
91 }
92
93 #[cfg(test)]
95 pub fn empty() -> Self {
96 Self {
97 by_file: FxHashMap::default(),
98 used: FxHashMap::default(),
99 }
100 }
101
102 #[must_use]
105 pub fn is_suppressed(&self, file_id: FileId, line: u32, kind: IssueKind) -> bool {
106 let Some(supps) = self.by_file.get(&file_id) else {
107 return false;
108 };
109 let Some(used) = self.used.get(&file_id) else {
110 return false;
111 };
112 for (i, s) in supps.iter().enumerate() {
113 let matched = if s.line == 0 {
114 s.kind.is_none() || s.kind == Some(kind)
115 } else {
116 s.line == line && (s.kind.is_none() || s.kind == Some(kind))
117 };
118 if matched {
119 used[i].store(true, Ordering::Relaxed);
120 return true;
121 }
122 }
123 false
124 }
125
126 #[must_use]
129 pub fn is_file_suppressed(&self, file_id: FileId, kind: IssueKind) -> bool {
130 let Some(supps) = self.by_file.get(&file_id) else {
131 return false;
132 };
133 let Some(used) = self.used.get(&file_id) else {
134 return false;
135 };
136 for (i, s) in supps.iter().enumerate() {
137 if s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)) {
138 used[i].store(true, Ordering::Relaxed);
139 return true;
140 }
141 }
142 false
143 }
144
145 pub fn get(&self, file_id: FileId) -> Option<&[Suppression]> {
147 self.by_file.get(&file_id).copied()
148 }
149
150 #[must_use]
152 pub fn used_count(&self) -> usize {
153 self.used
154 .values()
155 .flat_map(|used| used.iter())
156 .filter(|used| used.load(Ordering::Relaxed))
157 .count()
158 }
159
160 pub fn find_stale(&self, graph: &ModuleGraph) -> Vec<StaleSuppression> {
166 let mut stale = Vec::new();
167
168 for (&file_id, supps) in &self.by_file {
169 let used = &self.used[&file_id];
170 let path = &graph.modules[file_id.0 as usize].path;
171
172 for (i, s) in supps.iter().enumerate() {
173 if used[i].load(Ordering::Relaxed) {
174 continue;
175 }
176
177 if let Some(kind) = s.kind
181 && NON_CORE_KINDS.contains(&kind)
182 {
183 continue;
184 }
185
186 let is_file_level = s.line == 0;
187 let issue_kind_str = s.kind.map(|k| {
188 match k {
190 IssueKind::UnusedFile => "unused-file",
191 IssueKind::UnusedExport => "unused-export",
192 IssueKind::UnusedType => "unused-type",
193 IssueKind::PrivateTypeLeak => "private-type-leak",
194 IssueKind::UnusedDependency => "unused-dependency",
195 IssueKind::UnusedDevDependency => "unused-dev-dependency",
196 IssueKind::UnusedEnumMember => "unused-enum-member",
197 IssueKind::UnusedClassMember => "unused-class-member",
198 IssueKind::UnresolvedImport => "unresolved-import",
199 IssueKind::UnlistedDependency => "unlisted-dependency",
200 IssueKind::DuplicateExport => "duplicate-export",
201 IssueKind::CodeDuplication => "code-duplication",
202 IssueKind::CircularDependency => "circular-dependency",
203 IssueKind::TypeOnlyDependency => "type-only-dependency",
204 IssueKind::TestOnlyDependency => "test-only-dependency",
205 IssueKind::BoundaryViolation => "boundary-violation",
206 IssueKind::CoverageGaps => "coverage-gaps",
207 IssueKind::FeatureFlag => "feature-flag",
208 IssueKind::Complexity => "complexity",
209 IssueKind::StaleSuppression => "stale-suppression",
210 }
211 .to_string()
212 });
213
214 stale.push(StaleSuppression {
215 path: path.clone(),
216 line: s.comment_line,
217 col: 0,
218 origin: SuppressionOrigin::Comment {
219 issue_kind: issue_kind_str,
220 is_file_level,
221 },
222 });
223 }
224 }
225
226 stale
227 }
228}
229
230#[must_use]
235pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
236 suppressions.iter().any(|s| {
237 if s.line == 0 {
239 return s.kind.is_none() || s.kind == Some(kind);
240 }
241 s.line == line && (s.kind.is_none() || s.kind == Some(kind))
243 })
244}
245
246#[must_use]
250pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
251 suppressions
252 .iter()
253 .any(|s| s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)))
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259
260 #[test]
261 fn issue_kind_from_str_all_variants() {
262 assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
263 assert_eq!(
264 IssueKind::parse("unused-export"),
265 Some(IssueKind::UnusedExport)
266 );
267 assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
268 assert_eq!(
269 IssueKind::parse("unused-dependency"),
270 Some(IssueKind::UnusedDependency)
271 );
272 assert_eq!(
273 IssueKind::parse("unused-dev-dependency"),
274 Some(IssueKind::UnusedDevDependency)
275 );
276 assert_eq!(
277 IssueKind::parse("unused-enum-member"),
278 Some(IssueKind::UnusedEnumMember)
279 );
280 assert_eq!(
281 IssueKind::parse("unused-class-member"),
282 Some(IssueKind::UnusedClassMember)
283 );
284 assert_eq!(
285 IssueKind::parse("unresolved-import"),
286 Some(IssueKind::UnresolvedImport)
287 );
288 assert_eq!(
289 IssueKind::parse("unlisted-dependency"),
290 Some(IssueKind::UnlistedDependency)
291 );
292 assert_eq!(
293 IssueKind::parse("duplicate-export"),
294 Some(IssueKind::DuplicateExport)
295 );
296 }
297
298 #[test]
299 fn issue_kind_from_str_unknown() {
300 assert_eq!(IssueKind::parse("foo"), None);
301 assert_eq!(IssueKind::parse(""), None);
302 }
303
304 #[test]
305 fn discriminant_roundtrip() {
306 for kind in [
307 IssueKind::UnusedFile,
308 IssueKind::UnusedExport,
309 IssueKind::UnusedType,
310 IssueKind::PrivateTypeLeak,
311 IssueKind::UnusedDependency,
312 IssueKind::UnusedDevDependency,
313 IssueKind::UnusedEnumMember,
314 IssueKind::UnusedClassMember,
315 IssueKind::UnresolvedImport,
316 IssueKind::UnlistedDependency,
317 IssueKind::DuplicateExport,
318 IssueKind::CodeDuplication,
319 IssueKind::CircularDependency,
320 IssueKind::TestOnlyDependency,
321 IssueKind::BoundaryViolation,
322 IssueKind::CoverageGaps,
323 IssueKind::FeatureFlag,
324 IssueKind::Complexity,
325 IssueKind::StaleSuppression,
326 ] {
327 assert_eq!(
328 IssueKind::from_discriminant(kind.to_discriminant()),
329 Some(kind)
330 );
331 }
332 assert_eq!(IssueKind::from_discriminant(0), None);
333 assert_eq!(IssueKind::from_discriminant(21), None);
334 }
335
336 #[test]
337 fn parse_file_wide_suppression() {
338 let source = "// fallow-ignore-file\nexport const foo = 1;\n";
339 let suppressions = parse_suppressions_from_source(source);
340 assert_eq!(suppressions.len(), 1);
341 assert_eq!(suppressions[0].line, 0);
342 assert!(suppressions[0].kind.is_none());
343 }
344
345 #[test]
346 fn parse_file_wide_suppression_with_kind() {
347 let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
348 let suppressions = parse_suppressions_from_source(source);
349 assert_eq!(suppressions.len(), 1);
350 assert_eq!(suppressions[0].line, 0);
351 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
352 }
353
354 #[test]
355 fn parse_next_line_suppression() {
356 let source =
357 "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
358 let suppressions = parse_suppressions_from_source(source);
359 assert_eq!(suppressions.len(), 1);
360 assert_eq!(suppressions[0].line, 3); assert!(suppressions[0].kind.is_none());
362 }
363
364 #[test]
365 fn parse_next_line_suppression_with_kind() {
366 let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
367 let suppressions = parse_suppressions_from_source(source);
368 assert_eq!(suppressions.len(), 1);
369 assert_eq!(suppressions[0].line, 2);
370 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
371 }
372
373 #[test]
374 fn parse_unknown_kind_ignored() {
375 let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
376 let suppressions = parse_suppressions_from_source(source);
377 assert!(suppressions.is_empty());
378 }
379
380 #[test]
381 fn is_suppressed_file_wide() {
382 let suppressions = vec![Suppression {
383 line: 0,
384 comment_line: 1,
385 kind: None,
386 }];
387 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
388 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
389 }
390
391 #[test]
392 fn is_suppressed_file_wide_specific_kind() {
393 let suppressions = vec![Suppression {
394 line: 0,
395 comment_line: 1,
396 kind: Some(IssueKind::UnusedExport),
397 }];
398 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
399 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
400 }
401
402 #[test]
403 fn is_suppressed_line_specific() {
404 let suppressions = vec![Suppression {
405 line: 5,
406 comment_line: 4,
407 kind: None,
408 }];
409 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
410 assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
411 }
412
413 #[test]
414 fn is_suppressed_line_and_kind() {
415 let suppressions = vec![Suppression {
416 line: 5,
417 comment_line: 4,
418 kind: Some(IssueKind::UnusedExport),
419 }];
420 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
421 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
422 assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
423 }
424
425 #[test]
426 fn is_suppressed_empty() {
427 assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
428 }
429
430 #[test]
431 fn is_file_suppressed_works() {
432 let suppressions = vec![Suppression {
433 line: 0,
434 comment_line: 1,
435 kind: None,
436 }];
437 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
438
439 let suppressions = vec![Suppression {
440 line: 0,
441 comment_line: 1,
442 kind: Some(IssueKind::UnusedFile),
443 }];
444 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
445 assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
446
447 let suppressions = vec![Suppression {
449 line: 5,
450 comment_line: 4,
451 kind: None,
452 }];
453 assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
454 }
455
456 #[test]
457 fn parse_oxc_comments() {
458 use fallow_extract::suppress::parse_suppressions;
459 use oxc_allocator::Allocator;
460 use oxc_parser::Parser;
461 use oxc_span::SourceType;
462
463 let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
464 let allocator = Allocator::default();
465 let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
466
467 let suppressions = parse_suppressions(&parser_return.program.comments, source);
468 assert_eq!(suppressions.len(), 2);
469
470 assert_eq!(suppressions[0].line, 0);
472 assert!(suppressions[0].kind.is_none());
473
474 assert_eq!(suppressions[1].line, 3); assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedExport));
477 }
478
479 #[test]
480 fn parse_block_comment_suppression() {
481 let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
482 let suppressions = parse_suppressions_from_source(source);
483 assert_eq!(suppressions.len(), 1);
484 assert_eq!(suppressions[0].line, 0);
485 assert!(suppressions[0].kind.is_none());
486 }
487
488 #[test]
489 fn is_suppressed_multiple_suppressions_different_kinds() {
490 let suppressions = vec![
491 Suppression {
492 line: 5,
493 comment_line: 4,
494 kind: Some(IssueKind::UnusedExport),
495 },
496 Suppression {
497 line: 5,
498 comment_line: 4,
499 kind: Some(IssueKind::UnusedType),
500 },
501 ];
502 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
503 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
504 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
505 }
506
507 #[test]
508 fn is_suppressed_file_wide_blanket_and_specific_coexist() {
509 let suppressions = vec![
510 Suppression {
511 line: 0,
512 comment_line: 1,
513 kind: Some(IssueKind::UnusedExport),
514 },
515 Suppression {
516 line: 5,
517 comment_line: 4,
518 kind: None, },
520 ];
521 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
523 assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
524
525 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
527 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
528 }
529
530 #[test]
531 fn is_file_suppressed_blanket_suppresses_all_kinds() {
532 let suppressions = vec![Suppression {
533 line: 0,
534 comment_line: 1,
535 kind: None, }];
537 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
538 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
539 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
540 assert!(is_file_suppressed(
541 &suppressions,
542 IssueKind::CircularDependency
543 ));
544 assert!(is_file_suppressed(
545 &suppressions,
546 IssueKind::CodeDuplication
547 ));
548 }
549
550 #[test]
551 fn is_file_suppressed_empty_list() {
552 assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
553 }
554
555 #[test]
556 fn parse_multiple_next_line_suppressions() {
557 let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
558 let suppressions = parse_suppressions_from_source(source);
559 assert_eq!(suppressions.len(), 2);
560 assert_eq!(suppressions[0].line, 2);
561 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
562 assert_eq!(suppressions[1].line, 4);
563 assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedType));
564 }
565
566 #[test]
567 fn parse_code_duplication_suppression() {
568 let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
569 let suppressions = parse_suppressions_from_source(source);
570 assert_eq!(suppressions.len(), 1);
571 assert_eq!(suppressions[0].line, 0);
572 assert_eq!(suppressions[0].kind, Some(IssueKind::CodeDuplication));
573 }
574
575 #[test]
576 fn parse_circular_dependency_suppression() {
577 let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
578 let suppressions = parse_suppressions_from_source(source);
579 assert_eq!(suppressions.len(), 1);
580 assert_eq!(suppressions[0].line, 0);
581 assert_eq!(suppressions[0].kind, Some(IssueKind::CircularDependency));
582 }
583
584 #[test]
590 fn all_issue_kinds_classified_for_stale_detection() {
591 let core_kinds = [
593 IssueKind::UnusedFile,
594 IssueKind::UnusedExport,
595 IssueKind::UnusedType,
596 IssueKind::UnusedEnumMember,
597 IssueKind::UnusedClassMember,
598 IssueKind::UnresolvedImport,
599 IssueKind::DuplicateExport,
600 IssueKind::CircularDependency,
601 IssueKind::BoundaryViolation,
602 ];
603
604 let all_kinds = [
606 IssueKind::UnusedFile,
607 IssueKind::UnusedExport,
608 IssueKind::UnusedType,
609 IssueKind::UnusedDependency,
610 IssueKind::UnusedDevDependency,
611 IssueKind::UnusedEnumMember,
612 IssueKind::UnusedClassMember,
613 IssueKind::UnresolvedImport,
614 IssueKind::UnlistedDependency,
615 IssueKind::DuplicateExport,
616 IssueKind::CodeDuplication,
617 IssueKind::CircularDependency,
618 IssueKind::TypeOnlyDependency,
619 IssueKind::TestOnlyDependency,
620 IssueKind::BoundaryViolation,
621 IssueKind::CoverageGaps,
622 IssueKind::FeatureFlag,
623 IssueKind::Complexity,
624 IssueKind::StaleSuppression,
625 ];
626
627 for kind in all_kinds {
628 let in_core = core_kinds.contains(&kind);
629 let in_non_core = NON_CORE_KINDS.contains(&kind);
630 assert!(
631 in_core || in_non_core,
632 "IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
633 Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
634 or to core_kinds in this test if a core detector checks it."
635 );
636 assert!(
637 !(in_core && in_non_core),
638 "IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
639 );
640 }
641 }
642}