use std::collections::HashSet;
use crate::ir::ExtractResult;
use crate::model::{Edge, EdgeType, Language, Node as ModelNode, NodeLabel};
use crate::storage::schema::escape_cypher_string;
pub const CONFIDENCE_DEPENDS_ON: f32 = 0.9;
#[must_use]
pub fn package_root(import_path: &str, language: Language) -> String {
match language {
Language::JavaScript | Language::TypeScript if import_path.starts_with('@') => {
import_path.split('/').take(2).collect::<Vec<_>>().join("/")
}
Language::Rust => import_path
.split("::")
.find(|s| !s.is_empty())
.unwrap_or(import_path)
.to_string(),
Language::Python => import_path
.split('.')
.find(|s| !s.is_empty())
.unwrap_or(import_path)
.to_string(),
_ => import_path
.split('/')
.find(|s| !s.is_empty())
.unwrap_or(import_path)
.to_string(),
}
}
#[must_use]
pub fn ecosystem_for(language: Language) -> &'static str {
match language {
Language::Rust => "crates",
Language::Python => "pypi",
Language::JavaScript | Language::TypeScript => "npm",
Language::Go => "gomodules",
Language::Java => "maven",
Language::CSharp => "nuget",
Language::Ruby => "rubygems",
Language::Php => "packagist",
_ => "other",
}
}
#[must_use]
fn package_node_id(project: &str, root: &str) -> String {
format!(
"extpkg:{}:{}",
escape_cypher_string(project),
escape_cypher_string(root)
)
}
pub fn record_external_dependency(
graph: &mut crate::model::Graph,
source_file_id: &str,
import_path: &str,
language: Language,
line: u32,
project: &str,
seen_pairs: &mut HashSet<(String, String)>,
) {
if import_path.trim().is_empty() {
return;
}
let root = package_root(import_path, language);
if root.is_empty() {
return;
}
let node_id = package_node_id(project, &root);
if graph.get_node(&node_id).is_none() {
let mut package =
ModelNode::builder(NodeLabel::ExternalPackage, root.clone(), node_id.clone())
.language(language)
.project(project)
.properties(serde_json::json!({ "ecosystem": ecosystem_for(language) }))
.build();
package.id = node_id.clone();
graph.add_node(package);
}
let pair_key = (source_file_id.to_string(), node_id.clone());
if seen_pairs.insert(pair_key) {
let edge = Edge::builder(
source_file_id,
node_id.clone(),
EdgeType::DependsOn,
project,
)
.confidence(CONFIDENCE_DEPENDS_ON)
.start_line(line)
.reason(format!("import: {import_path}"))
.build();
graph.add_edge(edge);
}
}
pub fn collect_external_dependencies(
graph: &mut crate::model::Graph,
results: &[ExtractResult],
project: &str,
unresolved: &[(usize, &crate::ir::ImportInfo)],
source_file_ids: &[String],
) {
let mut seen = HashSet::new();
for (result_idx, import) in unresolved {
let Some(file_id) = source_file_ids.get(*result_idx) else {
continue;
};
record_external_dependency(
graph,
file_id,
&import.source_file,
results[*result_idx].language,
import.line,
project,
&mut seen,
);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn package_root_per_language() {
assert_eq!(package_root("serde_json::de", Language::Rust), "serde_json");
assert_eq!(package_root("os.path", Language::Python), "os");
assert_eq!(
package_root("@angular/core", Language::TypeScript),
"@angular/core"
);
assert_eq!(
package_root("@angular/core", Language::JavaScript),
"@angular/core"
);
assert_eq!(package_root("github.com/x/y/z", Language::Go), "github.com");
assert_eq!(
package_root("unknown_pkg", Language::Haskell),
"unknown_pkg"
);
}
#[test]
fn ecosystem_mapping() {
assert_eq!(ecosystem_for(Language::Rust), "crates");
assert_eq!(ecosystem_for(Language::Python), "pypi");
assert_eq!(ecosystem_for(Language::TypeScript), "npm");
assert_eq!(ecosystem_for(Language::Go), "gomodules");
assert_eq!(ecosystem_for(Language::C), "other");
}
#[test]
fn depends_on_edges_dedup_per_file_and_node_dedups_per_package() {
let mut graph = crate::model::Graph::new();
graph.add_node(
crate::model::Node::builder(NodeLabel::File, "a.rs", "file_a")
.project("demo")
.build(),
);
graph.add_node(
crate::model::Node::builder(NodeLabel::File, "b.rs", "file_b")
.project("demo")
.build(),
);
let mut seen = HashSet::new();
for _ in 0..3 {
record_external_dependency(
&mut graph,
"file_a",
"serde_json::de",
Language::Rust,
1,
"demo",
&mut seen,
);
}
record_external_dependency(
&mut graph,
"file_b",
"serde_json::ser",
Language::Rust,
2,
"demo",
&mut seen,
);
let ext_nodes: Vec<_> = graph
.nodes
.values()
.filter(|n| n.label == NodeLabel::ExternalPackage)
.collect();
assert_eq!(ext_nodes.len(), 1, "one package node for serde_json");
let deps: Vec<_> = graph
.edges
.iter()
.filter(|e| e.edge_type == EdgeType::DependsOn)
.collect();
assert_eq!(deps.len(), 2, "one edge per (file, package) pair");
}
}