Skip to main content

fallow_core/analyze/
mod.rs

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