use std::collections::{HashMap, HashSet};
use std::path::Path;
use std::time::UNIX_EPOCH;
use chrono::{DateTime, Utc};
use uuid::Uuid;
use walkdir::WalkDir;
use crate::graph::layers::detect_layer;
use crate::graph::types::{RelationType, Relationship, Symbol, SymbolType};
use crate::indexer::supported_extensions;
use crate::parser::python::{resolve_python_import, RawImport};
use crate::parser::{parse_file, FileParseResult};
#[derive(Debug, Default)]
pub struct IndexResult {
pub symbols: Vec<Symbol>,
pub relationships: Vec<Relationship>,
pub files_parsed: usize,
pub files_skipped: usize,
}
pub fn index_directory(
root_dir: impl AsRef<Path>,
project: &str,
since: Option<DateTime<Utc>>,
) -> anyhow::Result<IndexResult> {
let root_dir = root_dir.as_ref();
let mut result = IndexResult::default();
let mut file_results: Vec<FileParseResult> = Vec::new();
for entry in WalkDir::new(root_dir)
.follow_links(false)
.into_iter()
.filter_entry(|e| !should_skip(e.path()))
{
let entry = match entry {
Ok(e) => e,
Err(err) => {
tracing::warn!("walkdir error: {err}");
continue;
}
};
if !entry.file_type().is_file() {
continue;
}
let path = entry.path();
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !supported_extensions().contains(&ext.as_str()) {
continue;
}
let mtime = file_mtime(path);
if let Some(since_dt) = since {
if mtime <= since_dt {
result.files_skipped += 1;
continue;
}
}
let source = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(err) => {
tracing::warn!("failed to read {}: {err}", path.display());
continue;
}
};
let file_path = path.to_string_lossy().to_string();
let file_result: FileParseResult = match parse_file(&ext, &file_path, &source, project, mtime) {
Some(r) => r,
None => {
tracing::warn!("no parser for extension '{ext}' in {file_path}");
continue;
}
};
result.files_parsed += 1;
file_results.push(file_result);
}
let mut path_stem_to_uuid: HashMap<String, Uuid> = HashMap::new();
for fr in &file_results {
for sym in &fr.symbols {
if sym.symbol_type == SymbolType::File {
let p = sym.file_path.as_str();
let stem = strip_source_extension(p);
path_stem_to_uuid.insert(stem.to_string(), sym.id);
path_stem_to_uuid.insert(p.to_string(), sym.id);
}
}
}
let mut import_resolution: HashMap<(Uuid, Uuid), Uuid> = HashMap::new();
let mut resolved_count = 0usize;
for fr in &file_results {
let file_path = fr
.symbols
.first()
.filter(|s| s.symbol_type == SymbolType::File)
.map(|s| s.file_path.as_str())
.unwrap_or("");
for raw in &fr.raw_imports {
let placeholder_id = Uuid::new_v5(&Uuid::NAMESPACE_OID, raw.module_raw.as_bytes());
if let Some(resolved_uuid) =
resolve_import_to_uuid(file_path, raw, &path_stem_to_uuid)
{
import_resolution.insert((raw.source_id, placeholder_id), resolved_uuid);
resolved_count += 1;
}
}
}
let mut synthetic_to_real: HashMap<Uuid, Vec<Uuid>> = HashMap::new();
for fr in &file_results {
for s in &fr.symbols {
if matches!(
s.symbol_type,
SymbolType::Function | SymbolType::Method | SymbolType::Class | SymbolType::Field
) {
let key = Uuid::new_v5(&Uuid::NAMESPACE_OID, s.name.as_bytes());
synthetic_to_real.entry(key).or_default().push(s.id);
}
}
}
let file_results: Vec<_> = file_results
.into_iter()
.map(|mut fr| {
let file_path = fr
.symbols
.first()
.filter(|s| s.symbol_type == SymbolType::File)
.map(|s| s.file_path.as_str())
.unwrap_or("");
let layer = detect_layer(file_path);
for sym in &mut fr.symbols {
sym.layer = layer.clone();
}
fr
})
.collect();
for fr in file_results {
result.symbols.extend(fr.symbols);
for rel in fr.relationships {
if rel.rel_type == RelationType::Imports {
let key = (rel.source_id, rel.target_id);
if let Some(&resolved_target) = import_resolution.get(&key) {
result.relationships.push(Relationship {
source_id: rel.source_id,
target_id: resolved_target,
rel_type: RelationType::Imports,
confidence: 1.0,
});
continue;
}
result.relationships.push(rel);
} else if rel.rel_type == RelationType::Calls
|| rel.rel_type == RelationType::UsesType
{
if let Some(real_ids) = synthetic_to_real.get(&rel.target_id) {
let conf = rel.confidence / real_ids.len() as f32;
for &real_id in real_ids {
result.relationships.push(Relationship {
source_id: rel.source_id,
target_id: real_id,
rel_type: rel.rel_type.clone(),
confidence: conf,
});
}
} else {
result.relationships.push(rel);
}
} else if rel.rel_type == RelationType::References {
if let Some(real_ids) = synthetic_to_real.get(&rel.target_id) {
if real_ids.len() == 1 {
let mut rel = rel;
rel.target_id = real_ids[0];
rel.confidence = (rel.confidence * 0.8).min(1.0);
result.relationships.push(rel);
}
} else {
result.relationships.push(rel);
}
} else {
let mut rel = rel;
if let Some(real_ids) = synthetic_to_real.get(&rel.target_id) {
if let Some(&first) = real_ids.first() {
rel.target_id = first;
}
}
result.relationships.push(rel);
}
}
}
let sym_to_file: HashMap<Uuid, &str> = result
.symbols
.iter()
.map(|s| (s.id, s.file_path.as_str()))
.collect();
let file_to_sym: HashMap<&str, Uuid> = result
.symbols
.iter()
.filter(|s| s.symbol_type == SymbolType::File)
.map(|s| (s.file_path.as_str(), s.id))
.collect();
let mut import_pairs: HashSet<(Uuid, Uuid)> = result
.relationships
.iter()
.filter(|r| r.rel_type == RelationType::Imports)
.map(|r| (r.source_id, r.target_id))
.collect();
let mut derived_imports = Vec::new();
for rel in &result.relationships {
if !matches!(
rel.rel_type,
RelationType::Inherits | RelationType::Calls | RelationType::UsesType
) {
continue;
}
let src_file = sym_to_file
.get(&rel.source_id)
.and_then(|fp| file_to_sym.get(fp))
.copied();
let tgt_file = sym_to_file
.get(&rel.target_id)
.and_then(|fp| file_to_sym.get(fp))
.copied();
if let (Some(sf), Some(tf)) = (src_file, tgt_file) {
if sf != tf && !import_pairs.contains(&(sf, tf)) {
import_pairs.insert((sf, tf));
derived_imports.push(Relationship {
source_id: sf,
target_id: tf,
rel_type: RelationType::Imports,
confidence: 0.6,
});
}
}
}
let derived_count = derived_imports.len();
result.relationships.extend(derived_imports);
tracing::info!(
"Indexed {} - {} files parsed, {} skipped, {} symbols, {} relationships ({} imports resolved, {} derived)",
root_dir.display(),
result.files_parsed,
result.files_skipped,
result.symbols.len(),
result.relationships.len(),
resolved_count,
derived_count,
);
Ok(result)
}
fn resolve_import_to_uuid(
importing_file: &str,
raw: &RawImport,
path_stem_to_uuid: &HashMap<String, Uuid>,
) -> Option<Uuid> {
if raw.is_relative {
resolve_relative_import(importing_file, raw, path_stem_to_uuid)
} else {
resolve_absolute_import(&raw.module_path, path_stem_to_uuid)
}
}
fn resolve_relative_import(
importing_file: &str,
raw: &RawImport,
path_stem_to_uuid: &HashMap<String, Uuid>,
) -> Option<Uuid> {
let is_rust_path = raw.dot_count == 0 && raw.module_raw.contains("::");
let is_ts_path = !is_rust_path
&& (raw.module_raw.starts_with("./")
|| raw.module_raw.starts_with("../")
|| raw.module_path.starts_with("./")
|| raw.module_path.starts_with("../"));
if is_rust_path {
return resolve_rust_crate_import(&raw.module_path, path_stem_to_uuid);
}
let resolved_stem = if is_ts_path {
resolve_ts_relative(importing_file, &raw.module_path)?
} else {
resolve_python_import(importing_file, raw.dot_count, &raw.module_path)?
};
try_path_variants(&resolved_stem, path_stem_to_uuid)
}
fn resolve_rust_crate_import(
slash_path: &str,
path_stem_to_uuid: &HashMap<String, Uuid>,
) -> Option<Uuid> {
if slash_path.is_empty() {
return None;
}
let mut current = slash_path;
loop {
let best = path_stem_to_uuid
.iter()
.filter_map(|(file_stem, &uuid)| {
if !file_stem.ends_with(current) {
return None;
}
let remainder = &file_stem[..file_stem.len() - current.len()];
if remainder.is_empty() || remainder.ends_with('/') {
Some((file_stem.len(), uuid))
} else {
None
}
})
.min_by_key(|(len, _)| *len);
if let Some((_, uuid)) = best {
return Some(uuid);
}
if let Some(last_slash) = current.rfind('/') {
current = ¤t[..last_slash];
} else {
break;
}
}
None
}
fn resolve_ts_relative(importing_file: &str, module_specifier: &str) -> Option<String> {
let file = Path::new(importing_file);
let file_dir = file.parent()?;
let joined = file_dir.join(module_specifier);
let normalized = normalize_path(&joined);
Some(normalized.to_string_lossy().to_string())
}
fn resolve_absolute_import(
module_path: &str,
path_stem_to_uuid: &HashMap<String, Uuid>,
) -> Option<Uuid> {
let slash_path = module_path.replace('.', "/");
for (file_stem, &uuid) in path_stem_to_uuid {
if file_stem.ends_with(&slash_path) {
let remainder = &file_stem[..file_stem.len() - slash_path.len()];
if remainder.is_empty() || remainder.ends_with('/') {
return Some(uuid);
}
}
}
if slash_path.contains('/') {
let after_first_slash = slash_path.splitn(2, '/').nth(1).unwrap_or("");
if !after_first_slash.is_empty() {
if let Some(uuid) = resolve_rust_crate_import(after_first_slash, path_stem_to_uuid) {
return Some(uuid);
}
}
}
None
}
fn try_path_variants(stem: &str, path_stem_to_uuid: &HashMap<String, Uuid>) -> Option<Uuid> {
let candidates = [
stem.to_string(),
format!("{stem}.py"),
format!("{stem}/__init__.py"),
format!("{stem}.ts"),
format!("{stem}.tsx"),
format!("{stem}/index.ts"),
format!("{stem}/index.tsx"),
format!("{stem}.js"),
format!("{stem}/index.js"),
format!("{stem}.rs"),
format!("{stem}/mod.rs"),
format!("{stem}.go"),
format!("{stem}.rb"),
format!("{stem}.java"),
format!("{stem}.kt"),
format!("{stem}.kts"),
];
for candidate in &candidates {
if let Some(&uuid) = path_stem_to_uuid.get(candidate.as_str()) {
return Some(uuid);
}
}
let dir_prefix = format!("{stem}/");
let mut best: Option<(&str, &Uuid)> = None;
for (key, uuid) in path_stem_to_uuid {
if !key.starts_with(&dir_prefix) {
continue;
}
let after_prefix = &key[dir_prefix.len()..];
if after_prefix.contains('/') {
continue;
}
let is_better = match best {
None => true,
Some((prev_key, _)) => key.len() < prev_key.len(),
};
if is_better {
best = Some((key.as_str(), uuid));
}
}
if let Some((_, &uuid)) = best {
return Some(uuid);
}
None
}
fn strip_source_extension(path: &str) -> &str {
let extensions = [
".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".rs", ".rb", ".go", ".java",
".kt", ".kts",
];
for ext in &extensions {
if path.ends_with(ext) {
return &path[..path.len() - ext.len()];
}
}
path
}
fn normalize_path(path: &Path) -> std::path::PathBuf {
let mut components: Vec<std::path::Component> = Vec::new();
for component in path.components() {
use std::path::Component;
match component {
Component::CurDir => {}
Component::ParentDir => {
components.pop();
}
c => components.push(c),
}
}
components.iter().collect()
}
fn file_mtime(path: &Path) -> DateTime<Utc> {
std::fs::metadata(path)
.and_then(|m| m.modified())
.map(|t| {
let secs = t
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64;
DateTime::from_timestamp(secs, 0).unwrap_or_else(|| Utc::now())
})
.unwrap_or_else(|_| Utc::now())
}
fn should_skip(path: &Path) -> bool {
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
return false;
};
if name.starts_with('.') {
return true;
}
matches!(
name,
"node_modules"
| "__pycache__"
| "target"
| "dist"
| "build"
| ".git"
| "vendor"
| "venv"
| ".venv"
| "log"
| "logs"
| "tmp"
| "coverage"
| "public"
| "storage"
| ".bundle"
| "e2e"
| "playwright-report"
)
}