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}