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