use crate::config::{is_typescript_project, EXTENSIONS, IGNORE_FOLDERS, TYPESCRIPT_EXTENSIONS};
use glob::glob;
use indicatif::ProgressBar;
use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::fs::{self, File};
use std::io::{BufRead, BufReader};
use std::path::Path;
use std::process::Command;
fn normalize_path(path: &Path) -> String {
let path_str = fs::canonicalize(path)
.map(|p| p.display().to_string())
.unwrap_or_else(|_| path.display().to_string());
if cfg!(target_os = "macos") && path_str.starts_with("/private") {
path_str.replacen("/private", "", 1)
} else {
path_str
}
}
fn get_typescript_unused_imports() -> HashSet<String> {
let mut unused_imports = HashSet::new();
if !is_typescript_project() {
return unused_imports;
}
let output = Command::new("tsc")
.args(["--noEmit", "--pretty", "false", "--noUnusedLocals"])
.output();
match output {
Ok(output) if output.status.success() => return unused_imports,
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
for line in stderr.lines() {
if line.contains("TS6133") {
if let Some((file_path, line_number)) = extract_file_and_line(line) {
if let Some(package_name) =
extract_package_name_from_file_line(&file_path, line_number)
{
unused_imports.insert(package_name);
}
}
}
}
}
Err(_) => {
eprintln!("Warning: Failed to run tsc. Unused imports may not be detected.");
return unused_imports;
}
}
unused_imports
}
pub fn scan_files(
dependencies: &HashSet<String>,
pb: &ProgressBar,
) -> (HashSet<String>, Vec<String>, Vec<String>) {
let patterns: Vec<String> = EXTENSIONS
.iter()
.map(|ext| format!("**/*.{}", ext))
.collect();
let mut used_packages = HashSet::new();
let mut ignored_files = Vec::new();
let mut explored_files = Vec::new();
let mut seen_paths = HashSet::new();
let mut typescript_files = Vec::new();
for pattern in patterns {
for entry in glob(&pattern).expect("Failed to read glob pattern") {
pb.inc(1);
match entry {
Ok(path) if !path.is_dir() && !path.is_symlink() => {
let abs_path = normalize_path(&path);
if seen_paths.contains(&abs_path) {
continue;
}
seen_paths.insert(abs_path.clone());
if should_ignore(&path) {
ignored_files.push(abs_path);
continue;
}
let extension = path.extension().and_then(OsStr::to_str);
if extension.map_or(false, |ext| TYPESCRIPT_EXTENSIONS.contains(&ext)) {
typescript_files.push(abs_path.clone());
} else if let Ok(content) = fs::read_to_string(&path) {
used_packages.extend(find_dependencies_in_content(&content, dependencies));
}
explored_files.push(abs_path);
}
Ok(path) => {
let abs_path = normalize_path(&path);
if should_ignore(&path) && !seen_paths.contains(&abs_path) {
ignored_files.push(abs_path.clone());
seen_paths.insert(abs_path);
}
}
Err(_) => {}
}
pb.tick();
}
}
let unused_imports = get_typescript_unused_imports();
for path in &typescript_files {
if let Ok(content) = fs::read_to_string(path) {
let found = find_dependencies_in_content(&content, dependencies);
for dep in found {
if !unused_imports.contains(&dep) {
used_packages.insert(dep);
}
}
}
}
(used_packages, explored_files, ignored_files)
}
fn find_dependencies_in_content(content: &str, dependencies: &HashSet<String>) -> HashSet<String> {
let mut found = HashSet::new();
for dep in dependencies {
let dep_pattern = regex::escape(dep);
let regex_str = format!(
r#"(?m)(?:import\s*(?:\{{[^}}]*\}}|\w*)\s*from\s*['"]{}['"]|require\s*\(\s*['"]{}['"]\s*\)|import\s*['"]{}['"]\s*;)"#,
dep_pattern, dep_pattern, dep_pattern
);
let regex = Regex::new(®ex_str).unwrap();
if regex.is_match(content) {
found.insert(dep.clone());
}
}
found
}
fn should_ignore(path: &Path) -> bool {
path.components().any(|component| {
IGNORE_FOLDERS
.iter()
.any(|folder| component.as_os_str() == OsStr::new(folder))
})
}
fn extract_file_and_line(diagnostic: &str) -> Option<(String, usize)> {
static TS_REGEX: Lazy<Regex> = Lazy::new(|| {
let exts = TYPESCRIPT_EXTENSIONS
.iter()
.map(|ext| regex::escape(ext))
.collect::<Vec<_>>()
.join("|");
let pattern = format!(r"^(.*\.({}))\((\d+),\d+\)", exts);
Regex::new(&pattern).expect("Failed to compile regex")
});
let caps = TS_REGEX.captures(diagnostic)?;
let file_path = caps.get(1)?.as_str().to_string();
let line_number: usize = caps.get(3)?.as_str().parse().ok()?;
Some((file_path, line_number))
}
fn extract_package_name_from_file_line(file_path: &str, line_number: usize) -> Option<String> {
let path = Path::new(file_path);
let file = File::open(path).ok()?;
let reader = BufReader::new(file);
let total_lines = reader.lines().count();
if line_number == 0 || line_number > total_lines {
return None;
}
let reader = BufReader::new(File::open(path).ok()?);
let import_line = reader.lines().nth(line_number - 1)?.ok()?;
let import_line = import_line.trim();
if import_line.is_empty() || import_line.starts_with("//") || import_line.starts_with("/*") {
return None;
}
let re_named = Regex::new(r#"from\s+['"]([^'"\s]+)['"]"#).unwrap();
let re_default = Regex::new(r#"import\s+([^\s,]+)\s+from\s+['"]([^'"\s]+)['"]"#).unwrap();
let re_namespace =
Regex::new(r#"import\s+\*\s+as\s+([^\s]+)\s+from\s+['"]([^'"\s]+)['"]"#).unwrap();
let re_combined =
Regex::new(r#"import\s+([^\s,]+)\s*,\s*{([^}]+)}\s+from\s+['"]([^'"\s]+)['"]"#).unwrap();
let re_side_effect = Regex::new(r#"import\s+['"]([^'"\s]+)['"]"#).unwrap();
if let Some(caps) = re_named.captures(import_line) {
return Some(caps[1].to_string());
}
if let Some(caps) = re_default.captures(import_line) {
return Some(caps[2].to_string());
}
if let Some(caps) = re_namespace.captures(import_line) {
return Some(caps[2].to_string());
}
if let Some(caps) = re_combined.captures(import_line) {
return Some(caps[3].to_string());
}
if let Some(caps) = re_side_effect.captures(import_line) {
return Some(caps[1].to_string());
}
None
}