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