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