#[allow(clippy::wildcard_imports)]
use super::*;
pub(crate) fn build_edges_cached(
index: &mut ProjectIndex,
content_cache: &HashMap<String, String>,
) {
build_edges_with_cache(index, content_cache);
build_implicit_edges_with_cache(index, content_cache);
build_cochange_edges(index);
build_sibling_edges(index);
}
fn build_edges_with_cache(index: &mut ProjectIndex, content_cache: &HashMap<String, String>) {
index.edges.clear();
if crate::core::memory_guard::abort_requested() {
tracing::warn!("[graph_index: skipping edge-building due to memory pressure]");
return;
}
let root = normalize_project_root(&index.project_root);
let root_path = Path::new(&root);
let mut file_paths: Vec<String> = index.files.keys().cloned().collect();
file_paths.sort();
let resolver_ctx = import_resolver::ResolverContext::new(root_path, file_paths.clone());
const MAX_FILE_SIZE_FOR_EDGES: u64 = 2 * 1024 * 1024;
for (i, rel_path) in file_paths.iter().enumerate() {
if i.is_multiple_of(1000) && crate::core::memory_guard::is_under_pressure() {
tracing::warn!(
"[graph_index: stopping edge-building at file {i}/{} due to memory pressure]",
file_paths.len()
);
break;
}
let content = if let Some(cached) = content_cache.get(rel_path) {
std::borrow::Cow::Borrowed(cached.as_str())
} else {
let abs_path = root_path.join(rel_path.trim_start_matches(['/', '\\']));
if let Ok(meta) = abs_path.metadata() {
if meta.len() > MAX_FILE_SIZE_FOR_EDGES {
continue;
}
}
match std::fs::read_to_string(&abs_path) {
Ok(c) => std::borrow::Cow::Owned(c),
Err(_) => continue,
}
};
let ext = Path::new(rel_path)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let resolve_ext = match ext {
"vue" | "svelte" => "ts",
_ => ext,
};
let analysis_content = if ext == "vue" || ext == "svelte" {
if let Some(script) = crate::core::signatures_ts::sfc::extract_script_block(&content) {
std::borrow::Cow::Owned(script)
} else {
content
}
} else {
content
};
let imports = crate::core::deep_queries::analyze(&analysis_content, resolve_ext).imports;
if imports.is_empty() {
continue;
}
let resolved =
import_resolver::resolve_imports(&imports, rel_path, resolve_ext, &resolver_ctx);
for r in resolved {
if r.is_external {
continue;
}
if let Some(to) = r.resolved_path {
index.edges.push(IndexEdge {
from: rel_path.clone(),
to,
kind: "import".to_string(),
weight: 1.0,
});
}
}
}
index.edges.sort_by(|a, b| {
a.from
.cmp(&b.from)
.then_with(|| a.to.cmp(&b.to))
.then_with(|| a.kind.cmp(&b.kind))
});
index
.edges
.dedup_by(|a, b| a.from == b.from && a.to == b.to && a.kind == b.kind);
}
fn build_implicit_edges_with_cache(
index: &mut ProjectIndex,
content_cache: &HashMap<String, String>,
) {
let file_paths: Vec<String> = index.files.keys().cloned().collect();
let file_set: std::collections::HashSet<&str> = file_paths.iter().map(String::as_str).collect();
let mut new_edges: Vec<IndexEdge> = Vec::new();
for file in &file_paths {
let ext = Path::new(file.as_str())
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
match ext {
"rs" => {
collect_rust_mod_edges_cached(
file,
&file_set,
index,
&mut new_edges,
content_cache,
);
}
"go" => collect_go_package_edges(file, &file_paths, &mut new_edges),
"py" => collect_python_init_edges(file, &file_paths, &mut new_edges),
"ts" | "js" | "tsx" | "jsx" => {
collect_barrel_edges_cached(file, &file_set, index, &mut new_edges, content_cache);
}
_ => {}
}
}
index.edges.extend(new_edges);
}
fn collect_rust_mod_edges_cached(
file: &str,
file_set: &std::collections::HashSet<&str>,
index: &ProjectIndex,
edges: &mut Vec<IndexEdge>,
content_cache: &HashMap<String, String>,
) {
if !index.files.contains_key(file) {
return;
}
let content = if let Some(cached) = content_cache.get(file) {
std::borrow::Cow::Borrowed(cached.as_str())
} else {
let full_path = Path::new(&index.project_root).join(file);
match std::fs::read_to_string(&full_path) {
Ok(c) => std::borrow::Cow::Owned(c),
Err(_) => return,
}
};
let dir = Path::new(file)
.parent()
.map(|p| p.to_string_lossy().to_string());
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("mod ") || trimmed.contains('{') {
continue;
}
let mod_name = trimmed
.trim_start_matches("mod ")
.trim_start_matches("pub mod ")
.trim_start_matches("pub(crate) mod ")
.trim_end_matches(';')
.trim();
if mod_name.is_empty() || mod_name.contains(' ') {
continue;
}
let candidates = if let Some(ref d) = dir {
vec![
format!("{d}/{mod_name}.rs"),
format!("{d}/{mod_name}/mod.rs"),
]
} else {
vec![format!("{mod_name}.rs"), format!("{mod_name}/mod.rs")]
};
for candidate in candidates {
if file_set.contains(candidate.as_str()) {
edges.push(IndexEdge {
from: file.to_string(),
to: candidate,
kind: "module".to_string(),
weight: 0.8,
});
break;
}
}
}
}
fn collect_go_package_edges(file: &str, file_paths: &[String], edges: &mut Vec<IndexEdge>) {
let p = Path::new(file);
if p.extension().and_then(|e| e.to_str()) != Some("go") {
return;
}
if file.ends_with("_test.go") {
return;
}
let Some(dir) = p.parent().map(|d| d.to_string_lossy().to_string()) else {
return;
};
for other in file_paths {
if other == file {
continue;
}
let op = Path::new(other.as_str());
if op.extension().and_then(|e| e.to_str()) != Some("go") {
continue;
}
if other.ends_with("_test.go") {
continue;
}
let other_dir = op
.parent()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
if other_dir == dir {
edges.push(IndexEdge {
from: file.to_string(),
to: other.clone(),
kind: "package".to_string(),
weight: 0.5,
});
break;
}
}
}
fn collect_python_init_edges(file: &str, file_paths: &[String], edges: &mut Vec<IndexEdge>) {
let p = Path::new(file);
if p.file_name().and_then(|n| n.to_str()) != Some("__init__.py") {
return;
}
let Some(dir) = p.parent().map(|d| d.to_string_lossy().to_string()) else {
return;
};
for other in file_paths {
if other == file {
continue;
}
let op = Path::new(other.as_str());
if op.extension().and_then(|e| e.to_str()) != Some("py") {
continue;
}
let other_dir = op
.parent()
.map(|d| d.to_string_lossy().to_string())
.unwrap_or_default();
if other_dir == dir {
edges.push(IndexEdge {
from: file.to_string(),
to: other.clone(),
kind: "module".to_string(),
weight: 0.8,
});
}
}
}
fn collect_barrel_edges_cached(
file: &str,
file_set: &std::collections::HashSet<&str>,
index: &ProjectIndex,
edges: &mut Vec<IndexEdge>,
content_cache: &HashMap<String, String>,
) {
let basename = Path::new(file)
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
if basename != "index" {
return;
}
let content = if let Some(cached) = content_cache.get(file) {
std::borrow::Cow::Borrowed(cached.as_str())
} else {
let full_path = Path::new(&index.project_root).join(file);
match std::fs::read_to_string(&full_path) {
Ok(c) => std::borrow::Cow::Owned(c),
Err(_) => return,
}
};
let dir = Path::new(file)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let ext = Path::new(file)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("ts");
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.starts_with("export") || !trimmed.contains("from") {
continue;
}
if let Some(from_pos) = trimmed.find("from") {
let after = &trimmed[from_pos + 4..];
let source = after
.trim()
.trim_start_matches(['\'', '"'])
.trim_end_matches([';', '\'', '"'])
.trim_end_matches(['\'', '"']);
if source.starts_with("./") || source.starts_with("../") {
let resolved = if dir.is_empty() {
source.trim_start_matches("./").to_string()
} else {
format!("{dir}/{}", source.trim_start_matches("./"))
};
let candidates = vec![
format!("{resolved}.{ext}"),
format!("{resolved}/index.{ext}"),
resolved.clone(),
];
for candidate in candidates {
if file_set.contains(candidate.as_str()) {
edges.push(IndexEdge {
from: file.to_string(),
to: candidate,
kind: "reexport".to_string(),
weight: 0.8,
});
break;
}
}
}
}
}
}
fn build_cochange_edges(index: &mut ProjectIndex) {
let project_root = &index.project_root;
let output = match std::process::Command::new("git")
.args([
"log",
"--name-only",
"--pretty=format:---",
"--since=6 months",
"--",
".",
])
.current_dir(project_root)
.output()
{
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).to_string(),
_ => return,
};
let file_set: std::collections::HashSet<&str> =
index.files.keys().map(String::as_str).collect();
let connected: std::collections::HashSet<&str> = index
.edges
.iter()
.flat_map(|e| [e.from.as_str(), e.to.as_str()])
.collect();
let mut cooccurrence: HashMap<(String, String), u32> = HashMap::new();
let mut current_commit: Vec<&str> = Vec::new();
for line in output.lines() {
if line == "---" {
if current_commit.len() >= 2 && current_commit.len() <= 20 {
for i in 0..current_commit.len() {
for j in (i + 1)..current_commit.len() {
let a = current_commit[i];
let b = current_commit[j];
if !file_set.contains(a) || !file_set.contains(b) {
continue;
}
if connected.contains(a) && connected.contains(b) {
continue;
}
let key = if a < b {
(a.to_string(), b.to_string())
} else {
(b.to_string(), a.to_string())
};
*cooccurrence.entry(key).or_insert(0) += 1;
}
}
}
current_commit.clear();
} else if !line.is_empty() {
current_commit.push(line.trim());
}
}
let mut cochange_edges: Vec<IndexEdge> = cooccurrence
.into_iter()
.filter(|(_, count)| *count >= 5)
.map(|((from, to), _)| IndexEdge {
from,
to,
kind: "cochange".to_string(),
weight: 0.5,
})
.collect();
cochange_edges.sort_by(|a, b| a.from.cmp(&b.from).then_with(|| a.to.cmp(&b.to)));
cochange_edges.truncate(500);
index.edges.extend(cochange_edges);
}
fn build_sibling_edges(index: &mut ProjectIndex) {
let connected: std::collections::HashSet<&str> = index
.edges
.iter()
.flat_map(|e| [e.from.as_str(), e.to.as_str()])
.collect();
let file_paths: Vec<String> = index.files.keys().cloned().collect();
let mut new_edges: Vec<IndexEdge> = Vec::new();
for file in &file_paths {
if connected.contains(file.as_str()) {
continue;
}
let ext = Path::new(file.as_str())
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let dir = Path::new(file.as_str())
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
for other in &file_paths {
if other == file {
continue;
}
let other_ext = Path::new(other.as_str())
.extension()
.and_then(|e| e.to_str())
.unwrap_or("");
let other_dir = Path::new(other.as_str())
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if other_ext == ext && other_dir == dir {
new_edges.push(IndexEdge {
from: file.clone(),
to: other.clone(),
kind: "sibling".to_string(),
weight: 0.2,
});
break; }
}
}
index.edges.extend(new_edges);
}