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