use anyhow::Result;
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use crate::model::{Component, ComponentKind, Prop};
pub fn scan_components(root: &Path) -> Result<Vec<Component>> {
let mut components = Vec::new();
let mut file_components: HashMap<PathBuf, Vec<String>> = HashMap::new();
let walker = ignore::WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.add_custom_ignore_filename(".gitignore")
.filter_entry(|e| {
if e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
let name = e.file_name().to_string_lossy();
!matches!(name.as_ref(), "node_modules" | "dist" | "build" | ".next" | ".nuxt" | ".svelte-kit" | ".git" | ".svn" | "vendor" | "coverage" | "__pycache__" | ".cache")
} else {
true
}
})
.build();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
if !is_component_file(path) {
continue;
}
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let file_comps = extract_components(path, &content);
for comp in file_comps {
file_components.entry(path.to_path_buf()).or_default().push(comp.name.clone());
components.push(comp);
}
}
build_references(&mut components, root);
Ok(components)
}
fn is_component_file(path: &Path) -> bool {
if is_test_file(path) {
return false;
}
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
matches!(ext.as_str(), "jsx" | "tsx" | "vue" | "svelte" | "astro")
} else {
if let Some(ext) = path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
if matches!(ext.as_str(), "js" | "ts") {
if let Some(name) = path.file_stem() {
let name = name.to_string_lossy();
return name.chars().next().map(|c| c.is_uppercase()).unwrap_or(false);
}
}
}
false
}
}
fn is_test_file(path: &Path) -> bool {
let path_str = path.to_string_lossy();
if path_str.contains("/__tests__/") || path_str.contains("\\__tests\\") {
return true;
}
if let Some(name) = path.file_stem() {
let name = name.to_string_lossy();
if name.ends_with(".test") || name.ends_with(".spec") {
return true;
}
}
false
}
fn should_skip_dir(path: &Path) -> bool {
let dir_name = path.file_name().and_then(|s| s.to_str()).unwrap_or("");
matches!(dir_name, "node_modules" | "dist" | "build" | ".next" | ".nuxt" | ".svelte-kit" | ".git" | ".svn" | "vendor" | "coverage" | "__pycache__" | ".cache")
}
fn extract_components(file_path: &Path, content: &str) -> Vec<Component> {
let mut components = Vec::new();
if let Some(ext) = file_path.extension() {
let ext = ext.to_string_lossy().to_lowercase();
match ext.as_str() {
"vue" => return extract_vue_components(file_path, content),
"svelte" => return extract_svelte_components(file_path, content),
"astro" => return extract_astro_components(file_path, content),
_ => {} }
}
let patterns = vec![
(r"export\s+default\s+(?:async\s+)?function\s+(\w+)", ComponentKind::Function),
(r"export\s+const\s+(\w+)\s*=\s*(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>", ComponentKind::Arrow),
(r"export\s+(?:async\s+)?function\s+(\w+)", ComponentKind::Function),
(r"export\s+default\s+class\s+(\w+)", ComponentKind::Class),
(r"export\s+class\s+(\w+)", ComponentKind::Class),
(r"const\s+(\w+)\s*=\s*(?:\([^)]*\)|[a-zA-Z_]\w*)\s*=>", ComponentKind::Arrow),
(r"(?:async\s+)?function\s+(\w+)\s*\(", ComponentKind::Function),
];
let lines: Vec<&str> = content.lines().collect();
let mut seen_names: std::collections::HashSet<String> = std::collections::HashSet::new();
for (line_num, line) in lines.iter().enumerate() {
let line = line.trim();
for (pattern, kind) in &patterns {
if let Ok(re) = Regex::new(pattern) {
if let Some(caps) = re.captures(line) {
let name = caps[1].to_string();
if is_component_name(&name) && !seen_names.contains(&name) {
seen_names.insert(name.clone());
let props = extract_js_props(content, &name);
components.push(Component {
name,
file: file_path.to_path_buf(),
kind: kind.clone(),
props,
used_by: Vec::new(),
uses: Vec::new(),
line: line_num + 1,
});
}
}
}
}
}
components
}
fn extract_vue_components(file_path: &Path, content: &str) -> Vec<Component> {
let mut components = Vec::new();
let name = file_path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
if name.starts_with('_') || name == "index" {
if name == "index" {
if let Some(parent) = file_path.parent() {
let parent_name = parent.file_name()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
let props = extract_vue_props(content);
let script_line = find_script_line(content);
components.push(Component {
name: parent_name,
file: file_path.to_path_buf(),
kind: ComponentKind::Function,
props,
used_by: Vec::new(),
uses: Vec::new(),
line: script_line,
});
}
}
return components;
}
let props = extract_vue_props(content);
let script_line = find_script_line(content);
components.push(Component {
name,
file: file_path.to_path_buf(),
kind: ComponentKind::Function,
props,
used_by: Vec::new(),
uses: Vec::new(),
line: script_line,
});
components
}
fn extract_svelte_components(file_path: &Path, content: &str) -> Vec<Component> {
let mut components = Vec::new();
let name = file_path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
let props = extract_svelte_props(content);
let script_line = find_script_line(content);
components.push(Component {
name,
file: file_path.to_path_buf(),
kind: ComponentKind::Function,
props,
used_by: Vec::new(),
uses: Vec::new(),
line: script_line,
});
components
}
fn extract_astro_components(file_path: &Path, content: &str) -> Vec<Component> {
let mut components = Vec::new();
let name = file_path.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("Unknown")
.to_string();
let props = extract_astro_props(content);
let script_line = find_script_line(content);
components.push(Component {
name,
file: file_path.to_path_buf(),
kind: ComponentKind::Function,
props,
used_by: Vec::new(),
uses: Vec::new(),
line: script_line,
});
components
}
fn find_script_line(content: &str) -> usize {
let script_re = Regex::new(r"<script").expect("invalid regex pattern");
content.lines().enumerate()
.find(|(_, l)| script_re.is_match(l))
.map(|(i, _)| i + 1)
.unwrap_or(1)
}
fn is_component_name(name: &str) -> bool {
let skip_names = ["default", "props", "emits", "setup", "data", "methods", "computed", "watch"];
if skip_names.contains(&name) {
return false;
}
if name.chars().all(|c| c.is_uppercase() || c == '_' || c.is_numeric()) {
return false;
}
name.chars().next().map(|c| c.is_uppercase()).unwrap_or(false)
}
fn extract_js_props(content: &str, component_name: &str) -> Vec<Prop> {
let mut props = Vec::new();
let destructure_re = Regex::new(&format!(r"{}\s*\(\s*\{{([^}}]*)\}}", regex::escape(component_name))).expect("invalid regex pattern");
let type_re = Regex::new(&format!(r"{}\s*\(\s*(?:props|\{{[^}}]*\}})\s*:\s*\{{([^}}]*)\}}", regex::escape(component_name))).expect("invalid regex pattern");
let interface_re = Regex::new(r"(?:interface|type)\s+(?:Props|IProps)\s*(?:=\s*)?\{([^}]+)\}").expect("invalid regex pattern");
if let Some(caps) = destructure_re.captures(content) {
let props_str = &caps[1];
for prop in props_str.split(',') {
let prop = prop.trim();
if !prop.is_empty() {
let parts: Vec<&str> = prop.split(':').collect();
let name = parts[0].trim().to_string();
let type_annotation = if parts.len() > 1 {
Some(parts[1].trim().to_string())
} else {
None
};
props.push(Prop {
name,
type_annotation,
required: true,
});
}
}
} else if let Some(caps) = type_re.captures(content) {
let props_str = &caps[1];
for prop in props_str.split(',') {
let prop = prop.trim();
if !prop.is_empty() {
let parts: Vec<&str> = prop.split(':').collect();
let name = parts[0].trim().trim_matches('?').to_string();
let type_annotation = if parts.len() > 1 {
Some(parts[1].trim().to_string())
} else {
None
};
let required = !prop.contains('?');
props.push(Prop {
name,
type_annotation,
required,
});
}
}
} else if let Some(caps) = interface_re.captures(content) {
let props_str = &caps[1];
for prop in props_str.split('\n') {
let prop = prop.trim().trim_end_matches(';');
if !prop.is_empty() && !prop.starts_with("//") {
let parts: Vec<&str> = prop.split(':').collect();
if parts.len() >= 2 {
let name = parts[0].trim().trim_matches('?').to_string();
let type_annotation = Some(parts[1].trim().to_string());
let required = !prop.contains('?');
props.push(Prop {
name,
type_annotation,
required,
});
}
}
}
}
props
}
fn extract_vue_props(content: &str) -> Vec<Prop> {
let mut props = Vec::new();
let type_props_re = Regex::new(r"defineProps\s*<\s*\{([^}]+)\}\s*>").expect("invalid regex pattern");
let obj_props_re = Regex::new(r"defineProps\s*\(\s*\{([^}]+)\}\s*\)").expect("invalid regex pattern");
let options_re = Regex::new(r"props\s*:\s*\{([^}]+)\}").expect("invalid regex pattern");
if let Some(caps) = type_props_re.captures(content) {
let props_str = &caps[1];
for prop in props_str.split(',') {
let prop = prop.trim();
if !prop.is_empty() {
let parts: Vec<&str> = prop.split(':').collect();
let name = parts[0].trim().trim_matches('?').to_string();
let type_annotation = if parts.len() > 1 {
Some(parts[1].trim().to_string())
} else {
None
};
let required = !prop.contains('?');
props.push(Prop {
name,
type_annotation,
required,
});
}
}
} else if let Some(caps) = obj_props_re.captures(content) {
let props_str = &caps[1];
for prop in props_str.split(',') {
let prop = prop.trim();
if !prop.is_empty() {
let parts: Vec<&str> = prop.split(':').collect();
let name = parts[0].trim().to_string();
let type_annotation = if parts.len() > 1 {
Some(parts[1].trim().to_string())
} else {
None
};
props.push(Prop {
name,
type_annotation,
required: true,
});
}
}
} else if let Some(caps) = options_re.captures(content) {
let props_str = &caps[1];
for prop in props_str.split(',') {
let prop = prop.trim();
if !prop.is_empty() {
let parts: Vec<&str> = prop.split(':').collect();
let name = parts[0].trim().to_string();
let type_annotation = if parts.len() > 1 {
Some(parts[1].trim().to_string())
} else {
None
};
props.push(Prop {
name,
type_annotation,
required: true,
});
}
}
}
props
}
fn extract_svelte_props(content: &str) -> Vec<Prop> {
let mut props = Vec::new();
let prop_re = Regex::new(r"export\s+let\s+(\w+)(?:\s*:\s*(\w+))?").expect("invalid regex pattern");
for caps in prop_re.captures_iter(content) {
let name = caps[1].to_string();
let type_annotation = caps.get(2).map(|m| m.as_str().to_string());
props.push(Prop {
name,
type_annotation,
required: true,
});
}
props
}
fn extract_astro_props(content: &str) -> Vec<Prop> {
let mut props = Vec::new();
let interface_re = Regex::new(r"interface\s+Props\s*\{([^}]+)\}").expect("invalid regex pattern");
if let Some(caps) = interface_re.captures(content) {
let props_str = &caps[1];
for prop in props_str.split('\n') {
let prop = prop.trim().trim_end_matches(';');
if !prop.is_empty() && !prop.starts_with("//") {
let parts: Vec<&str> = prop.split(':').collect();
if parts.len() >= 2 {
let name = parts[0].trim().trim_matches('?').to_string();
let type_annotation = Some(parts[1].trim().to_string());
let required = !prop.contains('?');
props.push(Prop {
name,
type_annotation,
required,
});
}
}
}
}
props
}
fn build_references(components: &mut Vec<Component>, root: &Path) {
let component_names: Vec<String> = components.iter().map(|c| c.name.clone()).collect();
let walker = ignore::WalkBuilder::new(root)
.hidden(false)
.git_ignore(true)
.add_custom_ignore_filename(".gitignore")
.filter_entry(|e| {
if e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) {
let name = e.file_name().to_string_lossy();
!matches!(name.as_ref(), "node_modules" | "dist" | "build" | ".next" | ".nuxt" | ".svelte-kit" | ".git" | ".svn" | "vendor" | "coverage" | "__pycache__" | ".cache")
} else {
true
}
})
.build();
let mut file_refs: HashMap<PathBuf, Vec<String>> = HashMap::new();
for entry in walker {
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().map(|ft| ft.is_file()).unwrap_or(false) {
continue;
}
let path = entry.path();
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
if is_test_file(path) {
continue;
}
for comp_name in &component_names {
let jsx_pattern = format!(r"<{}", regex::escape(comp_name));
let jsx_re = Regex::new(&jsx_pattern).expect("invalid regex pattern");
let import_pattern = format!(r"import\s+.*{}\s+.*from", regex::escape(comp_name));
let import_re = Regex::new(&import_pattern).expect("invalid regex pattern");
let default_import_pattern = format!(r"import\s+{}\s+from", regex::escape(comp_name));
let default_import_re = Regex::new(&default_import_pattern).expect("invalid regex pattern");
if jsx_re.is_match(&content) || import_re.is_match(&content) || default_import_re.is_match(&content) {
file_refs.entry(path.to_path_buf()).or_default().push(comp_name.clone());
}
}
}
let comp_files: HashMap<String, PathBuf> = components.iter()
.map(|c| (c.name.clone(), c.file.clone()))
.collect();
for comp in components.iter_mut() {
let comp_path = comp.file.to_string_lossy().to_string();
for (file_path, refs) in &file_refs {
let file_str = file_path.to_string_lossy().to_string();
if refs.contains(&comp.name) && file_str != comp_path {
comp.used_by.push(file_path.clone());
}
}
if let Some(refs) = file_refs.get(&comp.file) {
for ref_name in refs {
if ref_name != &comp.name && comp_files.contains_key(ref_name) {
comp.uses.push(ref_name.clone());
}
}
}
}
}