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