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