pior/analyzer/
mod.rs

1use std::collections::HashSet;
2use std::path::PathBuf;
3use std::time::Instant;
4
5use crate::cache::{create_cache, create_cache_with_dir};
6use crate::config::ResolvedConfig;
7use crate::graph::{build_graph_with_options, BuildOptions, ModuleGraph};
8use crate::{
9    AnalysisResult, Counters, Issues, Stats, TypeKind, UnlistedDependency, UnresolvedImport,
10    UnusedDependency, UnusedExport, UnusedFile, UnusedType,
11};
12
13#[derive(Debug, Default)]
14pub struct AnalyzeOptions {
15    pub cache: bool,
16    pub cache_dir: Option<PathBuf>,
17    pub production: bool,
18    pub strict: bool,
19}
20
21pub fn analyze_project(config: &ResolvedConfig) -> anyhow::Result<AnalysisResult> {
22    analyze_project_with_options(config, AnalyzeOptions::default())
23}
24
25pub fn analyze_project_with_options(
26    config: &ResolvedConfig,
27    options: AnalyzeOptions,
28) -> anyhow::Result<AnalysisResult> {
29    let start = Instant::now();
30
31    let cache = if let Some(ref cache_dir) = options.cache_dir {
32        create_cache_with_dir(cache_dir.clone(), options.cache)?
33    } else {
34        create_cache(&config.root, options.cache)?
35    };
36
37    let build_options = BuildOptions {
38        cache,
39        production: options.production,
40        strict: options.strict,
41    };
42
43    let parse_start = Instant::now();
44    let graph = build_graph_with_options(config, build_options)?;
45    let parse_time = parse_start.elapsed().as_millis() as u64;
46
47    let analysis_start = Instant::now();
48
49    let unused_files = find_unused_files(&graph, config);
50    let (unused_exports, unused_types) = find_unused_exports(&graph, config);
51    let (unused_deps, unused_dev_deps) = find_unused_dependencies(&graph, config, &options);
52    let unlisted_deps = find_unlisted_dependencies(&graph, config, &options);
53    let unresolved_imports = find_unresolved_imports(&graph, config);
54
55    let analysis_time = analysis_start.elapsed().as_millis() as u64;
56
57    let counters = Counters {
58        files: unused_files.len(),
59        dependencies: unused_deps.len(),
60        dev_dependencies: unused_dev_deps.len(),
61        exports: unused_exports.len(),
62        types: unused_types.len(),
63        unlisted: unlisted_deps.len(),
64        unresolved: unresolved_imports.len(),
65        ..Default::default()
66    };
67
68    let stats = Stats {
69        files_analyzed: graph.modules.len(),
70        duration_ms: start.elapsed().as_millis() as u64,
71        parse_time_ms: parse_time,
72        resolve_time_ms: 0,
73        analysis_time_ms: analysis_time,
74    };
75
76    Ok(AnalysisResult {
77        issues: Issues {
78            files: unused_files,
79            dependencies: unused_deps,
80            dev_dependencies: unused_dev_deps,
81            exports: unused_exports,
82            types: unused_types,
83            unlisted: unlisted_deps,
84            unresolved: unresolved_imports,
85            ..Default::default()
86        },
87        counters,
88        stats,
89    })
90}
91
92fn find_unused_files(graph: &ModuleGraph, config: &ResolvedConfig) -> Vec<UnusedFile> {
93    let reachable = graph.get_reachable_files();
94    let mut unused = Vec::new();
95
96    let ignore_patterns: HashSet<&str> = config
97        .config
98        .ignore
99        .iter()
100        .map(|s| s.as_str())
101        .collect();
102
103    for path in graph.modules.keys() {
104        if reachable.contains(path) {
105            continue;
106        }
107
108        let relative = path
109            .strip_prefix(&config.root)
110            .unwrap_or(path)
111            .to_string_lossy();
112
113        let should_ignore = ignore_patterns.iter().any(|pattern| {
114            if pattern.contains('*') {
115                if let Ok(glob) = globset::Glob::new(pattern) {
116                    let matcher = glob.compile_matcher();
117                    return matcher.is_match(relative.as_ref());
118                }
119            }
120            relative.contains(*pattern)
121        });
122
123        if should_ignore {
124            continue;
125        }
126
127        if is_test_file(&relative) {
128            continue;
129        }
130
131        unused.push(UnusedFile {
132            path: path.clone(),
133        });
134    }
135
136    unused.sort_by(|a, b| a.path.cmp(&b.path));
137    unused
138}
139
140fn is_test_file(path: &str) -> bool {
141    path.contains(".test.")
142        || path.contains(".spec.")
143        || path.contains("__tests__")
144        || path.contains("__mocks__")
145        || path.ends_with(".test.ts")
146        || path.ends_with(".test.tsx")
147        || path.ends_with(".test.js")
148        || path.ends_with(".test.jsx")
149        || path.ends_with(".spec.ts")
150        || path.ends_with(".spec.tsx")
151        || path.ends_with(".spec.js")
152        || path.ends_with(".spec.jsx")
153}
154
155fn find_unused_exports(
156    graph: &ModuleGraph,
157    config: &ResolvedConfig,
158) -> (Vec<UnusedExport>, Vec<UnusedType>) {
159    let used_exports = graph.get_used_exports();
160    let reachable = graph.get_reachable_files();
161
162    let mut unused_exports = Vec::new();
163    let mut unused_types = Vec::new();
164
165    let entry_points: HashSet<&PathBuf> = graph.entry_points.iter().collect();
166
167    for (path, module) in &graph.modules {
168        if !reachable.contains(path) {
169            continue;
170        }
171
172        let used_in_file = used_exports.get(path);
173        let is_entry = entry_points.contains(path);
174
175        let relative = path
176            .strip_prefix(&config.root)
177            .unwrap_or(path)
178            .to_string_lossy();
179
180        let should_ignore_all = config
181            .config
182            .ignore_exports
183            .get("**/*")
184            .map(|patterns| patterns.contains(&"*".to_string()))
185            .unwrap_or(false);
186
187        if should_ignore_all {
188            continue;
189        }
190
191        let file_ignore_patterns = config
192            .config
193            .ignore_exports
194            .iter()
195            .filter(|(pattern, _)| {
196                if let Ok(glob) = globset::Glob::new(pattern) {
197                    glob.compile_matcher().is_match(relative.as_ref())
198                } else {
199                    false
200                }
201            })
202            .flat_map(|(_, patterns)| patterns.iter())
203            .collect::<HashSet<_>>();
204
205        for export in &module.exports {
206            if export.is_default && is_entry && !config.config.include_entry_exports {
207                continue;
208            }
209
210            if file_ignore_patterns.contains(&export.name)
211                || file_ignore_patterns.contains(&"*".to_string())
212            {
213                continue;
214            }
215
216            let is_used = used_in_file
217                .map(|used| {
218                    used.contains(&export.name)
219                        || used.contains("*")
220                        || (export.is_default && used.contains("default"))
221                })
222                .unwrap_or(false);
223
224            if is_used {
225                continue;
226            }
227
228            if config.config.ignore_exports_used_in_file {
229                continue;
230            }
231
232            if export.is_type {
233                unused_types.push(UnusedType {
234                    path: path.clone(),
235                    name: export.name.clone(),
236                    line: export.line,
237                    col: export.col,
238                    kind: match export.kind {
239                        crate::parser::ExportKind::Type => TypeKind::Type,
240                        crate::parser::ExportKind::Interface => TypeKind::Interface,
241                        crate::parser::ExportKind::Enum => TypeKind::Enum,
242                        _ => TypeKind::Type,
243                    },
244                });
245            } else {
246                unused_exports.push(UnusedExport {
247                    path: path.clone(),
248                    name: export.name.clone(),
249                    line: export.line,
250                    col: export.col,
251                    kind: convert_export_kind(export.kind),
252                    is_type: export.is_type,
253                });
254            }
255        }
256    }
257
258    unused_exports.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
259    unused_types.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
260
261    (unused_exports, unused_types)
262}
263
264fn convert_export_kind(kind: crate::parser::ExportKind) -> crate::ExportKind {
265    match kind {
266        crate::parser::ExportKind::Function => crate::ExportKind::Function,
267        crate::parser::ExportKind::Class => crate::ExportKind::Class,
268        crate::parser::ExportKind::Variable => crate::ExportKind::Variable,
269        crate::parser::ExportKind::Const => crate::ExportKind::Const,
270        crate::parser::ExportKind::Let => crate::ExportKind::Let,
271        crate::parser::ExportKind::Type => crate::ExportKind::Const,
272        crate::parser::ExportKind::Interface => crate::ExportKind::Const,
273        crate::parser::ExportKind::Enum => crate::ExportKind::Enum,
274        crate::parser::ExportKind::Namespace => crate::ExportKind::Namespace,
275        crate::parser::ExportKind::Default => crate::ExportKind::Default,
276    }
277}
278
279fn find_unused_dependencies(
280    graph: &ModuleGraph,
281    config: &ResolvedConfig,
282    options: &AnalyzeOptions,
283) -> (Vec<UnusedDependency>, Vec<UnusedDependency>) {
284    let mut unused_deps = Vec::new();
285    let mut unused_dev_deps = Vec::new();
286
287    let Some(ref pkg) = config.package_json else {
288        return (unused_deps, unused_dev_deps);
289    };
290
291    let used_packages = graph.get_used_packages();
292    let package_json_path = config.root.join("package.json");
293
294    let ignore_deps: HashSet<&str> = config
295        .config
296        .ignore_dependencies
297        .iter()
298        .map(|s| s.as_str())
299        .collect();
300
301    for dep_name in pkg.dependencies.keys() {
302        if ignore_deps.contains(dep_name.as_str()) {
303            continue;
304        }
305
306        if !used_packages.contains(dep_name) && !is_implicit_dependency(dep_name) {
307            unused_deps.push(UnusedDependency {
308                name: dep_name.clone(),
309                package_json: package_json_path.clone(),
310                workspace: None,
311                is_dev: false,
312            });
313        }
314    }
315
316    if !options.production {
317        for dep_name in pkg.dev_dependencies.keys() {
318            if ignore_deps.contains(dep_name.as_str()) {
319                continue;
320            }
321
322            if !used_packages.contains(dep_name) && !is_dev_tool_dependency(dep_name) {
323                unused_dev_deps.push(UnusedDependency {
324                    name: dep_name.clone(),
325                    package_json: package_json_path.clone(),
326                    workspace: None,
327                    is_dev: true,
328                });
329            }
330        }
331    }
332
333    unused_deps.sort_by(|a, b| a.name.cmp(&b.name));
334    unused_dev_deps.sort_by(|a, b| a.name.cmp(&b.name));
335
336    (unused_deps, unused_dev_deps)
337}
338
339fn is_implicit_dependency(name: &str) -> bool {
340    matches!(
341        name,
342        "typescript" | "@types/node" | "tslib" | "core-js" | "regenerator-runtime"
343    )
344}
345
346fn is_dev_tool_dependency(name: &str) -> bool {
347    name.starts_with("@types/")
348        || name.starts_with("eslint")
349        || name.starts_with("prettier")
350        || matches!(
351            name,
352            "typescript"
353                | "jest"
354                | "vitest"
355                | "mocha"
356                | "chai"
357                | "ts-node"
358                | "ts-jest"
359                | "webpack"
360                | "vite"
361                | "rollup"
362                | "esbuild"
363                | "parcel"
364                | "babel"
365                | "swc"
366                | "husky"
367                | "lint-staged"
368                | "commitlint"
369        )
370}
371
372fn find_unlisted_dependencies(
373    graph: &ModuleGraph,
374    config: &ResolvedConfig,
375    options: &AnalyzeOptions,
376) -> Vec<UnlistedDependency> {
377    let mut unlisted = Vec::new();
378
379    let Some(ref pkg) = config.package_json else {
380        return unlisted;
381    };
382
383    let all_deps: HashSet<&str> = if options.strict {
384        pkg.dependencies.keys().map(|s| s.as_str()).collect()
385    } else {
386        pkg.dependencies
387            .keys()
388            .chain(pkg.dev_dependencies.keys())
389            .chain(pkg.peer_dependencies.keys())
390            .chain(pkg.optional_dependencies.keys())
391            .map(|s| s.as_str())
392            .collect()
393    };
394
395    for (package_name, used_in_files) in &graph.external_imports {
396        if all_deps.contains(package_name.as_str()) {
397            continue;
398        }
399
400        if is_builtin_module(package_name) {
401            continue;
402        }
403
404        if package_name.starts_with("@types/") {
405            continue;
406        }
407
408        unlisted.push(UnlistedDependency {
409            name: package_name.clone(),
410            used_in: used_in_files.clone(),
411        });
412    }
413
414    unlisted.sort_by(|a, b| a.name.cmp(&b.name));
415    unlisted
416}
417
418fn is_builtin_module(name: &str) -> bool {
419    matches!(
420        name,
421        "assert"
422            | "buffer"
423            | "child_process"
424            | "cluster"
425            | "console"
426            | "constants"
427            | "crypto"
428            | "dgram"
429            | "dns"
430            | "domain"
431            | "events"
432            | "fs"
433            | "http"
434            | "http2"
435            | "https"
436            | "inspector"
437            | "module"
438            | "net"
439            | "os"
440            | "path"
441            | "perf_hooks"
442            | "process"
443            | "punycode"
444            | "querystring"
445            | "readline"
446            | "repl"
447            | "stream"
448            | "string_decoder"
449            | "sys"
450            | "timers"
451            | "tls"
452            | "trace_events"
453            | "tty"
454            | "url"
455            | "util"
456            | "v8"
457            | "vm"
458            | "wasi"
459            | "worker_threads"
460            | "zlib"
461    ) || name.starts_with("node:")
462}
463
464fn find_unresolved_imports(
465    graph: &ModuleGraph,
466    config: &ResolvedConfig,
467) -> Vec<UnresolvedImport> {
468    let mut unresolved = Vec::new();
469
470    let Some(ref pkg) = config.package_json else {
471        return unresolved;
472    };
473
474    let all_deps: HashSet<&str> = pkg
475        .dependencies
476        .keys()
477        .chain(pkg.dev_dependencies.keys())
478        .chain(pkg.peer_dependencies.keys())
479        .chain(pkg.optional_dependencies.keys())
480        .map(|s| s.as_str())
481        .collect();
482
483    for module in graph.modules.values() {
484        for import in &module.imports {
485            let specifier = &import.original.specifier;
486
487            if specifier.starts_with("./") || specifier.starts_with("../") {
488                if import.resolved_path.is_none() {
489                    unresolved.push(UnresolvedImport {
490                        path: module.path.clone(),
491                        specifier: specifier.clone(),
492                        line: import.original.line,
493                        col: import.original.col,
494                    });
495                }
496            } else if let Some(ref pkg_name) = import.package_name {
497                if !all_deps.contains(pkg_name.as_str()) && !is_builtin_module(pkg_name) {
498                    continue;
499                }
500                if import.resolved_path.is_none() && !is_builtin_module(pkg_name) {
501                    unresolved.push(UnresolvedImport {
502                        path: module.path.clone(),
503                        specifier: specifier.clone(),
504                        line: import.original.line,
505                        col: import.original.col,
506                    });
507                }
508            }
509        }
510    }
511
512    unresolved.sort_by(|a, b| (&a.path, a.line).cmp(&(&b.path, b.line)));
513    unresolved
514}