use std::path::Path;
use ahash::AHashMap;
use oxc_resolver::{ResolveOptions, Resolver};
use crate::index::IndexDb;
use crate::intel::model::{ExportEdge, ImportEdge};
use crate::path::RelPath;
use crate::store::Store;
pub struct FileFacts {
pub imports: Vec<ImportEdge>,
pub exports: Vec<ExportEdge>,
}
const DEFAULT_EXPORT_NAME: &str = "default";
const COMMIT_BATCH: usize = 256;
const RESOLVE_EXTENSIONS: &[&str] = &[".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"];
fn build_resolver() -> Resolver {
let ext_alias = |from: &str, to: &[&str]| (from.to_string(), to.iter().map(|s| (*s).to_string()).collect());
Resolver::new(ResolveOptions {
extensions: RESOLVE_EXTENSIONS.iter().map(|e| (*e).to_string()).collect(),
extension_alias: vec![
ext_alias(".js", &[".ts", ".tsx", ".js", ".jsx"]),
ext_alias(".mjs", &[".mts", ".mjs"]),
ext_alias(".cjs", &[".cts", ".cjs"]),
],
condition_names: vec![
"node".to_string(),
"import".to_string(),
"require".to_string(),
"default".to_string(),
],
symlinks: false,
..ResolveOptions::default()
})
}
fn to_repo_relative(root: &Path, target_abs: &Path) -> Option<RelPath> {
let rel = target_abs.strip_prefix(root).ok()?;
let normalized = rel.to_str()?.replace('\\', "/");
Some(RelPath::from(normalized.as_str()))
}
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 resolver = build_resolver();
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 importer_abs = root.join(importer_key);
let Some(importer_dir) = importer_abs.parent() else {
continue;
};
let importer_rel = RelPath::from(importer_key.as_str());
for import in &importer_facts.imports {
if import.is_type {
continue;
}
let target_abs = match resolver.resolve(importer_dir, &import.specifier) {
Ok(resolution) => resolution.full_path(),
Err(error) => {
tracing::debug!(
importer = %importer_rel,
specifier = %import.specifier,
%error,
"cross-file stitch: specifier did not resolve — skipping"
);
continue;
}
};
let Some(target_rel) = to_repo_relative(root, &target_abs) else {
continue; };
if store.lookup(&target_rel).is_none() {
continue;
}
let Some(target_key) = target_rel.as_str() else {
continue;
};
let Some(export_map) = export_maps.get(target_key) else {
continue; };
let wanted = import.imported.as_deref().unwrap_or(DEFAULT_EXPORT_NAME);
if let Some(&name_start) = export_map.get(wanted) {
match writer.upsert_cross_file_edge(&target_rel, name_start, &importer_rel, import.local_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 = %target_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");
}