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