Skip to main content

fallow_types/
suppress.rs

1//! Inline suppression comment types and issue kind definitions.
2
3/// Issue kind for suppression matching.
4///
5/// # Examples
6///
7/// ```
8/// use fallow_types::suppress::IssueKind;
9///
10/// let kind = IssueKind::parse("unused-export");
11/// assert_eq!(kind, Some(IssueKind::UnusedExport));
12///
13/// // Round-trip through discriminant
14/// let d = IssueKind::UnusedFile.to_discriminant();
15/// assert_eq!(IssueKind::from_discriminant(d), Some(IssueKind::UnusedFile));
16///
17/// // Unknown strings return None
18/// assert_eq!(IssueKind::parse("not-a-kind"), None);
19/// ```
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum IssueKind {
22    /// An unused file.
23    UnusedFile,
24    /// An unused export.
25    UnusedExport,
26    /// An unused type export.
27    UnusedType,
28    /// An exported signature that references a same-file private type.
29    PrivateTypeLeak,
30    /// An unused dependency.
31    UnusedDependency,
32    /// An unused dev dependency.
33    UnusedDevDependency,
34    /// An unused enum member.
35    UnusedEnumMember,
36    /// An unused class member.
37    UnusedClassMember,
38    /// An unresolved import.
39    UnresolvedImport,
40    /// An unlisted dependency.
41    UnlistedDependency,
42    /// A duplicate export name across modules.
43    DuplicateExport,
44    /// Code duplication.
45    CodeDuplication,
46    /// A circular dependency chain.
47    CircularDependency,
48    /// A production dependency only imported via type-only imports.
49    TypeOnlyDependency,
50    /// A production dependency only imported by test files.
51    TestOnlyDependency,
52    /// An import that crosses an architecture boundary.
53    BoundaryViolation,
54    /// A runtime file or export with no test dependency path.
55    CoverageGaps,
56    /// A detected feature flag pattern.
57    FeatureFlag,
58    /// A function exceeding complexity thresholds (health command).
59    Complexity,
60    /// A suppression comment or JSDoc tag that no longer matches any issue.
61    StaleSuppression,
62    /// A pnpm catalog entry in pnpm-workspace.yaml not referenced by any workspace package.
63    PnpmCatalogEntry,
64    /// A workspace package.json reference (`catalog:` / `catalog:<name>`) pointing at
65    /// a catalog that does not declare the consumed package.
66    UnresolvedCatalogReference,
67}
68
69impl IssueKind {
70    /// Parse an issue kind from the string tokens used in CLI output and suppression comments.
71    #[must_use]
72    pub fn parse(s: &str) -> Option<Self> {
73        match s {
74            "unused-file" => Some(Self::UnusedFile),
75            "unused-export" => Some(Self::UnusedExport),
76            "unused-type" => Some(Self::UnusedType),
77            "private-type-leak" => Some(Self::PrivateTypeLeak),
78            "unused-dependency" => Some(Self::UnusedDependency),
79            "unused-dev-dependency" => Some(Self::UnusedDevDependency),
80            "unused-enum-member" => Some(Self::UnusedEnumMember),
81            "unused-class-member" => Some(Self::UnusedClassMember),
82            "unresolved-import" => Some(Self::UnresolvedImport),
83            "unlisted-dependency" => Some(Self::UnlistedDependency),
84            "duplicate-export" => Some(Self::DuplicateExport),
85            "code-duplication" => Some(Self::CodeDuplication),
86            "circular-dependency" | "circular-dependencies" => Some(Self::CircularDependency),
87            "type-only-dependency" => Some(Self::TypeOnlyDependency),
88            "test-only-dependency" => Some(Self::TestOnlyDependency),
89            "boundary-violation" => Some(Self::BoundaryViolation),
90            "coverage-gaps" => Some(Self::CoverageGaps),
91            "feature-flag" => Some(Self::FeatureFlag),
92            "complexity" => Some(Self::Complexity),
93            "stale-suppression" => Some(Self::StaleSuppression),
94            "unused-catalog-entry" | "unused-catalog-entries" => Some(Self::PnpmCatalogEntry),
95            "unresolved-catalog-reference" | "unresolved-catalog-references" => {
96                Some(Self::UnresolvedCatalogReference)
97            }
98            _ => None,
99        }
100    }
101
102    /// Convert to a u8 discriminant for compact cache storage.
103    #[must_use]
104    pub const fn to_discriminant(self) -> u8 {
105        match self {
106            Self::UnusedFile => 1,
107            Self::UnusedExport => 2,
108            Self::UnusedType => 3,
109            Self::PrivateTypeLeak => 4,
110            Self::UnusedDependency => 5,
111            Self::UnusedDevDependency => 6,
112            Self::UnusedEnumMember => 7,
113            Self::UnusedClassMember => 8,
114            Self::UnresolvedImport => 9,
115            Self::UnlistedDependency => 10,
116            Self::DuplicateExport => 11,
117            Self::CodeDuplication => 12,
118            Self::CircularDependency => 13,
119            Self::TypeOnlyDependency => 14,
120            Self::TestOnlyDependency => 15,
121            Self::BoundaryViolation => 16,
122            Self::CoverageGaps => 17,
123            Self::FeatureFlag => 18,
124            Self::Complexity => 19,
125            Self::StaleSuppression => 20,
126            Self::PnpmCatalogEntry => 21,
127            Self::UnresolvedCatalogReference => 22,
128        }
129    }
130
131    /// Reconstruct from a cache discriminant.
132    #[must_use]
133    pub const fn from_discriminant(d: u8) -> Option<Self> {
134        match d {
135            1 => Some(Self::UnusedFile),
136            2 => Some(Self::UnusedExport),
137            3 => Some(Self::UnusedType),
138            4 => Some(Self::PrivateTypeLeak),
139            5 => Some(Self::UnusedDependency),
140            6 => Some(Self::UnusedDevDependency),
141            7 => Some(Self::UnusedEnumMember),
142            8 => Some(Self::UnusedClassMember),
143            9 => Some(Self::UnresolvedImport),
144            10 => Some(Self::UnlistedDependency),
145            11 => Some(Self::DuplicateExport),
146            12 => Some(Self::CodeDuplication),
147            13 => Some(Self::CircularDependency),
148            14 => Some(Self::TypeOnlyDependency),
149            15 => Some(Self::TestOnlyDependency),
150            16 => Some(Self::BoundaryViolation),
151            17 => Some(Self::CoverageGaps),
152            18 => Some(Self::FeatureFlag),
153            19 => Some(Self::Complexity),
154            20 => Some(Self::StaleSuppression),
155            21 => Some(Self::PnpmCatalogEntry),
156            22 => Some(Self::UnresolvedCatalogReference),
157            _ => None,
158        }
159    }
160}
161
162/// A suppression directive parsed from a source comment.
163///
164/// # Examples
165///
166/// ```
167/// use fallow_types::suppress::{Suppression, IssueKind};
168///
169/// // File-wide suppression (line 0, no specific kind)
170/// let file_wide = Suppression { line: 0, comment_line: 1, kind: None };
171/// assert_eq!(file_wide.line, 0);
172///
173/// // Line-specific suppression for unused exports
174/// let line_suppress = Suppression {
175///     line: 42,
176///     comment_line: 41,
177///     kind: Some(IssueKind::UnusedExport),
178/// };
179/// assert_eq!(line_suppress.kind, Some(IssueKind::UnusedExport));
180/// ```
181#[derive(Debug, Clone)]
182pub struct Suppression {
183    /// 1-based line this suppression applies to. 0 = file-wide suppression.
184    pub line: u32,
185    /// 1-based line where the suppression comment itself appears.
186    /// For `fallow-ignore-next-line`, this is `line - 1`.
187    /// For `fallow-ignore-file`, this is the actual line of the comment in the source.
188    pub comment_line: u32,
189    /// None = suppress all issue kinds on this line.
190    pub kind: Option<IssueKind>,
191}
192
193// Size assertions to prevent memory regressions.
194// `Suppression` is stored in a Vec per file; `IssueKind` appears in every suppression.
195const _: () = assert!(std::mem::size_of::<Suppression>() == 12);
196const _: () = assert!(std::mem::size_of::<IssueKind>() == 1);
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn issue_kind_from_str_all_variants() {
204        assert_eq!(IssueKind::parse("unused-file"), Some(IssueKind::UnusedFile));
205        assert_eq!(
206            IssueKind::parse("unused-export"),
207            Some(IssueKind::UnusedExport)
208        );
209        assert_eq!(IssueKind::parse("unused-type"), Some(IssueKind::UnusedType));
210        assert_eq!(
211            IssueKind::parse("private-type-leak"),
212            Some(IssueKind::PrivateTypeLeak)
213        );
214        assert_eq!(
215            IssueKind::parse("unused-dependency"),
216            Some(IssueKind::UnusedDependency)
217        );
218        assert_eq!(
219            IssueKind::parse("unused-dev-dependency"),
220            Some(IssueKind::UnusedDevDependency)
221        );
222        assert_eq!(
223            IssueKind::parse("unused-enum-member"),
224            Some(IssueKind::UnusedEnumMember)
225        );
226        assert_eq!(
227            IssueKind::parse("unused-class-member"),
228            Some(IssueKind::UnusedClassMember)
229        );
230        assert_eq!(
231            IssueKind::parse("unresolved-import"),
232            Some(IssueKind::UnresolvedImport)
233        );
234        assert_eq!(
235            IssueKind::parse("unlisted-dependency"),
236            Some(IssueKind::UnlistedDependency)
237        );
238        assert_eq!(
239            IssueKind::parse("duplicate-export"),
240            Some(IssueKind::DuplicateExport)
241        );
242        assert_eq!(
243            IssueKind::parse("code-duplication"),
244            Some(IssueKind::CodeDuplication)
245        );
246        assert_eq!(
247            IssueKind::parse("circular-dependency"),
248            Some(IssueKind::CircularDependency)
249        );
250        assert_eq!(
251            IssueKind::parse("circular-dependencies"),
252            Some(IssueKind::CircularDependency)
253        );
254        assert_eq!(
255            IssueKind::parse("type-only-dependency"),
256            Some(IssueKind::TypeOnlyDependency)
257        );
258        assert_eq!(
259            IssueKind::parse("test-only-dependency"),
260            Some(IssueKind::TestOnlyDependency)
261        );
262        assert_eq!(
263            IssueKind::parse("boundary-violation"),
264            Some(IssueKind::BoundaryViolation)
265        );
266        assert_eq!(
267            IssueKind::parse("coverage-gaps"),
268            Some(IssueKind::CoverageGaps)
269        );
270        assert_eq!(
271            IssueKind::parse("feature-flag"),
272            Some(IssueKind::FeatureFlag)
273        );
274        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
275        assert_eq!(
276            IssueKind::parse("stale-suppression"),
277            Some(IssueKind::StaleSuppression)
278        );
279        assert_eq!(
280            IssueKind::parse("unused-catalog-entry"),
281            Some(IssueKind::PnpmCatalogEntry)
282        );
283        assert_eq!(
284            IssueKind::parse("unused-catalog-entries"),
285            Some(IssueKind::PnpmCatalogEntry)
286        );
287        assert_eq!(
288            IssueKind::parse("unresolved-catalog-reference"),
289            Some(IssueKind::UnresolvedCatalogReference)
290        );
291        assert_eq!(
292            IssueKind::parse("unresolved-catalog-references"),
293            Some(IssueKind::UnresolvedCatalogReference)
294        );
295    }
296
297    #[test]
298    fn issue_kind_from_str_unknown() {
299        assert_eq!(IssueKind::parse("foo"), None);
300        assert_eq!(IssueKind::parse(""), None);
301    }
302
303    #[test]
304    fn issue_kind_from_str_near_misses() {
305        // Case sensitivity — these should NOT match
306        assert_eq!(IssueKind::parse("Unused-File"), None);
307        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
308        // Typos / near-misses
309        assert_eq!(IssueKind::parse("unused_file"), None);
310        assert_eq!(IssueKind::parse("unused-files"), None);
311    }
312
313    #[test]
314    fn discriminant_out_of_range() {
315        assert_eq!(IssueKind::from_discriminant(0), None);
316        assert_eq!(IssueKind::from_discriminant(23), None);
317        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
318    }
319
320    #[test]
321    fn discriminant_roundtrip() {
322        for kind in [
323            IssueKind::UnusedFile,
324            IssueKind::UnusedExport,
325            IssueKind::UnusedType,
326            IssueKind::PrivateTypeLeak,
327            IssueKind::UnusedDependency,
328            IssueKind::UnusedDevDependency,
329            IssueKind::UnusedEnumMember,
330            IssueKind::UnusedClassMember,
331            IssueKind::UnresolvedImport,
332            IssueKind::UnlistedDependency,
333            IssueKind::DuplicateExport,
334            IssueKind::CodeDuplication,
335            IssueKind::CircularDependency,
336            IssueKind::TypeOnlyDependency,
337            IssueKind::TestOnlyDependency,
338            IssueKind::BoundaryViolation,
339            IssueKind::CoverageGaps,
340            IssueKind::FeatureFlag,
341            IssueKind::Complexity,
342            IssueKind::StaleSuppression,
343            IssueKind::PnpmCatalogEntry,
344            IssueKind::UnresolvedCatalogReference,
345        ] {
346            assert_eq!(
347                IssueKind::from_discriminant(kind.to_discriminant()),
348                Some(kind)
349            );
350        }
351        assert_eq!(IssueKind::from_discriminant(0), None);
352        assert_eq!(IssueKind::from_discriminant(23), None);
353    }
354
355    // ── Discriminant uniqueness ─────────────────────────────────
356
357    #[test]
358    fn discriminant_values_are_unique() {
359        let all_kinds = [
360            IssueKind::UnusedFile,
361            IssueKind::UnusedExport,
362            IssueKind::UnusedType,
363            IssueKind::PrivateTypeLeak,
364            IssueKind::UnusedDependency,
365            IssueKind::UnusedDevDependency,
366            IssueKind::UnusedEnumMember,
367            IssueKind::UnusedClassMember,
368            IssueKind::UnresolvedImport,
369            IssueKind::UnlistedDependency,
370            IssueKind::DuplicateExport,
371            IssueKind::CodeDuplication,
372            IssueKind::CircularDependency,
373            IssueKind::TypeOnlyDependency,
374            IssueKind::TestOnlyDependency,
375            IssueKind::BoundaryViolation,
376            IssueKind::CoverageGaps,
377            IssueKind::FeatureFlag,
378            IssueKind::Complexity,
379            IssueKind::StaleSuppression,
380            IssueKind::PnpmCatalogEntry,
381            IssueKind::UnresolvedCatalogReference,
382        ];
383        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
384        let mut sorted = discriminants.clone();
385        sorted.sort_unstable();
386        sorted.dedup();
387        assert_eq!(
388            discriminants.len(),
389            sorted.len(),
390            "discriminant values must be unique"
391        );
392    }
393
394    // ── Discriminant starts at 1 ────────────────────────────────
395
396    #[test]
397    fn discriminant_starts_at_one() {
398        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
399    }
400
401    // ── Suppression struct ──────────────────────────────────────
402
403    #[test]
404    fn suppression_line_zero_is_file_wide() {
405        let s = Suppression {
406            line: 0,
407            comment_line: 1,
408            kind: None,
409        };
410        assert_eq!(s.line, 0);
411        assert!(s.kind.is_none());
412    }
413
414    #[test]
415    fn suppression_with_specific_kind_and_line() {
416        let s = Suppression {
417            line: 42,
418            comment_line: 41,
419            kind: Some(IssueKind::UnusedExport),
420        };
421        assert_eq!(s.line, 42);
422        assert_eq!(s.comment_line, 41);
423        assert_eq!(s.kind, Some(IssueKind::UnusedExport));
424    }
425}