Skip to main content

fallow_core/analyze/
mod.rs

1mod predicates;
2mod unused_deps;
3mod unused_exports;
4mod unused_files;
5mod unused_members;
6
7use rustc_hash::FxHashMap;
8
9use fallow_config::{PackageJson, ResolvedConfig, Severity};
10
11use crate::discover::FileId;
12use crate::extract::ModuleInfo;
13use crate::graph::ModuleGraph;
14use crate::resolve::ResolvedModule;
15use crate::results::*;
16use crate::suppress::{self, IssueKind, Suppression};
17
18use unused_deps::{
19    find_type_only_dependencies, find_unlisted_dependencies, find_unresolved_imports,
20    find_unused_dependencies,
21};
22use unused_exports::{collect_export_usages, find_duplicate_exports, find_unused_exports};
23use unused_files::find_unused_files;
24use unused_members::find_unused_members;
25
26/// Pre-computed line offset tables indexed by `FileId`, built during parse and
27/// carried through the cache. Eliminates redundant file reads during analysis.
28pub(crate) type LineOffsetsMap<'a> = FxHashMap<FileId, &'a [u32]>;
29
30/// Convert a byte offset to (line, col) using pre-computed line offsets.
31/// Falls back to `(1, byte_offset)` when no line table is available.
32pub(crate) fn byte_offset_to_line_col(
33    line_offsets_map: &LineOffsetsMap<'_>,
34    file_id: FileId,
35    byte_offset: u32,
36) -> (u32, u32) {
37    line_offsets_map
38        .get(&file_id)
39        .map_or((1, byte_offset), |offsets| {
40            fallow_types::extract::byte_offset_to_line_col(offsets, byte_offset)
41        })
42}
43
44/// Read source content from disk, returning empty string on failure.
45/// Only used for LSP Code Lens reference resolution where the referencing
46/// file may not be in the line offsets map.
47fn read_source(path: &std::path::Path) -> String {
48    std::fs::read_to_string(path).unwrap_or_default()
49}
50
51/// Find all dead code in the project.
52pub fn find_dead_code(graph: &ModuleGraph, config: &ResolvedConfig) -> AnalysisResults {
53    find_dead_code_with_resolved(graph, config, &[], None)
54}
55
56/// Find all dead code, with optional resolved module data and plugin context.
57pub fn find_dead_code_with_resolved(
58    graph: &ModuleGraph,
59    config: &ResolvedConfig,
60    resolved_modules: &[ResolvedModule],
61    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
62) -> AnalysisResults {
63    find_dead_code_full(
64        graph,
65        config,
66        resolved_modules,
67        plugin_result,
68        &[],
69        &[],
70        false,
71    )
72}
73
74/// Find all dead code, with optional resolved module data, plugin context, and workspace info.
75pub fn find_dead_code_full(
76    graph: &ModuleGraph,
77    config: &ResolvedConfig,
78    resolved_modules: &[ResolvedModule],
79    plugin_result: Option<&crate::plugins::AggregatedPluginResult>,
80    workspaces: &[fallow_config::WorkspaceInfo],
81    modules: &[ModuleInfo],
82    collect_usages: bool,
83) -> AnalysisResults {
84    let _span = tracing::info_span!("find_dead_code").entered();
85
86    // Build suppression index: FileId -> suppressions
87    let suppressions_by_file: FxHashMap<FileId, &[Suppression]> = modules
88        .iter()
89        .filter(|m| !m.suppressions.is_empty())
90        .map(|m| (m.file_id, m.suppressions.as_slice()))
91        .collect();
92
93    // Build line offset index: FileId -> pre-computed line start offsets.
94    // Eliminates redundant file reads for byte-to-line/col conversion.
95    let line_offsets_by_file: LineOffsetsMap<'_> = modules
96        .iter()
97        .filter(|m| !m.line_offsets.is_empty())
98        .map(|m| (m.file_id, m.line_offsets.as_slice()))
99        .collect();
100
101    let mut results = AnalysisResults::default();
102
103    if config.rules.unused_files != Severity::Off {
104        results.unused_files = find_unused_files(graph, &suppressions_by_file);
105    }
106
107    if config.rules.unused_exports != Severity::Off || config.rules.unused_types != Severity::Off {
108        let (exports, types) = find_unused_exports(
109            graph,
110            config,
111            plugin_result,
112            &suppressions_by_file,
113            &line_offsets_by_file,
114        );
115        if config.rules.unused_exports != Severity::Off {
116            results.unused_exports = exports;
117        }
118        if config.rules.unused_types != Severity::Off {
119            results.unused_types = types;
120        }
121    }
122
123    if config.rules.unused_enum_members != Severity::Off
124        || config.rules.unused_class_members != Severity::Off
125    {
126        let (enum_members, class_members) = find_unused_members(
127            graph,
128            config,
129            resolved_modules,
130            &suppressions_by_file,
131            &line_offsets_by_file,
132        );
133        if config.rules.unused_enum_members != Severity::Off {
134            results.unused_enum_members = enum_members;
135        }
136        if config.rules.unused_class_members != Severity::Off {
137            results.unused_class_members = class_members;
138        }
139    }
140
141    // Build merged dependency set from root + all workspace package.json files
142    let pkg_path = config.root.join("package.json");
143    let pkg = PackageJson::load(&pkg_path).ok();
144    if let Some(ref pkg) = pkg {
145        if config.rules.unused_dependencies != Severity::Off
146            || config.rules.unused_dev_dependencies != Severity::Off
147            || config.rules.unused_optional_dependencies != Severity::Off
148        {
149            let (deps, dev_deps, optional_deps) =
150                find_unused_dependencies(graph, pkg, config, plugin_result, workspaces);
151            if config.rules.unused_dependencies != Severity::Off {
152                results.unused_dependencies = deps;
153            }
154            if config.rules.unused_dev_dependencies != Severity::Off {
155                results.unused_dev_dependencies = dev_deps;
156            }
157            if config.rules.unused_optional_dependencies != Severity::Off {
158                results.unused_optional_dependencies = optional_deps;
159            }
160        }
161
162        if config.rules.unlisted_dependencies != Severity::Off {
163            results.unlisted_dependencies = find_unlisted_dependencies(
164                graph,
165                pkg,
166                config,
167                workspaces,
168                plugin_result,
169                resolved_modules,
170                &line_offsets_by_file,
171            );
172        }
173    }
174
175    if config.rules.unresolved_imports != Severity::Off && !resolved_modules.is_empty() {
176        let virtual_prefixes: Vec<&str> = plugin_result
177            .map(|pr| {
178                pr.virtual_module_prefixes
179                    .iter()
180                    .map(|s| s.as_str())
181                    .collect()
182            })
183            .unwrap_or_default();
184        results.unresolved_imports = find_unresolved_imports(
185            resolved_modules,
186            config,
187            &suppressions_by_file,
188            &virtual_prefixes,
189            &line_offsets_by_file,
190        );
191    }
192
193    if config.rules.duplicate_exports != Severity::Off {
194        results.duplicate_exports =
195            find_duplicate_exports(graph, config, &suppressions_by_file, &line_offsets_by_file);
196    }
197
198    // In production mode, detect dependencies that are only used via type-only imports
199    if config.production
200        && let Some(ref pkg) = pkg
201    {
202        results.type_only_dependencies =
203            find_type_only_dependencies(graph, pkg, config, workspaces);
204    }
205
206    // Detect circular dependencies
207    if config.rules.circular_dependencies != Severity::Off {
208        let cycles = graph.find_cycles();
209        results.circular_dependencies = cycles
210            .into_iter()
211            .filter(|cycle| {
212                // Skip cycles where any participating file has a file-level suppression
213                !cycle.iter().any(|&id| {
214                    suppressions_by_file.get(&id).is_some_and(|supps| {
215                        suppress::is_file_suppressed(supps, IssueKind::CircularDependency)
216                    })
217                })
218            })
219            .map(|cycle| {
220                let files: Vec<std::path::PathBuf> = cycle
221                    .iter()
222                    .map(|&id| graph.modules[id.0 as usize].path.clone())
223                    .collect();
224                let length = files.len();
225                // Look up the import span from cycle[0] → cycle[1] for precise location
226                let (line, col) = if cycle.len() >= 2 {
227                    graph
228                        .find_import_span_start(cycle[0], cycle[1])
229                        .map_or((1, 0), |span_start| {
230                            byte_offset_to_line_col(&line_offsets_by_file, cycle[0], span_start)
231                        })
232                } else {
233                    (1, 0)
234                };
235                CircularDependency {
236                    files,
237                    length,
238                    line,
239                    col,
240                }
241            })
242            .collect();
243    }
244
245    // Collect export usage counts for Code Lens (LSP feature).
246    // Skipped in CLI mode since the field is #[serde(skip)] in all output formats.
247    if collect_usages {
248        results.export_usages = collect_export_usages(graph, &line_offsets_by_file);
249    }
250
251    results
252}
253
254#[cfg(test)]
255mod tests {
256    use fallow_types::extract::{byte_offset_to_line_col, compute_line_offsets};
257
258    // Helper: compute line offsets from source and convert byte offset
259    fn line_col(source: &str, byte_offset: u32) -> (u32, u32) {
260        let offsets = compute_line_offsets(source);
261        byte_offset_to_line_col(&offsets, byte_offset)
262    }
263
264    // ── compute_line_offsets ─────────────────────────────────────
265
266    #[test]
267    fn compute_offsets_empty() {
268        assert_eq!(compute_line_offsets(""), vec![0]);
269    }
270
271    #[test]
272    fn compute_offsets_single_line() {
273        assert_eq!(compute_line_offsets("hello"), vec![0]);
274    }
275
276    #[test]
277    fn compute_offsets_multiline() {
278        assert_eq!(compute_line_offsets("abc\ndef\nghi"), vec![0, 4, 8]);
279    }
280
281    #[test]
282    fn compute_offsets_trailing_newline() {
283        assert_eq!(compute_line_offsets("abc\n"), vec![0, 4]);
284    }
285
286    #[test]
287    fn compute_offsets_crlf() {
288        assert_eq!(compute_line_offsets("ab\r\ncd"), vec![0, 4]);
289    }
290
291    #[test]
292    fn compute_offsets_consecutive_newlines() {
293        assert_eq!(compute_line_offsets("\n\n"), vec![0, 1, 2]);
294    }
295
296    // ── byte_offset_to_line_col ─────────────────────────────────
297
298    #[test]
299    fn byte_offset_empty_source() {
300        assert_eq!(line_col("", 0), (1, 0));
301    }
302
303    #[test]
304    fn byte_offset_single_line_start() {
305        assert_eq!(line_col("hello", 0), (1, 0));
306    }
307
308    #[test]
309    fn byte_offset_single_line_middle() {
310        assert_eq!(line_col("hello", 4), (1, 4));
311    }
312
313    #[test]
314    fn byte_offset_multiline_start_of_line2() {
315        assert_eq!(line_col("line1\nline2\nline3", 6), (2, 0));
316    }
317
318    #[test]
319    fn byte_offset_multiline_middle_of_line3() {
320        assert_eq!(line_col("line1\nline2\nline3", 14), (3, 2));
321    }
322
323    #[test]
324    fn byte_offset_at_newline_boundary() {
325        assert_eq!(line_col("line1\nline2", 5), (1, 5));
326    }
327
328    #[test]
329    fn byte_offset_multibyte_utf8() {
330        let source = "hi\n\u{1F600}x";
331        assert_eq!(line_col(source, 3), (2, 0));
332        assert_eq!(line_col(source, 7), (2, 4));
333    }
334
335    #[test]
336    fn byte_offset_multibyte_accented_chars() {
337        let source = "caf\u{00E9}\nbar";
338        assert_eq!(line_col(source, 6), (2, 0));
339        assert_eq!(line_col(source, 3), (1, 3));
340    }
341
342    #[test]
343    fn byte_offset_via_map_fallback() {
344        use super::*;
345        let map: LineOffsetsMap<'_> = FxHashMap::default();
346        assert_eq!(
347            super::byte_offset_to_line_col(&map, FileId(99), 42),
348            (1, 42)
349        );
350    }
351
352    #[test]
353    fn byte_offset_via_map_lookup() {
354        use super::*;
355        let offsets = compute_line_offsets("abc\ndef\nghi");
356        let mut map: LineOffsetsMap<'_> = FxHashMap::default();
357        map.insert(FileId(0), &offsets);
358        assert_eq!(super::byte_offset_to_line_col(&map, FileId(0), 5), (2, 1));
359    }
360}