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