Skip to main content

fallow_types/
results.rs

1//! Analysis result types for all issue categories.
2
3use std::path::PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::extract::MemberKind;
8use crate::serde_path;
9
10/// Complete analysis results.
11///
12/// # Examples
13///
14/// ```
15/// use fallow_types::results::{AnalysisResults, UnusedFile};
16/// use std::path::PathBuf;
17///
18/// let mut results = AnalysisResults::default();
19/// assert_eq!(results.total_issues(), 0);
20/// assert!(!results.has_issues());
21///
22/// results.unused_files.push(UnusedFile {
23///     path: PathBuf::from("src/dead.ts"),
24/// });
25/// assert_eq!(results.total_issues(), 1);
26/// assert!(results.has_issues());
27/// ```
28#[derive(Debug, Default, Clone, Serialize)]
29pub struct AnalysisResults {
30    /// Files not reachable from any entry point.
31    pub unused_files: Vec<UnusedFile>,
32    /// Exports never imported by other modules.
33    pub unused_exports: Vec<UnusedExport>,
34    /// Type exports never imported by other modules.
35    pub unused_types: Vec<UnusedExport>,
36    /// Dependencies listed in package.json but never imported.
37    pub unused_dependencies: Vec<UnusedDependency>,
38    /// Dev dependencies listed in package.json but never imported.
39    pub unused_dev_dependencies: Vec<UnusedDependency>,
40    /// Optional dependencies listed in package.json but never imported.
41    pub unused_optional_dependencies: Vec<UnusedDependency>,
42    /// Enum members never accessed.
43    pub unused_enum_members: Vec<UnusedMember>,
44    /// Class members never accessed.
45    pub unused_class_members: Vec<UnusedMember>,
46    /// Import specifiers that could not be resolved.
47    pub unresolved_imports: Vec<UnresolvedImport>,
48    /// Dependencies used in code but not listed in package.json.
49    pub unlisted_dependencies: Vec<UnlistedDependency>,
50    /// Exports with the same name across multiple modules.
51    pub duplicate_exports: Vec<DuplicateExport>,
52    /// Production dependencies only used via type-only imports (could be devDependencies).
53    /// Only populated in production mode.
54    pub type_only_dependencies: Vec<TypeOnlyDependency>,
55    /// Production dependencies only imported by test files (could be devDependencies).
56    #[serde(default)]
57    pub test_only_dependencies: Vec<TestOnlyDependency>,
58    /// Circular dependency chains detected in the module graph.
59    pub circular_dependencies: Vec<CircularDependency>,
60    /// Imports that cross architecture boundary rules.
61    #[serde(default)]
62    pub boundary_violations: Vec<BoundaryViolation>,
63    /// Usage counts for all exports across the project. Used by the LSP for Code Lens.
64    /// Not included in issue counts -- this is metadata, not an issue type.
65    /// Skipped during serialization: this is internal LSP data, not part of the JSON output schema.
66    #[serde(skip)]
67    pub export_usages: Vec<ExportUsage>,
68}
69
70impl AnalysisResults {
71    /// Total number of issues found.
72    ///
73    /// Sums across all issue categories (unused files, exports, types,
74    /// dependencies, members, unresolved imports, unlisted deps, duplicates,
75    /// type-only deps, circular deps, and boundary violations).
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use fallow_types::results::{AnalysisResults, UnusedFile, UnresolvedImport};
81    /// use std::path::PathBuf;
82    ///
83    /// let mut results = AnalysisResults::default();
84    /// results.unused_files.push(UnusedFile { path: PathBuf::from("a.ts") });
85    /// results.unresolved_imports.push(UnresolvedImport {
86    ///     path: PathBuf::from("b.ts"),
87    ///     specifier: "./missing".to_string(),
88    ///     line: 1,
89    ///     col: 0,
90    ///     specifier_col: 0,
91    /// });
92    /// assert_eq!(results.total_issues(), 2);
93    /// ```
94    #[must_use]
95    pub const fn total_issues(&self) -> usize {
96        self.unused_files.len()
97            + self.unused_exports.len()
98            + self.unused_types.len()
99            + self.unused_dependencies.len()
100            + self.unused_dev_dependencies.len()
101            + self.unused_optional_dependencies.len()
102            + self.unused_enum_members.len()
103            + self.unused_class_members.len()
104            + self.unresolved_imports.len()
105            + self.unlisted_dependencies.len()
106            + self.duplicate_exports.len()
107            + self.type_only_dependencies.len()
108            + self.test_only_dependencies.len()
109            + self.circular_dependencies.len()
110            + self.boundary_violations.len()
111    }
112
113    /// Whether any issues were found.
114    #[must_use]
115    pub const fn has_issues(&self) -> bool {
116        self.total_issues() > 0
117    }
118}
119
120/// A file that is not reachable from any entry point.
121#[derive(Debug, Clone, Serialize)]
122pub struct UnusedFile {
123    /// Absolute path to the unused file.
124    #[serde(serialize_with = "serde_path::serialize")]
125    pub path: PathBuf,
126}
127
128/// An export that is never imported by other modules.
129#[derive(Debug, Clone, Serialize)]
130pub struct UnusedExport {
131    /// File containing the unused export.
132    #[serde(serialize_with = "serde_path::serialize")]
133    pub path: PathBuf,
134    /// Name of the unused export.
135    pub export_name: String,
136    /// Whether this is a type-only export.
137    pub is_type_only: bool,
138    /// 1-based line number of the export.
139    pub line: u32,
140    /// 0-based byte column offset.
141    pub col: u32,
142    /// Byte offset into the source file (used by the fix command).
143    pub span_start: u32,
144    /// Whether this finding comes from a barrel/index re-export rather than the source definition.
145    pub is_re_export: bool,
146}
147
148/// A dependency that is listed in package.json but never imported.
149#[derive(Debug, Clone, Serialize)]
150pub struct UnusedDependency {
151    /// npm package name.
152    pub package_name: String,
153    /// Whether this is in `dependencies`, `devDependencies`, or `optionalDependencies`.
154    pub location: DependencyLocation,
155    /// Path to the package.json where this dependency is listed.
156    /// For root deps this is `<root>/package.json`, for workspace deps it is `<ws>/package.json`.
157    #[serde(serialize_with = "serde_path::serialize")]
158    pub path: PathBuf,
159    /// 1-based line number of the dependency entry in package.json.
160    pub line: u32,
161}
162
163/// Where in package.json a dependency is listed.
164///
165/// # Examples
166///
167/// ```
168/// use fallow_types::results::DependencyLocation;
169///
170/// // All three variants are constructible
171/// let loc = DependencyLocation::Dependencies;
172/// let dev = DependencyLocation::DevDependencies;
173/// let opt = DependencyLocation::OptionalDependencies;
174/// // Debug output includes the variant name
175/// assert!(format!("{loc:?}").contains("Dependencies"));
176/// assert!(format!("{dev:?}").contains("DevDependencies"));
177/// assert!(format!("{opt:?}").contains("OptionalDependencies"));
178/// ```
179#[derive(Debug, Clone, Serialize)]
180#[serde(rename_all = "camelCase")]
181pub enum DependencyLocation {
182    /// Listed in `dependencies`.
183    Dependencies,
184    /// Listed in `devDependencies`.
185    DevDependencies,
186    /// Listed in `optionalDependencies`.
187    OptionalDependencies,
188}
189
190/// An unused enum or class member.
191#[derive(Debug, Clone, Serialize)]
192pub struct UnusedMember {
193    /// File containing the unused member.
194    #[serde(serialize_with = "serde_path::serialize")]
195    pub path: PathBuf,
196    /// Name of the parent enum or class.
197    pub parent_name: String,
198    /// Name of the unused member.
199    pub member_name: String,
200    /// Whether this is an enum member, class method, or class property.
201    pub kind: MemberKind,
202    /// 1-based line number.
203    pub line: u32,
204    /// 0-based byte column offset.
205    pub col: u32,
206}
207
208/// An import that could not be resolved.
209#[derive(Debug, Clone, Serialize)]
210pub struct UnresolvedImport {
211    /// File containing the unresolved import.
212    #[serde(serialize_with = "serde_path::serialize")]
213    pub path: PathBuf,
214    /// The import specifier that could not be resolved.
215    pub specifier: String,
216    /// 1-based line number.
217    pub line: u32,
218    /// 0-based byte column offset of the import statement.
219    pub col: u32,
220    /// 0-based byte column offset of the source string literal (the specifier in quotes).
221    /// Used by the LSP to underline just the specifier, not the entire import line.
222    pub specifier_col: u32,
223}
224
225/// A dependency used in code but not listed in package.json.
226#[derive(Debug, Clone, Serialize)]
227pub struct UnlistedDependency {
228    /// npm package name.
229    pub package_name: String,
230    /// Import sites where this unlisted dependency is used (file path, line, column).
231    pub imported_from: Vec<ImportSite>,
232}
233
234/// A location where an import occurs.
235#[derive(Debug, Clone, Serialize)]
236pub struct ImportSite {
237    /// File containing the import.
238    #[serde(serialize_with = "serde_path::serialize")]
239    pub path: PathBuf,
240    /// 1-based line number.
241    pub line: u32,
242    /// 0-based byte column offset.
243    pub col: u32,
244}
245
246/// An export that appears multiple times across the project.
247#[derive(Debug, Clone, Serialize)]
248pub struct DuplicateExport {
249    /// The duplicated export name.
250    pub export_name: String,
251    /// Locations where this export name appears.
252    pub locations: Vec<DuplicateLocation>,
253}
254
255/// A location where a duplicate export appears.
256#[derive(Debug, Clone, Serialize)]
257pub struct DuplicateLocation {
258    /// File containing the duplicate export.
259    #[serde(serialize_with = "serde_path::serialize")]
260    pub path: PathBuf,
261    /// 1-based line number.
262    pub line: u32,
263    /// 0-based byte column offset.
264    pub col: u32,
265}
266
267/// A production dependency that is only used via type-only imports.
268/// In production builds, type imports are erased, so this dependency
269/// is not needed at runtime and could be moved to devDependencies.
270#[derive(Debug, Clone, Serialize)]
271pub struct TypeOnlyDependency {
272    /// npm package name.
273    pub package_name: String,
274    /// Path to the package.json where the dependency is listed.
275    #[serde(serialize_with = "serde_path::serialize")]
276    pub path: PathBuf,
277    /// 1-based line number of the dependency entry in package.json.
278    pub line: u32,
279}
280
281/// A production dependency that is only imported by test files.
282/// Since it is never used in production code, it could be moved to devDependencies.
283#[derive(Debug, Clone, Serialize)]
284pub struct TestOnlyDependency {
285    /// npm package name.
286    pub package_name: String,
287    /// Path to the package.json where the dependency is listed.
288    #[serde(serialize_with = "serde_path::serialize")]
289    pub path: PathBuf,
290    /// 1-based line number of the dependency entry in package.json.
291    pub line: u32,
292}
293
294/// A circular dependency chain detected in the module graph.
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct CircularDependency {
297    /// Files forming the cycle, in import order.
298    #[serde(serialize_with = "serde_path::serialize_vec")]
299    pub files: Vec<PathBuf>,
300    /// Number of files in the cycle.
301    pub length: usize,
302    /// 1-based line number of the import that starts the cycle (in the first file).
303    #[serde(default)]
304    pub line: u32,
305    /// 0-based byte column offset of the import that starts the cycle.
306    #[serde(default)]
307    pub col: u32,
308}
309
310/// An import that crosses an architecture boundary rule.
311#[derive(Debug, Clone, Serialize)]
312pub struct BoundaryViolation {
313    /// The file making the disallowed import.
314    #[serde(serialize_with = "serde_path::serialize")]
315    pub from_path: PathBuf,
316    /// The file being imported that violates the boundary.
317    #[serde(serialize_with = "serde_path::serialize")]
318    pub to_path: PathBuf,
319    /// The zone the importing file belongs to.
320    pub from_zone: String,
321    /// The zone the imported file belongs to.
322    pub to_zone: String,
323    /// The raw import specifier from the source file.
324    pub import_specifier: String,
325    /// 1-based line number of the import statement in the source file.
326    pub line: u32,
327    /// 0-based byte column offset of the import statement.
328    pub col: u32,
329}
330
331/// Usage count for an export symbol. Used by the LSP Code Lens to show
332/// reference counts above each export declaration.
333#[derive(Debug, Clone, Serialize)]
334pub struct ExportUsage {
335    /// File containing the export.
336    #[serde(serialize_with = "serde_path::serialize")]
337    pub path: PathBuf,
338    /// Name of the exported symbol.
339    pub export_name: String,
340    /// 1-based line number.
341    pub line: u32,
342    /// 0-based byte column offset.
343    pub col: u32,
344    /// Number of files that reference this export.
345    pub reference_count: usize,
346    /// Locations where this export is referenced. Used by the LSP Code Lens
347    /// to enable click-to-navigate via `editor.action.showReferences`.
348    pub reference_locations: Vec<ReferenceLocation>,
349}
350
351/// A location where an export is referenced (import site in another file).
352#[derive(Debug, Clone, Serialize)]
353pub struct ReferenceLocation {
354    /// File containing the import that references the export.
355    #[serde(serialize_with = "serde_path::serialize")]
356    pub path: PathBuf,
357    /// 1-based line number.
358    pub line: u32,
359    /// 0-based byte column offset.
360    pub col: u32,
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn empty_results_no_issues() {
369        let results = AnalysisResults::default();
370        assert_eq!(results.total_issues(), 0);
371        assert!(!results.has_issues());
372    }
373
374    #[test]
375    fn results_with_unused_file() {
376        let mut results = AnalysisResults::default();
377        results.unused_files.push(UnusedFile {
378            path: PathBuf::from("test.ts"),
379        });
380        assert_eq!(results.total_issues(), 1);
381        assert!(results.has_issues());
382    }
383
384    #[test]
385    fn results_with_unused_export() {
386        let mut results = AnalysisResults::default();
387        results.unused_exports.push(UnusedExport {
388            path: PathBuf::from("test.ts"),
389            export_name: "foo".to_string(),
390            is_type_only: false,
391            line: 1,
392            col: 0,
393            span_start: 0,
394            is_re_export: false,
395        });
396        assert_eq!(results.total_issues(), 1);
397        assert!(results.has_issues());
398    }
399
400    #[test]
401    fn results_total_counts_all_types() {
402        let mut results = AnalysisResults::default();
403        results.unused_files.push(UnusedFile {
404            path: PathBuf::from("a.ts"),
405        });
406        results.unused_exports.push(UnusedExport {
407            path: PathBuf::from("b.ts"),
408            export_name: "x".to_string(),
409            is_type_only: false,
410            line: 1,
411            col: 0,
412            span_start: 0,
413            is_re_export: false,
414        });
415        results.unused_types.push(UnusedExport {
416            path: PathBuf::from("c.ts"),
417            export_name: "T".to_string(),
418            is_type_only: true,
419            line: 1,
420            col: 0,
421            span_start: 0,
422            is_re_export: false,
423        });
424        results.unused_dependencies.push(UnusedDependency {
425            package_name: "dep".to_string(),
426            location: DependencyLocation::Dependencies,
427            path: PathBuf::from("package.json"),
428            line: 5,
429        });
430        results.unused_dev_dependencies.push(UnusedDependency {
431            package_name: "dev".to_string(),
432            location: DependencyLocation::DevDependencies,
433            path: PathBuf::from("package.json"),
434            line: 5,
435        });
436        results.unused_enum_members.push(UnusedMember {
437            path: PathBuf::from("d.ts"),
438            parent_name: "E".to_string(),
439            member_name: "A".to_string(),
440            kind: MemberKind::EnumMember,
441            line: 1,
442            col: 0,
443        });
444        results.unused_class_members.push(UnusedMember {
445            path: PathBuf::from("e.ts"),
446            parent_name: "C".to_string(),
447            member_name: "m".to_string(),
448            kind: MemberKind::ClassMethod,
449            line: 1,
450            col: 0,
451        });
452        results.unresolved_imports.push(UnresolvedImport {
453            path: PathBuf::from("f.ts"),
454            specifier: "./missing".to_string(),
455            line: 1,
456            col: 0,
457            specifier_col: 0,
458        });
459        results.unlisted_dependencies.push(UnlistedDependency {
460            package_name: "unlisted".to_string(),
461            imported_from: vec![ImportSite {
462                path: PathBuf::from("g.ts"),
463                line: 1,
464                col: 0,
465            }],
466        });
467        results.duplicate_exports.push(DuplicateExport {
468            export_name: "dup".to_string(),
469            locations: vec![
470                DuplicateLocation {
471                    path: PathBuf::from("h.ts"),
472                    line: 15,
473                    col: 0,
474                },
475                DuplicateLocation {
476                    path: PathBuf::from("i.ts"),
477                    line: 30,
478                    col: 0,
479                },
480            ],
481        });
482        results.unused_optional_dependencies.push(UnusedDependency {
483            package_name: "optional".to_string(),
484            location: DependencyLocation::OptionalDependencies,
485            path: PathBuf::from("package.json"),
486            line: 5,
487        });
488        results.type_only_dependencies.push(TypeOnlyDependency {
489            package_name: "type-only".to_string(),
490            path: PathBuf::from("package.json"),
491            line: 8,
492        });
493        results.test_only_dependencies.push(TestOnlyDependency {
494            package_name: "test-only".to_string(),
495            path: PathBuf::from("package.json"),
496            line: 9,
497        });
498        results.circular_dependencies.push(CircularDependency {
499            files: vec![PathBuf::from("a.ts"), PathBuf::from("b.ts")],
500            length: 2,
501            line: 3,
502            col: 0,
503        });
504        results.boundary_violations.push(BoundaryViolation {
505            from_path: PathBuf::from("src/ui/Button.tsx"),
506            to_path: PathBuf::from("src/db/queries.ts"),
507            from_zone: "ui".to_string(),
508            to_zone: "database".to_string(),
509            import_specifier: "../db/queries".to_string(),
510            line: 3,
511            col: 0,
512        });
513
514        // 15 categories, one of each
515        assert_eq!(results.total_issues(), 15);
516        assert!(results.has_issues());
517    }
518
519    // ── total_issues / has_issues consistency ──────────────────
520
521    #[test]
522    fn total_issues_and_has_issues_are_consistent() {
523        let results = AnalysisResults::default();
524        assert_eq!(results.total_issues(), 0);
525        assert!(!results.has_issues());
526        assert_eq!(results.total_issues() > 0, results.has_issues());
527    }
528
529    // ── total_issues counts each category independently ─────────
530
531    #[test]
532    fn total_issues_sums_all_categories_independently() {
533        let mut results = AnalysisResults::default();
534        results.unused_files.push(UnusedFile {
535            path: PathBuf::from("a.ts"),
536        });
537        assert_eq!(results.total_issues(), 1);
538
539        results.unused_files.push(UnusedFile {
540            path: PathBuf::from("b.ts"),
541        });
542        assert_eq!(results.total_issues(), 2);
543
544        results.unresolved_imports.push(UnresolvedImport {
545            path: PathBuf::from("c.ts"),
546            specifier: "./missing".to_string(),
547            line: 1,
548            col: 0,
549            specifier_col: 0,
550        });
551        assert_eq!(results.total_issues(), 3);
552    }
553
554    // ── default is truly empty ──────────────────────────────────
555
556    #[test]
557    fn default_results_all_fields_empty() {
558        let r = AnalysisResults::default();
559        assert!(r.unused_files.is_empty());
560        assert!(r.unused_exports.is_empty());
561        assert!(r.unused_types.is_empty());
562        assert!(r.unused_dependencies.is_empty());
563        assert!(r.unused_dev_dependencies.is_empty());
564        assert!(r.unused_optional_dependencies.is_empty());
565        assert!(r.unused_enum_members.is_empty());
566        assert!(r.unused_class_members.is_empty());
567        assert!(r.unresolved_imports.is_empty());
568        assert!(r.unlisted_dependencies.is_empty());
569        assert!(r.duplicate_exports.is_empty());
570        assert!(r.type_only_dependencies.is_empty());
571        assert!(r.test_only_dependencies.is_empty());
572        assert!(r.circular_dependencies.is_empty());
573        assert!(r.boundary_violations.is_empty());
574        assert!(r.export_usages.is_empty());
575    }
576}