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