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