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" | "circular-dependencies" => 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("circular-dependencies"),
239            Some(IssueKind::CircularDependency)
240        );
241        assert_eq!(
242            IssueKind::parse("type-only-dependency"),
243            Some(IssueKind::TypeOnlyDependency)
244        );
245        assert_eq!(
246            IssueKind::parse("test-only-dependency"),
247            Some(IssueKind::TestOnlyDependency)
248        );
249        assert_eq!(
250            IssueKind::parse("boundary-violation"),
251            Some(IssueKind::BoundaryViolation)
252        );
253        assert_eq!(
254            IssueKind::parse("coverage-gaps"),
255            Some(IssueKind::CoverageGaps)
256        );
257        assert_eq!(
258            IssueKind::parse("feature-flag"),
259            Some(IssueKind::FeatureFlag)
260        );
261        assert_eq!(IssueKind::parse("complexity"), Some(IssueKind::Complexity));
262        assert_eq!(
263            IssueKind::parse("stale-suppression"),
264            Some(IssueKind::StaleSuppression)
265        );
266    }
267
268    #[test]
269    fn issue_kind_from_str_unknown() {
270        assert_eq!(IssueKind::parse("foo"), None);
271        assert_eq!(IssueKind::parse(""), None);
272    }
273
274    #[test]
275    fn issue_kind_from_str_near_misses() {
276        // Case sensitivity — these should NOT match
277        assert_eq!(IssueKind::parse("Unused-File"), None);
278        assert_eq!(IssueKind::parse("UNUSED-EXPORT"), None);
279        // Typos / near-misses
280        assert_eq!(IssueKind::parse("unused_file"), None);
281        assert_eq!(IssueKind::parse("unused-files"), None);
282    }
283
284    #[test]
285    fn discriminant_out_of_range() {
286        assert_eq!(IssueKind::from_discriminant(0), None);
287        assert_eq!(IssueKind::from_discriminant(21), None);
288        assert_eq!(IssueKind::from_discriminant(u8::MAX), None);
289    }
290
291    #[test]
292    fn discriminant_roundtrip() {
293        for kind in [
294            IssueKind::UnusedFile,
295            IssueKind::UnusedExport,
296            IssueKind::UnusedType,
297            IssueKind::PrivateTypeLeak,
298            IssueKind::UnusedDependency,
299            IssueKind::UnusedDevDependency,
300            IssueKind::UnusedEnumMember,
301            IssueKind::UnusedClassMember,
302            IssueKind::UnresolvedImport,
303            IssueKind::UnlistedDependency,
304            IssueKind::DuplicateExport,
305            IssueKind::CodeDuplication,
306            IssueKind::CircularDependency,
307            IssueKind::TypeOnlyDependency,
308            IssueKind::TestOnlyDependency,
309            IssueKind::BoundaryViolation,
310            IssueKind::CoverageGaps,
311            IssueKind::FeatureFlag,
312            IssueKind::Complexity,
313            IssueKind::StaleSuppression,
314        ] {
315            assert_eq!(
316                IssueKind::from_discriminant(kind.to_discriminant()),
317                Some(kind)
318            );
319        }
320        assert_eq!(IssueKind::from_discriminant(0), None);
321        assert_eq!(IssueKind::from_discriminant(21), None);
322    }
323
324    // ── Discriminant uniqueness ─────────────────────────────────
325
326    #[test]
327    fn discriminant_values_are_unique() {
328        let all_kinds = [
329            IssueKind::UnusedFile,
330            IssueKind::UnusedExport,
331            IssueKind::UnusedType,
332            IssueKind::PrivateTypeLeak,
333            IssueKind::UnusedDependency,
334            IssueKind::UnusedDevDependency,
335            IssueKind::UnusedEnumMember,
336            IssueKind::UnusedClassMember,
337            IssueKind::UnresolvedImport,
338            IssueKind::UnlistedDependency,
339            IssueKind::DuplicateExport,
340            IssueKind::CodeDuplication,
341            IssueKind::CircularDependency,
342            IssueKind::TypeOnlyDependency,
343            IssueKind::TestOnlyDependency,
344            IssueKind::BoundaryViolation,
345            IssueKind::CoverageGaps,
346            IssueKind::FeatureFlag,
347            IssueKind::Complexity,
348            IssueKind::StaleSuppression,
349        ];
350        let discriminants: Vec<u8> = all_kinds.iter().map(|k| k.to_discriminant()).collect();
351        let mut sorted = discriminants.clone();
352        sorted.sort_unstable();
353        sorted.dedup();
354        assert_eq!(
355            discriminants.len(),
356            sorted.len(),
357            "discriminant values must be unique"
358        );
359    }
360
361    // ── Discriminant starts at 1 ────────────────────────────────
362
363    #[test]
364    fn discriminant_starts_at_one() {
365        assert_eq!(IssueKind::UnusedFile.to_discriminant(), 1);
366    }
367
368    // ── Suppression struct ──────────────────────────────────────
369
370    #[test]
371    fn suppression_line_zero_is_file_wide() {
372        let s = Suppression {
373            line: 0,
374            comment_line: 1,
375            kind: None,
376        };
377        assert_eq!(s.line, 0);
378        assert!(s.kind.is_none());
379    }
380
381    #[test]
382    fn suppression_with_specific_kind_and_line() {
383        let s = Suppression {
384            line: 42,
385            comment_line: 41,
386            kind: Some(IssueKind::UnusedExport),
387        };
388        assert_eq!(s.line, 42);
389        assert_eq!(s.comment_line, 41);
390        assert_eq!(s.kind, Some(IssueKind::UnusedExport));
391    }
392}