use std::collections::HashMap;
use std::path::Path;
use std::sync::OnceLock;
use regex::Regex;
use crate::datatypes::values::{DataFrame, Value};
use crate::graph::dir_graph::DirGraph;
use crate::graph::mutation::maintain;
use crate::graph::storage::GraphRead;
fn client_patterns() -> &'static Vec<Regex> {
static PATS: OnceLock<Vec<Regex>> = OnceLock::new();
PATS.get_or_init(|| {
[
r#"fetch\(\s*['"`]([^'"`]+)"#,
r#"axios\s*\.\s*(?:get|post|put|delete|patch|head)\(\s*['"`]([^'"`]+)"#,
r#"requests\s*\.\s*(?:get|post|put|delete|patch|head)\(\s*['"]([^'"]+)"#,
r#"httpx\s*\.\s*(?:get|post|put|delete|patch|head)\(\s*['"]([^'"]+)"#,
r#"reqwest::(?:blocking::)?get\(\s*"([^"]+)"#,
r#"http\.(?:Get|Post|Head|PostForm)\(\s*"([^"]+)"#,
]
.iter()
.map(|p| Regex::new(p).expect("valid client-call regex"))
.collect()
})
}
fn normalize_path(raw: &str) -> Option<String> {
let mut s = raw.trim();
if let Some(pos) = s.find("://") {
let after = &s[pos + 3..];
s = match after.find('/') {
Some(slash) => &after[slash..],
None => "/",
};
}
s = s.split(['?', '#']).next().unwrap_or(s);
if !s.starts_with('/') {
return None;
}
Some(s.to_string())
}
fn route_segments(path: &str) -> Vec<String> {
let norm = normalize_path(path).unwrap_or_else(|| path.to_string());
norm.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|seg| {
if seg.starts_with('{') || seg.starts_with('<') || seg.starts_with(':') {
"{}".to_string()
} else {
seg.to_string()
}
})
.collect()
}
fn segments(path: &str) -> Vec<String> {
path.trim_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect()
}
fn path_matches(client: &[String], route: &[String]) -> bool {
client.len() == route.len() && client.iter().zip(route).all(|(c, r)| r == "{}" || r == c)
}
pub fn ingest_http_cross_edges(
graph: &mut DirGraph,
root: &Path,
verbose: bool,
) -> Result<usize, String> {
let mut routes: Vec<(String, Vec<String>)> = Vec::new();
for idx in graph.graph.node_indices() {
let Some(node) = graph.get_node(idx) else {
continue;
};
if node.node_type_str(&graph.interner) != "Route" {
continue;
}
let Value::String(route_id) = node.id().as_ref().clone() else {
continue;
};
if let Some(Value::String(path)) = node.get_property("path").as_deref() {
routes.push((route_id, route_segments(path)));
}
}
if routes.is_empty() {
return Ok(0);
}
let mut by_file: HashMap<String, Vec<(String, usize, usize)>> = HashMap::new();
for idx in graph.graph.node_indices() {
let Some(node) = graph.get_node(idx) else {
continue;
};
if node.node_type_str(&graph.interner) != "Function" {
continue;
}
let Value::String(qname) = node.id().as_ref().clone() else {
continue;
};
let Some(Value::String(file_path)) = node.get_property("file_path").as_deref().cloned()
else {
continue;
};
let start = match node.get_property("line_number").as_deref() {
Some(Value::Int64(n)) => *n as usize,
_ => continue,
};
let end = match node.get_property("end_line").as_deref() {
Some(Value::Int64(n)) => *n as usize,
_ => start,
};
by_file
.entry(file_path)
.or_default()
.push((qname, start, end));
}
let pats = client_patterns();
let mut edges: Vec<(String, String)> = Vec::new();
for (file_path, fns) in &by_file {
let Ok(src) = std::fs::read_to_string(root.join(file_path)) else {
continue;
};
let lines: Vec<&str> = src.lines().collect();
for (qname, start, end) in fns {
let lo = start.saturating_sub(1);
let hi = (*end).min(lines.len());
for line in lines.get(lo..hi).unwrap_or(&[]) {
let t = line.trim_start();
if t.starts_with("//") || t.starts_with('#') || t.starts_with('*') {
continue;
}
for re in pats {
for cap in re.captures_iter(line) {
let Some(url) = cap.get(1) else { continue };
let Some(path) = normalize_path(url.as_str()) else {
continue;
};
let segs = segments(&path);
for (route_id, rsegs) in &routes {
if path_matches(&segs, rsegs) {
edges.push((qname.clone(), route_id.clone()));
}
}
}
}
}
}
}
edges.sort();
edges.dedup();
if edges.is_empty() {
return Ok(0);
}
let n = edges.len();
let rows: Vec<Vec<Value>> = edges
.into_iter()
.map(|(s, t)| {
vec![
Value::String(s),
Value::String(t),
Value::String("inferred".to_string()),
]
})
.collect();
let df = DataFrame::from_cypher_rows(
vec![
"source_id".to_string(),
"target_id".to_string(),
"confidence".to_string(),
],
rows,
)?;
maintain::add_connections(
graph,
df,
"CALLS_SERVICE".to_string(),
"Function".to_string(),
"source_id".to_string(),
"Route".to_string(),
"target_id".to_string(),
None,
None,
Some("update".to_string()),
)?;
if verbose {
eprintln!("[cross-lang] {n} CALLS_SERVICE edge(s)");
}
Ok(n)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_strips_host_query_fragment() {
assert_eq!(
normalize_path("https://api.x/users?q=1#f").as_deref(),
Some("/users")
);
assert_eq!(normalize_path("/api/users").as_deref(), Some("/api/users"));
assert_eq!(normalize_path("relative/x"), None);
}
#[test]
fn parameterized_route_matches_concrete_path() {
assert!(path_matches(
&segments("/users/7"),
&route_segments("/users/{id}")
));
assert!(path_matches(
&segments("/users/7"),
&route_segments("/users/<int:id>")
));
assert!(!path_matches(
&segments("/users/7/x"),
&route_segments("/users/{id}")
));
assert!(!path_matches(
&segments("/orders/7"),
&route_segments("/users/{id}")
));
}
}