use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::Path;
use regex::Regex;
use crate::mcp::dynamic_boundaries::{BoundaryMatch, scan_dynamic_dispatch};
use crate::types::{Node, NodeKind};
use super::super::shared::{dedupe_nodes, language_label, project_file_path};
pub(super) fn format_dynamic_dispatch_links(
cg: &mut crate::CodeGraph,
project_root: &Path,
named_nodes: &[Node],
) -> Vec<String> {
if named_nodes.len() < 2 {
return Vec::new();
}
let files = cg.get_files();
let mut source_by_file = HashMap::new();
for file in files {
let path = project_file_path(project_root, &file.path);
let source = fs::read_to_string(path).unwrap_or_default();
source_by_file.insert(file.path, source);
}
let mut rows = Vec::new();
let mut seen = HashSet::new();
for source_node in named_nodes {
let Some(source) = source_by_file.get(&source_node.file_path) else {
continue;
};
for (event, emit_line) in literal_event_emits(source) {
for target_node in named_nodes {
if target_node.id == source_node.id {
continue;
}
let Some((registered_file, registered_line)) =
find_event_registration(&source_by_file, &event, &target_node.name)
else {
continue;
};
let key = format!("{}:{}:{event}", source_node.id, target_node.id);
if !seen.insert(key) {
continue;
}
let wiring = if registered_file.is_empty() {
format!("@{}:{emit_line}", source_node.file_path)
} else {
format!("@{registered_file}:{registered_line}")
};
rows.push(format!(
"- {} → {} [dynamic: event {event} {wiring}]",
source_node.name, target_node.name
));
}
}
}
if rows.is_empty() {
Vec::new()
} else {
let mut lines = vec![
"## Dynamic-dispatch links among your symbols".to_string(),
"(synthesized — indirect runtime hops surfaced from registry/bus wiring)".to_string(),
String::new(),
];
lines.extend(rows);
lines
}
}
fn literal_event_emits(source: &str) -> Vec<(String, u64)> {
let Ok(re) = Regex::new(r#"\.emit\s*\(\s*['"]([^'"]+)['"]"#) else {
return Vec::new();
};
re.captures_iter(source)
.filter_map(|cap| {
let event = cap.get(1)?.as_str().to_string();
let start = cap.get(0)?.start();
Some((event, line_number_at(source, start)))
})
.collect()
}
fn find_event_registration(
source_by_file: &HashMap<String, String>,
event: &str,
handler_name: &str,
) -> Option<(String, u64)> {
let pattern = format!(
r#"\.on\s*\(\s*['"]{}['"]\s*,\s*{}(?:\s|,|\))"#,
regex::escape(event),
regex::escape(handler_name)
);
let re = Regex::new(&pattern).ok()?;
for (file_path, source) in source_by_file {
let Some(mat) = re.find(source) else {
continue;
};
return Some((file_path.clone(), line_number_at(source, mat.start())));
}
None
}
pub(super) fn format_dynamic_boundaries(
cg: &mut crate::CodeGraph,
project_root: &Path,
named_nodes: &[Node],
query_tokens: &HashSet<String>,
) -> Vec<String> {
if named_nodes.is_empty() {
return Vec::new();
}
let named_names = named_nodes
.iter()
.map(|node| node.name.clone())
.collect::<HashSet<_>>();
let mut notes = Vec::new();
let mut seen_sites = HashSet::new();
for node in named_nodes.iter().take(8) {
if notes.len() >= 4 {
break;
}
let source = fs::read_to_string(project_file_path(project_root, &node.file_path))
.unwrap_or_default();
if source.is_empty() {
continue;
}
let language = language_label(node.language);
for boundary in scan_dynamic_dispatch(&source, &language, 1) {
if notes.len() >= 4 {
break;
}
let site_key = format!("{}:{}:{}", node.file_path, boundary.line, boundary.form);
if !seen_sites.insert(site_key) {
continue;
}
push_dynamic_boundary_note(cg, &mut notes, node, &boundary, &named_names, query_tokens);
}
}
if notes.is_empty() {
Vec::new()
} else {
let mut lines = vec![
"## Dynamic boundaries (the static path ends at runtime dispatch)".to_string(),
String::new(),
];
lines.extend(notes);
lines.push(String::new());
lines.push(
"> These sites choose their target at runtime; the source for the sites above is included below."
.to_string(),
);
lines
}
}
fn push_dynamic_boundary_note(
cg: &mut crate::CodeGraph,
notes: &mut Vec<String>,
node: &Node,
boundary: &BoundaryMatch,
named_names: &HashSet<String>,
query_tokens: &HashSet<String>,
) {
let more = boundary
.more_sites
.map(|count| {
format!(
" (+{count} more such site{})",
if count > 1 { "s" } else { "" }
)
})
.unwrap_or_default();
notes.push(format!(
"- `{}` ({}:{}) — {}: `{}`{}",
node.name, node.file_path, boundary.line, boundary.label, boundary.snippet, more
));
if let Some(key) = boundary.key.as_deref()
&& let Some(candidates) = dynamic_boundary_candidates(
cg,
key,
boundary.key_is_type.unwrap_or(false),
named_names,
query_tokens,
&node.id,
)
{
notes.push(format!(" {candidates}"));
}
}
fn dynamic_boundary_candidates(
cg: &mut crate::CodeGraph,
key: &str,
key_is_type: bool,
named_names: &HashSet<String>,
query_tokens: &HashSet<String>,
self_id: &str,
) -> Option<String> {
let key_norm = normalize_candidate_key(key);
if key_norm.len() < 3 {
return None;
}
let mut candidates = Vec::new();
let cap = capitalize_first(key);
let probes = if key_is_type {
vec![format!("{key}Handler"), key.to_string()]
} else {
vec![
key.to_string(),
format!("on{cap}"),
format!("handle{cap}"),
format!("{key}Handler"),
format!("handle_{key}"),
]
};
for probe in probes {
candidates.extend(cg.get_nodes_by_name(&probe));
}
candidates.extend(
cg.search_nodes(key, None)
.into_iter()
.map(|result| result.node),
);
candidates.retain(|node| {
node.id != self_id
&& is_dynamic_candidate_node(node)
&& candidate_matches_key(&node.name, &key_norm)
});
dedupe_nodes(&mut candidates);
candidates.sort_by(|a, b| {
let a_named = named_names.contains(&a.name) || query_tokens.contains(&a.name);
let b_named = named_names.contains(&b.name) || query_tokens.contains(&b.name);
b_named
.cmp(&a_named)
.then_with(|| a.file_path.cmp(&b.file_path))
.then_with(|| a.start_line.cmp(&b.start_line))
});
if candidates.is_empty() {
return None;
}
let rendered = candidates
.into_iter()
.take(4)
.map(|node| {
let named = named_names.contains(&node.name) || query_tokens.contains(&node.name);
format!(
"`{}` ({}:{}){}",
node.name,
node.file_path,
node.start_line,
if named { " ← you named this" } else { "" }
)
})
.collect::<Vec<_>>();
Some(format!(
"candidates for key `{key}`: {}",
rendered.join(", ")
))
}
fn is_dynamic_candidate_node(node: &Node) -> bool {
matches!(
node.kind,
NodeKind::Function | NodeKind::Method | NodeKind::Class | NodeKind::Component
)
}
fn candidate_matches_key(name: &str, key_norm: &str) -> bool {
let name_norm = normalize_candidate_key(name);
name_norm.len() >= 3 && (name_norm.contains(key_norm) || key_norm.contains(&name_norm))
}
fn normalize_candidate_key(value: &str) -> String {
value
.chars()
.filter(|ch| ch.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
fn capitalize_first(value: &str) -> String {
let mut chars = value.chars();
let Some(first) = chars.next() else {
return String::new();
};
first.to_uppercase().chain(chars).collect()
}
fn line_number_at(source: &str, byte_index: usize) -> u64 {
source
.as_bytes()
.iter()
.take(byte_index.min(source.len()))
.filter(|byte| **byte == b'\n')
.count() as u64
+ 1
}