use std::collections::{HashMap, HashSet};
use regex::Regex;
use serde_json::{Value, json};
use crate::resolution::types::ResolutionContext;
use crate::types::{Edge, EdgeKind, Node, NodeKind};
use super::common::edge;
fn kebab_to_pascal(input: &str) -> String {
input
.split('-')
.map(|part| {
let mut chars = part.chars();
chars
.next()
.map(|first| first.to_uppercase().collect::<String>() + chars.as_str())
.unwrap_or_default()
})
.collect()
}
fn nuxt_component_name(file_path: &str) -> Option<String> {
let marker = file_path.rfind("components/")?;
let rel = file_path[marker + "components/".len()..]
.trim_end_matches(".vue")
.trim_end_matches(".tsx")
.trim_end_matches(".ts")
.trim_end_matches(".jsx")
.trim_end_matches(".js");
let segments = rel
.split('/')
.filter(|s| !s.is_empty())
.map(kebab_to_pascal)
.collect::<Vec<_>>();
if segments.len() < 2 {
return None;
}
let mut out: Vec<String> = Vec::new();
for segment in segments {
if out
.last()
.map(|prev| segment.starts_with(prev))
.unwrap_or(false)
{
*out.last_mut().unwrap() = segment;
} else {
out.push(segment);
}
}
Some(out.join(""))
}
pub(super) fn vue_template_edges(ctx: &mut dyn ResolutionContext) -> Vec<Edge> {
static TEMPLATE_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r#"(?is)<template[^>]*>([\s\S]*)</template>"#).unwrap()
});
static VUE_KEBAB_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r#"<([a-z][a-z0-9]*(?:-[a-z0-9]+)+)[\s/>]"#).unwrap()
});
static VUE_PASCAL_RE: std::sync::LazyLock<Regex> =
std::sync::LazyLock::new(|| Regex::new(r#"<([A-Z][A-Za-z0-9]*)[\s/>]"#).unwrap());
static VUE_HANDLER_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r#"(?:@|v-on:)([a-zA-Z][\w-]*)(?:\.[\w]+)*\s*=\s*"([^"]+)""#).unwrap()
});
static VUE_DESTRUCTURE_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r#"(?:const|let|var)\s*\{([^}]+)\}\s*=\s*(\w+)\s*\("#).unwrap()
});
static SCRIPT_RE: std::sync::LazyLock<Regex> = std::sync::LazyLock::new(|| {
Regex::new(r#"(?is)<script[^>]*>([\s\S]*?)</script>"#).unwrap()
});
let mut edges = Vec::new();
let mut seen = HashSet::new();
let mut nuxt_components = HashMap::new();
for c in ctx.get_nodes_by_kind(NodeKind::Component) {
if let Some(name) = nuxt_component_name(&c.file_path) {
nuxt_components.entry(name).or_insert(c);
}
}
for file in ctx
.get_all_files()
.into_iter()
.filter(|f| f.ends_with(".vue"))
{
let Some(content) = ctx.read_file(&file) else {
continue;
};
let Some(tpl) = TEMPLATE_RE
.captures(&content)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str().to_string())
else {
continue;
};
let Some(component) = ctx
.get_nodes_in_file(&file)
.into_iter()
.find(|node| node.kind == NodeKind::Component)
else {
continue;
};
let script = SCRIPT_RE
.captures(&content)
.and_then(|cap| cap.get(1))
.map(|m| m.as_str())
.unwrap_or("");
let mut destructured = HashMap::new();
for cap in VUE_DESTRUCTURE_RE.captures_iter(script) {
if !cap[2].starts_with("use") {
continue;
}
for part in cap[1].split(',') {
if let Some((key, alias)) = part.trim().split_once(':') {
destructured.insert(
alias.trim().to_string(),
(cap[2].to_string(), key.trim().to_string()),
);
} else {
let key = part.trim();
if !key.is_empty() {
destructured.insert(key.to_string(), (cap[2].to_string(), key.to_string()));
}
}
}
}
let mut add_edge = |target: Option<Node>, meta: HashMap<&'static str, Value>| {
let Some(target) = target else {
return;
};
if target.id == component.id {
return;
}
let key = format!(
"{}>{}>{:?}",
component.id,
target.id,
meta.get("synthesizedBy")
);
if seen.insert(key) {
let synth = meta
.get("synthesizedBy")
.and_then(Value::as_str)
.unwrap_or("jsx-render")
.to_string();
edges.push(edge(
&component.id,
&target.id,
EdgeKind::Calls,
Some(component.start_line),
&synth,
meta,
));
}
};
fn resolve_vue_name(
ctx: &mut dyn ResolutionContext,
file: &str,
name: &str,
kinds: &[NodeKind],
) -> Option<Node> {
let matches = ctx
.get_nodes_by_name(name)
.into_iter()
.filter(|node| kinds.contains(&node.kind))
.collect::<Vec<_>>();
matches
.iter()
.find(|node| node.file_path == file)
.cloned()
.or_else(|| matches.into_iter().next())
}
for cap in VUE_KEBAB_RE.captures_iter(&tpl) {
let tag = kebab_to_pascal(&cap[1]);
add_edge(
resolve_vue_name(
ctx,
file.as_str(),
&tag,
&[NodeKind::Component, NodeKind::Function, NodeKind::Class],
)
.or_else(|| nuxt_components.get(&tag).cloned()),
HashMap::from([
("synthesizedBy", json!("jsx-render")),
("via", json!(cap[1].to_string())),
]),
);
}
for cap in VUE_PASCAL_RE.captures_iter(&tpl) {
let tag = cap[1].to_string();
add_edge(
resolve_vue_name(
ctx,
file.as_str(),
&tag,
&[NodeKind::Component, NodeKind::Function, NodeKind::Class],
)
.or_else(|| nuxt_components.get(&tag).cloned()),
HashMap::from([("synthesizedBy", json!("jsx-render")), ("via", json!(tag))]),
);
}
for cap in VUE_HANDLER_RE.captures_iter(&tpl) {
let event = cap[1].to_string();
let expr = cap[2].trim();
if expr.contains("=>") || expr.starts_with('$') {
continue;
}
let name = expr
.chars()
.take_while(|ch| ch.is_ascii_alphanumeric() || *ch == '_')
.collect::<String>();
if name.is_empty() {
continue;
}
if let Some(direct) = resolve_vue_name(
ctx,
file.as_str(),
&name,
&[NodeKind::Method, NodeKind::Function],
) {
add_edge(
Some(direct),
HashMap::from([
("synthesizedBy", json!("vue-handler")),
("event", json!(event)),
]),
);
continue;
}
if let Some((composable, key)) = destructured.get(&name).cloned() {
let composable_node = resolve_vue_name(
ctx,
file.as_str(),
&composable,
&[NodeKind::Method, NodeKind::Function],
);
let key_fn = composable_node.and_then(|node| {
ctx.get_nodes_by_name(&key).into_iter().find(|n| {
matches!(
n.kind,
NodeKind::Method
| NodeKind::Function
| NodeKind::Variable
| NodeKind::Constant
) && n.file_path == node.file_path
})
});
add_edge(
key_fn,
HashMap::from([
("synthesizedBy", json!("vue-handler")),
("event", json!(event)),
("via", json!(composable)),
]),
);
}
}
}
edges
}
pub(super) fn pascal_form_edges(ctx: &mut dyn ResolutionContext) -> Vec<Edge> {
let all_files = ctx.get_all_files().into_iter().collect::<HashSet<_>>();
let mut edges = Vec::new();
for file in &all_files {
if !file.ends_with(".dfm") && !file.ends_with(".fmx") {
continue;
}
let pas_file = file
.trim_end_matches(".dfm")
.trim_end_matches(".fmx")
.to_string()
+ ".pas";
if !all_files.contains(&pas_file) {
continue;
}
let form_node = ctx
.get_nodes_in_file(file)
.into_iter()
.find(|node| node.kind == NodeKind::File);
let unit_node = ctx
.get_nodes_in_file(&pas_file)
.into_iter()
.find(|node| node.kind == NodeKind::File);
if let (Some(unit), Some(form)) = (unit_node, form_node) {
edges.push(edge(
&unit.id,
&form.id,
EdgeKind::References,
Some(unit.start_line),
"pascal-form",
[("registeredAt", json!(pas_file))],
));
}
}
edges
}
pub(super) fn svelte_kit_load_edges(ctx: &mut dyn ResolutionContext) -> Vec<Edge> {
let all_files = ctx.get_all_files().into_iter().collect::<HashSet<_>>();
let mut edges = Vec::new();
for file in &all_files {
let Some((dir, name)) = file.rsplit_once('/') else {
continue;
};
if !(name == "+page.svelte" || name == "+layout.svelte") {
continue;
}
let prefix = name.trim_end_matches(".svelte");
let Some(page) = ctx
.get_nodes_in_file(file)
.into_iter()
.find(|node| node.kind == NodeKind::Component)
else {
continue;
};
for ext in [".server.ts", ".server.js", ".ts", ".js"] {
let loader_file = format!("{dir}/{prefix}{ext}");
if !all_files.contains(&loader_file) {
continue;
}
for hook in ctx.get_nodes_in_file(&loader_file) {
if !matches!(
hook.kind,
NodeKind::Function | NodeKind::Method | NodeKind::Constant | NodeKind::Variable
) || !(hook.name == "load" || hook.name == "actions")
{
continue;
}
edges.push(edge(
&page.id,
&hook.id,
EdgeKind::References,
Some(page.start_line),
"sveltekit-load",
[
("via", json!(hook.name)),
(
"registeredAt",
json!(format!("{}:{}", loader_file, hook.start_line)),
),
],
));
}
}
}
edges
}