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 IssueKind::PnpmCatalogEntry => "unused-catalog-entry",
211 IssueKind::UnresolvedCatalogReference => "unresolved-catalog-reference",
212 }
213 .to_string()
214 });
215
216 stale.push(StaleSuppression {
217 path: path.clone(),
218 line: s.comment_line,
219 col: 0,
220 origin: SuppressionOrigin::Comment {
221 issue_kind: issue_kind_str,
222 is_file_level,
223 },
224 });
225 }
226 }
227
228 stale
229 }
230}
231
232#[must_use]
237pub fn is_suppressed(suppressions: &[Suppression], line: u32, kind: IssueKind) -> bool {
238 suppressions.iter().any(|s| {
239 if s.line == 0 {
241 return s.kind.is_none() || s.kind == Some(kind);
242 }
243 s.line == line && (s.kind.is_none() || s.kind == Some(kind))
245 })
246}
247
248#[must_use]
252pub fn is_file_suppressed(suppressions: &[Suppression], kind: IssueKind) -> bool {
253 suppressions
254 .iter()
255 .any(|s| s.line == 0 && (s.kind.is_none() || s.kind == Some(kind)))
256}
257
258#[cfg(test)]
259mod tests {
260 use super::*;
261
262 #[test]
263 fn issue_kind_from_str_all_variants() {
264 assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
265 assert_eq!(
266 IssueKind::parse("unused-export"),
267 Some(IssueKind::UnusedExport)
268 );
269 assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
270 assert_eq!(
271 IssueKind::parse("unused-dependency"),
272 Some(IssueKind::UnusedDependency)
273 );
274 assert_eq!(
275 IssueKind::parse("unused-dev-dependency"),
276 Some(IssueKind::UnusedDevDependency)
277 );
278 assert_eq!(
279 IssueKind::parse("unused-enum-member"),
280 Some(IssueKind::UnusedEnumMember)
281 );
282 assert_eq!(
283 IssueKind::parse("unused-class-member"),
284 Some(IssueKind::UnusedClassMember)
285 );
286 assert_eq!(
287 IssueKind::parse("unresolved-import"),
288 Some(IssueKind::UnresolvedImport)
289 );
290 assert_eq!(
291 IssueKind::parse("unlisted-dependency"),
292 Some(IssueKind::UnlistedDependency)
293 );
294 assert_eq!(
295 IssueKind::parse("duplicate-export"),
296 Some(IssueKind::DuplicateExport)
297 );
298 }
299
300 #[test]
301 fn issue_kind_from_str_unknown() {
302 assert_eq!(IssueKind::parse("foo"), None);
303 assert_eq!(IssueKind::parse(""), None);
304 }
305
306 #[test]
307 fn discriminant_roundtrip() {
308 for kind in [
309 IssueKind::UnusedFile,
310 IssueKind::UnusedExport,
311 IssueKind::UnusedType,
312 IssueKind::PrivateTypeLeak,
313 IssueKind::UnusedDependency,
314 IssueKind::UnusedDevDependency,
315 IssueKind::UnusedEnumMember,
316 IssueKind::UnusedClassMember,
317 IssueKind::UnresolvedImport,
318 IssueKind::UnlistedDependency,
319 IssueKind::DuplicateExport,
320 IssueKind::CodeDuplication,
321 IssueKind::CircularDependency,
322 IssueKind::TestOnlyDependency,
323 IssueKind::BoundaryViolation,
324 IssueKind::CoverageGaps,
325 IssueKind::FeatureFlag,
326 IssueKind::Complexity,
327 IssueKind::StaleSuppression,
328 IssueKind::PnpmCatalogEntry,
329 IssueKind::UnresolvedCatalogReference,
330 ] {
331 assert_eq!(
332 IssueKind::from_discriminant(kind.to_discriminant()),
333 Some(kind)
334 );
335 }
336 assert_eq!(IssueKind::from_discriminant(0), None);
337 assert_eq!(IssueKind::from_discriminant(23), None);
338 }
339
340 #[test]
341 fn parse_file_wide_suppression() {
342 let source = "// fallow-ignore-file\nexport const foo = 1;\n";
343 let suppressions = parse_suppressions_from_source(source);
344 assert_eq!(suppressions.len(), 1);
345 assert_eq!(suppressions[0].line, 0);
346 assert!(suppressions[0].kind.is_none());
347 }
348
349 #[test]
350 fn parse_file_wide_suppression_with_kind() {
351 let source = "// fallow-ignore-file unused-export\nexport const foo = 1;\n";
352 let suppressions = parse_suppressions_from_source(source);
353 assert_eq!(suppressions.len(), 1);
354 assert_eq!(suppressions[0].line, 0);
355 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
356 }
357
358 #[test]
359 fn parse_next_line_suppression() {
360 let source =
361 "import { x } from './x';\n// fallow-ignore-next-line\nexport const foo = 1;\n";
362 let suppressions = parse_suppressions_from_source(source);
363 assert_eq!(suppressions.len(), 1);
364 assert_eq!(suppressions[0].line, 3); assert!(suppressions[0].kind.is_none());
366 }
367
368 #[test]
369 fn parse_next_line_suppression_with_kind() {
370 let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n";
371 let suppressions = parse_suppressions_from_source(source);
372 assert_eq!(suppressions.len(), 1);
373 assert_eq!(suppressions[0].line, 2);
374 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
375 }
376
377 #[test]
378 fn parse_unknown_kind_ignored() {
379 let source = "// fallow-ignore-next-line typo-kind\nexport const foo = 1;\n";
380 let suppressions = parse_suppressions_from_source(source);
381 assert!(suppressions.is_empty());
382 }
383
384 #[test]
385 fn is_suppressed_file_wide() {
386 let suppressions = vec![Suppression {
387 line: 0,
388 comment_line: 1,
389 kind: None,
390 }];
391 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
392 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedFile));
393 }
394
395 #[test]
396 fn is_suppressed_file_wide_specific_kind() {
397 let suppressions = vec![Suppression {
398 line: 0,
399 comment_line: 1,
400 kind: Some(IssueKind::UnusedExport),
401 }];
402 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
403 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
404 }
405
406 #[test]
407 fn is_suppressed_line_specific() {
408 let suppressions = vec![Suppression {
409 line: 5,
410 comment_line: 4,
411 kind: None,
412 }];
413 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
414 assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
415 }
416
417 #[test]
418 fn is_suppressed_line_and_kind() {
419 let suppressions = vec![Suppression {
420 line: 5,
421 comment_line: 4,
422 kind: Some(IssueKind::UnusedExport),
423 }];
424 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
425 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedType));
426 assert!(!is_suppressed(&suppressions, 6, IssueKind::UnusedExport));
427 }
428
429 #[test]
430 fn is_suppressed_empty() {
431 assert!(!is_suppressed(&[], 5, IssueKind::UnusedExport));
432 }
433
434 #[test]
435 fn is_file_suppressed_works() {
436 let suppressions = vec![Suppression {
437 line: 0,
438 comment_line: 1,
439 kind: None,
440 }];
441 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
442
443 let suppressions = vec![Suppression {
444 line: 0,
445 comment_line: 1,
446 kind: Some(IssueKind::UnusedFile),
447 }];
448 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
449 assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedExport));
450
451 let suppressions = vec![Suppression {
453 line: 5,
454 comment_line: 4,
455 kind: None,
456 }];
457 assert!(!is_file_suppressed(&suppressions, IssueKind::UnusedFile));
458 }
459
460 #[test]
461 fn parse_oxc_comments() {
462 use fallow_extract::suppress::parse_suppressions;
463 use oxc_allocator::Allocator;
464 use oxc_parser::Parser;
465 use oxc_span::SourceType;
466
467 let source = "// fallow-ignore-file\n// fallow-ignore-next-line unused-export\nexport const foo = 1;\nexport const bar = 2;\n";
468 let allocator = Allocator::default();
469 let parser_return = Parser::new(&allocator, source, SourceType::mjs()).parse();
470
471 let suppressions = parse_suppressions(&parser_return.program.comments, source);
472 assert_eq!(suppressions.len(), 2);
473
474 assert_eq!(suppressions[0].line, 0);
476 assert!(suppressions[0].kind.is_none());
477
478 assert_eq!(suppressions[1].line, 3); assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedExport));
481 }
482
483 #[test]
484 fn parse_block_comment_suppression() {
485 let source = "/* fallow-ignore-file */\nexport const foo = 1;\n";
486 let suppressions = parse_suppressions_from_source(source);
487 assert_eq!(suppressions.len(), 1);
488 assert_eq!(suppressions[0].line, 0);
489 assert!(suppressions[0].kind.is_none());
490 }
491
492 #[test]
493 fn is_suppressed_multiple_suppressions_different_kinds() {
494 let suppressions = vec![
495 Suppression {
496 line: 5,
497 comment_line: 4,
498 kind: Some(IssueKind::UnusedExport),
499 },
500 Suppression {
501 line: 5,
502 comment_line: 4,
503 kind: Some(IssueKind::UnusedType),
504 },
505 ];
506 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
507 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
508 assert!(!is_suppressed(&suppressions, 5, IssueKind::UnusedFile));
509 }
510
511 #[test]
512 fn is_suppressed_file_wide_blanket_and_specific_coexist() {
513 let suppressions = vec![
514 Suppression {
515 line: 0,
516 comment_line: 1,
517 kind: Some(IssueKind::UnusedExport),
518 },
519 Suppression {
520 line: 5,
521 comment_line: 4,
522 kind: None, },
524 ];
525 assert!(is_suppressed(&suppressions, 10, IssueKind::UnusedExport));
527 assert!(!is_suppressed(&suppressions, 10, IssueKind::UnusedType));
528
529 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedType));
531 assert!(is_suppressed(&suppressions, 5, IssueKind::UnusedExport));
532 }
533
534 #[test]
535 fn is_file_suppressed_blanket_suppresses_all_kinds() {
536 let suppressions = vec![Suppression {
537 line: 0,
538 comment_line: 1,
539 kind: None, }];
541 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedFile));
542 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedExport));
543 assert!(is_file_suppressed(&suppressions, IssueKind::UnusedType));
544 assert!(is_file_suppressed(
545 &suppressions,
546 IssueKind::CircularDependency
547 ));
548 assert!(is_file_suppressed(
549 &suppressions,
550 IssueKind::CodeDuplication
551 ));
552 }
553
554 #[test]
555 fn is_file_suppressed_empty_list() {
556 assert!(!is_file_suppressed(&[], IssueKind::UnusedFile));
557 }
558
559 #[test]
560 fn parse_multiple_next_line_suppressions() {
561 let source = "// fallow-ignore-next-line unused-export\nexport const foo = 1;\n// fallow-ignore-next-line unused-type\nexport type Bar = string;\n";
562 let suppressions = parse_suppressions_from_source(source);
563 assert_eq!(suppressions.len(), 2);
564 assert_eq!(suppressions[0].line, 2);
565 assert_eq!(suppressions[0].kind, Some(IssueKind::UnusedExport));
566 assert_eq!(suppressions[1].line, 4);
567 assert_eq!(suppressions[1].kind, Some(IssueKind::UnusedType));
568 }
569
570 #[test]
571 fn parse_code_duplication_suppression() {
572 let source = "// fallow-ignore-file code-duplication\nexport const foo = 1;\n";
573 let suppressions = parse_suppressions_from_source(source);
574 assert_eq!(suppressions.len(), 1);
575 assert_eq!(suppressions[0].line, 0);
576 assert_eq!(suppressions[0].kind, Some(IssueKind::CodeDuplication));
577 }
578
579 #[test]
580 fn parse_circular_dependency_suppression() {
581 let source = "// fallow-ignore-file circular-dependency\nimport { x } from './x';\n";
582 let suppressions = parse_suppressions_from_source(source);
583 assert_eq!(suppressions.len(), 1);
584 assert_eq!(suppressions[0].line, 0);
585 assert_eq!(suppressions[0].kind, Some(IssueKind::CircularDependency));
586 }
587
588 #[test]
594 fn all_issue_kinds_classified_for_stale_detection() {
595 let core_kinds = [
597 IssueKind::UnusedFile,
598 IssueKind::UnusedExport,
599 IssueKind::UnusedType,
600 IssueKind::UnusedEnumMember,
601 IssueKind::UnusedClassMember,
602 IssueKind::UnresolvedImport,
603 IssueKind::DuplicateExport,
604 IssueKind::CircularDependency,
605 IssueKind::BoundaryViolation,
606 ];
607
608 let all_kinds = [
610 IssueKind::UnusedFile,
611 IssueKind::UnusedExport,
612 IssueKind::UnusedType,
613 IssueKind::UnusedDependency,
614 IssueKind::UnusedDevDependency,
615 IssueKind::UnusedEnumMember,
616 IssueKind::UnusedClassMember,
617 IssueKind::UnresolvedImport,
618 IssueKind::UnlistedDependency,
619 IssueKind::DuplicateExport,
620 IssueKind::CodeDuplication,
621 IssueKind::CircularDependency,
622 IssueKind::TypeOnlyDependency,
623 IssueKind::TestOnlyDependency,
624 IssueKind::BoundaryViolation,
625 IssueKind::CoverageGaps,
626 IssueKind::FeatureFlag,
627 IssueKind::Complexity,
628 IssueKind::StaleSuppression,
629 ];
630
631 for kind in all_kinds {
632 let in_core = core_kinds.contains(&kind);
633 let in_non_core = NON_CORE_KINDS.contains(&kind);
634 assert!(
635 in_core || in_non_core,
636 "IssueKind::{kind:?} is not classified in either core_kinds or NON_CORE_KINDS. \
637 Add it to NON_CORE_KINDS if it is checked outside find_dead_code_full, \
638 or to core_kinds in this test if a core detector checks it."
639 );
640 assert!(
641 !(in_core && in_non_core),
642 "IssueKind::{kind:?} is in BOTH core_kinds and NON_CORE_KINDS. Pick one."
643 );
644 }
645 }
646}