use std::path::Path;
use anyhow::{Context, Result};
use crate::graph::GraphQuery;
struct VizNode {
id: String,
label: String,
kind: String,
file: String,
start_line: String,
end_line: String,
}
struct VizEdge {
from: String,
to: String,
rel_type: String,
}
pub fn generate_html(gq: &GraphQuery, output_path: &Path) -> Result<String> {
let nodes = query_nodes(gq)?;
let edges = query_edges(gq)?;
let nodes_json = build_nodes_json(&nodes);
let edges_json = build_edges_json(&edges);
let html = HTML_TEMPLATE
.replace("/*__NODES_DATA__*/", &nodes_json)
.replace("/*__EDGES_DATA__*/", &edges_json);
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(output_path, html.as_bytes())
.with_context(|| format!("failed to write {}", output_path.display()))?;
Ok(output_path.to_string_lossy().to_string())
}
pub fn generate_symbol_html(
gq: &GraphQuery,
symbol_id: &str,
depth: u32,
output_path: &Path,
) -> Result<String> {
let (nodes, edges) = query_symbol_subgraph(gq, symbol_id, depth)?;
if nodes.is_empty() {
anyhow::bail!("symbol not found: {symbol_id}");
}
let nodes_json = build_nodes_json_with_focus(&nodes, symbol_id);
let edges_json = build_edges_json(&edges);
let html = HTML_TEMPLATE
.replace("/*__NODES_DATA__*/", &nodes_json)
.replace("/*__EDGES_DATA__*/", &edges_json);
if let Some(parent) = output_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(output_path, html.as_bytes())
.with_context(|| format!("failed to write {}", output_path.display()))?;
Ok(output_path.to_string_lossy().to_string())
}
fn query_nodes(gq: &GraphQuery) -> Result<Vec<VizNode>> {
let rows = gq.raw_query(
"MATCH (s:Symbol) RETURN s.id, s.name, s.kind, s.file, s.start_line, s.end_line",
)?;
let mut nodes = Vec::with_capacity(rows.len());
for row in &rows {
if row.len() >= 6 {
nodes.push(VizNode {
id: row[0].clone(),
label: row[1].clone(),
kind: row[2].clone(),
file: row[3].clone(),
start_line: row[4].clone(),
end_line: row[5].clone(),
});
}
}
Ok(nodes)
}
fn query_edges(gq: &GraphQuery) -> Result<Vec<VizEdge>> {
let mut edges = Vec::new();
let call_rows = gq.raw_query("MATCH (a:Symbol)-[:CALLS]->(b:Symbol) RETURN a.id, b.id")?;
for row in &call_rows {
if row.len() >= 2 {
edges.push(VizEdge {
from: row[0].clone(),
to: row[1].clone(),
rel_type: "CALLS".to_string(),
});
}
}
let inherit_rows =
gq.raw_query("MATCH (a:Symbol)-[:INHERITS]->(b:Symbol) RETURN a.id, b.id")?;
for row in &inherit_rows {
if row.len() >= 2 {
edges.push(VizEdge {
from: row[0].clone(),
to: row[1].clone(),
rel_type: "INHERITS".to_string(),
});
}
}
let contains_rows =
gq.raw_query("MATCH (m:Module)-[:CONTAINS]->(s:Symbol) RETURN m.id, s.id")?;
for row in &contains_rows {
if row.len() >= 2 {
edges.push(VizEdge {
from: row[0].clone(),
to: row[1].clone(),
rel_type: "CONTAINS".to_string(),
});
}
}
Ok(edges)
}
fn query_symbol_subgraph(
gq: &GraphQuery,
symbol_id: &str,
depth: u32,
) -> Result<(Vec<VizNode>, Vec<VizEdge>)> {
use std::collections::{HashSet, VecDeque};
let mut visited: HashSet<String> = HashSet::new();
let mut queue: VecDeque<(String, u32)> = VecDeque::new();
queue.push_back((symbol_id.to_string(), 0));
visited.insert(symbol_id.to_string());
while let Some((id, hop)) = queue.pop_front() {
if hop >= depth {
continue;
}
let esc = id.replace('\'', "\\'");
let q = format!("MATCH (a:Symbol)-[:CALLS]->(b:Symbol) WHERE a.id = '{esc}' RETURN b.id");
if let Ok(rows) = gq.raw_query(&q) {
for row in &rows {
if let Some(nid) = row.first() {
if visited.insert(nid.clone()) {
queue.push_back((nid.clone(), hop + 1));
}
}
}
}
let q = format!("MATCH (a:Symbol)-[:CALLS]->(b:Symbol) WHERE b.id = '{esc}' RETURN a.id");
if let Ok(rows) = gq.raw_query(&q) {
for row in &rows {
if let Some(nid) = row.first() {
if visited.insert(nid.clone()) {
queue.push_back((nid.clone(), hop + 1));
}
}
}
}
let q = format!("MATCH (a:Symbol)-[:INHERITS]->(b:Symbol) WHERE a.id = '{esc}' OR b.id = '{esc}' RETURN a.id, b.id");
if let Ok(rows) = gq.raw_query(&q) {
for row in &rows {
for nid in row {
if visited.insert(nid.clone()) {
queue.push_back((nid.clone(), hop + 1));
}
}
}
}
}
let mut nodes = Vec::new();
for id in &visited {
let esc = id.replace('\'', "\\'");
let q = format!(
"MATCH (s:Symbol) WHERE s.id = '{esc}' RETURN s.id, s.name, s.kind, s.file, s.start_line, s.end_line"
);
if let Ok(rows) = gq.raw_query(&q) {
for row in &rows {
if row.len() >= 6 {
nodes.push(VizNode {
id: row[0].clone(),
label: row[1].clone(),
kind: row[2].clone(),
file: row[3].clone(),
start_line: row[4].clone(),
end_line: row[5].clone(),
});
}
}
}
}
let mut edges = Vec::new();
let id_set: HashSet<&str> = visited.iter().map(|s| s.as_str()).collect();
let call_rows = gq.raw_query("MATCH (a:Symbol)-[:CALLS]->(b:Symbol) RETURN a.id, b.id")?;
for row in &call_rows {
if row.len() >= 2 && id_set.contains(row[0].as_str()) && id_set.contains(row[1].as_str()) {
edges.push(VizEdge {
from: row[0].clone(),
to: row[1].clone(),
rel_type: "CALLS".to_string(),
});
}
}
let inherit_rows =
gq.raw_query("MATCH (a:Symbol)-[:INHERITS]->(b:Symbol) RETURN a.id, b.id")?;
for row in &inherit_rows {
if row.len() >= 2 && id_set.contains(row[0].as_str()) && id_set.contains(row[1].as_str()) {
edges.push(VizEdge {
from: row[0].clone(),
to: row[1].clone(),
rel_type: "INHERITS".to_string(),
});
}
}
Ok((nodes, edges))
}
fn build_nodes_json_with_focus(nodes: &[VizNode], focus_id: &str) -> String {
let entries: Vec<String> = nodes
.iter()
.map(|n| {
let color = match n.kind.as_str() {
"Function" => "#4A90D9",
"Class" => "#27AE60",
"Method" => "#17A2B8",
"Test" => "#E67E22",
"Variable" | "Constant" => "#95A5A6",
"Struct" | "Interface" | "Trait" => "#27AE60",
"Enum" => "#16A085",
"Module" => "#F39C12",
"Section" => "#8E44AD",
_ => "#BDC3C7",
};
if n.id == focus_id {
format!(
"{{id:\"{}\",label:\"{}\",kind:\"{}\",file:\"{}\",startLine:\"{}\",endLine:\"{}\",color:\"{}\",size:30,borderWidth:4,borderColor:\"#FFD700\"}}",
json_escape(&n.id), json_escape(&n.label), json_escape(&n.kind),
json_escape(&n.file), json_escape(&n.start_line), json_escape(&n.end_line),
color,
)
} else {
format!(
r#"{{id:"{}",label:"{}",kind:"{}",file:"{}",startLine:"{}",endLine:"{}",color:"{}"}}"#,
json_escape(&n.id), json_escape(&n.label), json_escape(&n.kind),
json_escape(&n.file), json_escape(&n.start_line), json_escape(&n.end_line),
color,
)
}
})
.collect();
entries.join(",\n")
}
fn json_escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('"', "\\\"")
.replace('\n', "\\n")
.replace('\r', "\\r")
.replace('\t', "\\t")
}
fn build_nodes_json(nodes: &[VizNode]) -> String {
let entries: Vec<String> = nodes
.iter()
.map(|n| {
let color = match n.kind.as_str() {
"Function" => "#4A90D9",
"Class" => "#27AE60",
"Method" => "#17A2B8",
"Test" => "#E67E22",
"Variable" | "Constant" => "#95A5A6",
"Struct" | "Interface" | "Trait" => "#27AE60",
"Enum" => "#16A085",
"Module" => "#F39C12",
"Section" => "#8E44AD",
_ => "#BDC3C7",
};
format!(
r#"{{id:"{}",label:"{}",kind:"{}",file:"{}",startLine:"{}",endLine:"{}",color:"{}"}}"#,
json_escape(&n.id),
json_escape(&n.label),
json_escape(&n.kind),
json_escape(&n.file),
json_escape(&n.start_line),
json_escape(&n.end_line),
color,
)
})
.collect();
entries.join(",\n")
}
fn build_edges_json(edges: &[VizEdge]) -> String {
let entries: Vec<String> = edges
.iter()
.enumerate()
.map(|(i, e)| {
let color = match e.rel_type.as_str() {
"CALLS" => "#3498DB",
"INHERITS" => "#E74C3C",
"CONTAINS" => "#7F8C8D",
_ => "#95A5A6",
};
format!(
r#"{{id:"e{}",from:"{}",to:"{}",relType:"{}",color:"{}"}}"#,
i,
json_escape(&e.from),
json_escape(&e.to),
json_escape(&e.rel_type),
color,
)
})
.collect();
entries.join(",\n")
}
const HTML_TEMPLATE: &str = include_str!("template.html");