use std::path::Path;
use ahash::AHashMap;
use crate::index::IndexDb;
use crate::intel::model::{ExportEdge, ImportEdge, ResolvedEdge};
use crate::intel::resolver::SpecifierResolver;
use crate::store::Store;
pub struct FileFacts {
pub imports: Vec<ImportEdge>,
pub exports: Vec<ExportEdge>,
pub import_uses: Vec<ResolvedEdge>,
}
const COMMIT_BATCH: usize = 256;
const MAX_REEXPORT_HOPS: usize = 8;
fn resolve_export_transitively(
root: &Path,
store: &Store,
facts: &AHashMap<String, FileFacts>,
export_maps: &AHashMap<&str, AHashMap<&str, u32>>,
resolver: &SpecifierResolver,
target_rel: crate::path::RelPath,
wanted: &str,
) -> Option<(crate::path::RelPath, u32)> {
let mut current_rel = target_rel;
let mut wanted = wanted.to_string();
let mut visited: ahash::AHashSet<(String, String)> = ahash::AHashSet::new();
for _ in 0..MAX_REEXPORT_HOPS {
let current_key = current_rel.as_str()?.to_string();
if !visited.insert((current_key.clone(), wanted.clone())) {
return None;
}
if let Some(&name_start) = export_maps
.get(current_key.as_str())
.and_then(|m| m.get(wanted.as_str()))
{
return Some((current_rel, name_start));
}
let reexport = facts
.get(¤t_key)?
.imports
.iter()
.find(|import| !import.is_type && import.local == wanted)?;
let next_rel = resolver.resolve(root, ¤t_key, reexport)?;
store.lookup(&next_rel)?;
wanted = reexport.imported.clone().unwrap_or(wanted);
current_rel = next_rel;
}
None
}
pub fn stitch_cross_file_edges(root: &Path, store: &Store, index_db: &IndexDb, facts: &AHashMap<String, FileFacts>) {
if facts.is_empty() {
return;
}
let export_maps: AHashMap<&str, AHashMap<&str, u32>> = facts
.iter()
.filter(|(_, f)| !f.exports.is_empty())
.map(|(key, f)| {
let by_name: AHashMap<&str, u32> = f.exports.iter().map(|e| (e.name.as_str(), e.name_start)).collect();
(key.as_str(), by_name)
})
.collect();
let mut resolvers: AHashMap<String, Option<SpecifierResolver>> = AHashMap::new();
let mut writer = index_db.writer();
let mut edges = 0usize;
for (importer_key, importer_facts) in facts {
if importer_facts.imports.is_empty() {
continue;
}
let Some(language) = store.lookup(importer_key.as_str()).map(|e| e.language.clone()) else {
continue;
};
let resolver = resolvers
.entry(language)
.or_insert_with_key(|lang| SpecifierResolver::for_language(lang));
let Some(resolver) = resolver.as_ref() else {
continue;
};
let importer_rel = crate::path::RelPath::from(importer_key.as_str());
for import in &importer_facts.imports {
if import.is_type {
continue;
}
let Some(target_rel) = resolver.resolve(root, importer_key, import) else {
tracing::debug!(
importer = %importer_rel,
specifier = %import.specifier,
"cross-file stitch: specifier did not resolve — skipping"
);
continue;
};
if store.lookup(&target_rel).is_none() {
continue;
}
let Some(wanted) = import.imported.as_deref().or_else(|| resolver.default_export_name()) else {
continue;
};
let Some((def_rel, name_start)) =
resolve_export_transitively(root, store, facts, &export_maps, resolver, target_rel, wanted)
else {
continue;
};
let mut use_starts: Vec<u32> = vec![import.local_start];
for edge in &importer_facts.import_uses {
if edge.def_start == import.local_start && !use_starts.contains(&edge.use_start) {
use_starts.push(edge.use_start);
}
}
for use_start in use_starts {
match writer.upsert_cross_file_edge(&def_rel, name_start, &importer_rel, use_start) {
Ok(()) => {
edges += 1;
if edges.is_multiple_of(COMMIT_BATCH) {
if let Err(error) = writer.commit() {
tracing::warn!(%error, "cross-file stitch: batch commit failed — navigation may be stale");
}
writer = index_db.writer();
}
}
Err(error) => tracing::warn!(
importer = %importer_rel,
target = %def_rel,
%error,
"cross-file stitch: failed to stage edge — skipping"
),
}
}
}
}
if let Err(error) = writer.commit() {
tracing::warn!(%error, "cross-file stitch: index commit failed — cross-file navigation may be stale");
return;
}
tracing::debug!(edges, "cross-file stitch: staged cross-file resolved edges");
}