use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use petgraph::graph::NodeIndex;
use petgraph::Direction;
use crate::datatypes::values::Value;
use crate::graph::dir_graph::DirGraph;
use crate::graph::schema::InternedKey;
use crate::graph::storage::GraphRead;
#[derive(Debug, Clone)]
pub struct ExploreOptions {
pub max_entities: usize,
pub max_depth: usize,
pub include_source: bool,
pub source_budget_bytes: usize,
}
impl Default for ExploreOptions {
fn default() -> Self {
Self {
max_entities: 10,
max_depth: 2,
include_source: true,
source_budget_bytes: 32 * 1024,
}
}
}
#[derive(Debug)]
struct Hit {
nidx: NodeIndex,
score: i64,
qname: String,
name: String,
kind: String,
file_path: Option<String>,
line_number: Option<i64>,
end_line: Option<i64>,
signature: Option<String>,
}
const ENTRY_NODE_TYPES: &[&str] = &[
"Function",
"Class",
"Struct",
"Mixin",
"Interface",
"Trait",
"Protocol",
"Enum",
];
const TRAVERSAL_EDGES: &[&str] = &[
"CALLS",
"USES_TYPE",
"HAS_METHOD",
"DEFINES",
"REFERENCES_FN",
];
pub fn explore_markdown(
dir: &DirGraph,
query: &str,
opts: &ExploreOptions,
source_roots: &[PathBuf],
) -> String {
let query_trim = query.trim();
if query_trim.is_empty() {
return "## Query\n\n_empty query_\n".to_string();
}
let entry_hits = lexical_search(dir, query_trim, opts.max_entities);
if entry_hits.is_empty() {
return format!(
"## Query\n\n`{query_trim}`\n\n_No matching Function/Class/Interface nodes in this graph._\n"
);
}
let related = traverse(dir, &entry_hits, opts.max_depth);
let mut out = String::new();
out.push_str("## Query\n\n");
out.push_str(&format!("`{query_trim}`\n\n"));
out.push_str(&format!("## Entry points ({})\n\n", entry_hits.len()));
for (i, hit) in entry_hits.iter().enumerate() {
out.push_str(&format!(
"{}. **{} `{}`** — `{}:{}-{}`\n",
i + 1,
hit.kind,
hit.qname,
hit.file_path.as_deref().unwrap_or("(unknown)"),
hit.line_number.unwrap_or(0),
hit.end_line.unwrap_or(0),
));
if let Some(sig) = &hit.signature {
if !sig.is_empty() {
out.push_str(&format!(" signature: `{}`\n", truncate(sig, 200)));
}
}
}
out.push('\n');
if !related.is_empty() {
out.push_str(&format!("## Related ({})\n\n", related.len()));
for hit in &related {
out.push_str(&format!(
"- {} `{}` — `{}:{}-{}`\n",
hit.kind,
hit.qname,
hit.file_path.as_deref().unwrap_or("(unknown)"),
hit.line_number.unwrap_or(0),
hit.end_line.unwrap_or(0),
));
}
out.push('\n');
}
if opts.include_source {
out.push_str("## Source\n\n");
render_source_sections(
&mut out,
&entry_hits,
&related,
source_roots,
opts.source_budget_bytes,
);
}
out
}
fn lexical_search(dir: &DirGraph, query: &str, max_entities: usize) -> Vec<Hit> {
let q_lower = query.to_lowercase();
let mut hits: Vec<Hit> = Vec::new();
for node_type in ENTRY_NODE_TYPES {
let Some(idx_ref) = dir.type_indices.get(node_type) else {
continue;
};
for nidx in idx_ref.iter() {
let Some(node) = dir.graph.node_weight(nidx) else {
continue;
};
let title = crate::datatypes::values::raw_string(&node.title());
let title_lower = title.to_lowercase();
let mut score: i64 = 0;
if title_lower == q_lower {
score += 100;
} else if title_lower.contains(&q_lower) {
score += 30 + (40 / (title_lower.len() as i64).max(1));
}
let signature = node
.get_property("signature")
.as_deref()
.map(crate::datatypes::values::raw_string);
if let Some(sig) = signature.as_deref().map(str::to_lowercase) {
if sig.contains(&q_lower) {
score += 10;
}
}
let docstring = node
.get_property("docstring")
.as_deref()
.map(crate::datatypes::values::raw_string);
if let Some(doc) = docstring.as_deref().map(str::to_lowercase) {
if doc.contains(&q_lower) {
score += 5;
}
}
if score == 0 {
continue;
}
let file_path = node
.get_property("file_path")
.as_deref()
.map(crate::datatypes::values::raw_string);
let line_number = node
.get_property("line_number")
.as_deref()
.and_then(|v| match v {
Value::Int64(n) => Some(*n),
_ => None,
});
let end_line = node
.get_property("end_line")
.as_deref()
.and_then(|v| match v {
Value::Int64(n) => Some(*n),
_ => None,
});
let qname = crate::datatypes::values::raw_string(&node.id());
hits.push(Hit {
nidx,
score,
qname,
name: title,
kind: node_type.to_string(),
file_path,
line_number,
end_line,
signature,
});
}
}
hits.sort_by(|a, b| b.score.cmp(&a.score).then_with(|| a.qname.cmp(&b.qname)));
hits.truncate(max_entities);
hits
}
fn traverse(dir: &DirGraph, seeds: &[Hit], max_depth: usize) -> Vec<Hit> {
if max_depth == 0 {
return Vec::new();
}
let edge_keys: Vec<InternedKey> = TRAVERSAL_EDGES
.iter()
.map(|n| InternedKey::from_str(n))
.collect();
let seed_set: HashSet<NodeIndex> = seeds.iter().map(|h| h.nidx).collect();
let mut visited: HashSet<NodeIndex> = seed_set.clone();
let mut frontier: Vec<NodeIndex> = seeds.iter().map(|h| h.nidx).collect();
for _ in 0..max_depth {
let mut next: Vec<NodeIndex> = Vec::new();
for nidx in &frontier {
for direction in [Direction::Outgoing, Direction::Incoming] {
for er in dir.graph.edges_directed(*nidx, direction) {
if !edge_keys.contains(&er.weight().connection_type) {
continue;
}
let other = if direction == Direction::Outgoing {
er.target()
} else {
er.source()
};
if visited.insert(other) {
next.push(other);
}
}
}
}
if next.is_empty() {
break;
}
frontier = next;
}
let mut out: Vec<Hit> = Vec::new();
for nidx in visited {
if seed_set.contains(&nidx) {
continue;
}
let Some(node) = dir.graph.node_weight(nidx) else {
continue;
};
let kind = node.get_node_type_ref(&dir.interner).to_string();
if !ENTRY_NODE_TYPES.contains(&kind.as_str()) {
continue;
}
let qname = crate::datatypes::values::raw_string(&node.id());
let name = crate::datatypes::values::raw_string(&node.title());
let file_path = node
.get_property("file_path")
.as_deref()
.map(crate::datatypes::values::raw_string);
let line_number = node
.get_property("line_number")
.as_deref()
.and_then(|v| match v {
Value::Int64(n) => Some(*n),
_ => None,
});
let end_line = node
.get_property("end_line")
.as_deref()
.and_then(|v| match v {
Value::Int64(n) => Some(*n),
_ => None,
});
out.push(Hit {
nidx,
score: 0,
qname,
name,
kind,
file_path,
line_number,
end_line,
signature: None,
});
}
out.sort_by(|a, b| a.qname.cmp(&b.qname));
out
}
fn render_source_sections(
out: &mut String,
entries: &[Hit],
related: &[Hit],
source_roots: &[PathBuf],
budget: usize,
) {
let mut by_file: HashMap<String, Vec<(i64, i64, String)>> = HashMap::new();
for hit in entries {
let (Some(path), Some(start), Some(end)) = (&hit.file_path, hit.line_number, hit.end_line)
else {
continue;
};
if start <= 0 || end < start {
continue;
}
by_file
.entry(path.clone())
.or_default()
.push((start, end, hit.name.clone()));
}
let _ = related; let mut total = 0usize;
let mut files: Vec<&String> = by_file.keys().collect();
files.sort();
for file in files {
let mut ranges = by_file.get(file).cloned().unwrap_or_default();
ranges.sort_by_key(|(s, _, _)| *s);
let merged = merge_ranges(&ranges);
let Some(content) = read_file(file, source_roots) else {
out.push_str(&format!("### `{file}`\n_could not read source_\n\n"));
continue;
};
let lines: Vec<&str> = content.lines().collect();
out.push_str(&format!("### `{file}`\n\n"));
for (s, e, names) in merged {
if total >= budget {
out.push_str("_… truncated (source budget reached) …_\n\n");
return;
}
let s_idx = (s as usize).saturating_sub(1);
let e_idx = (e as usize).min(lines.len());
if s_idx >= lines.len() {
continue;
}
out.push_str(&format!("<!-- {} -->\n```\n", names.join(", "),));
for (line_no, line) in lines[s_idx..e_idx].iter().enumerate() {
let actual_lineno = (s as usize) + line_no;
let next = format!("{actual_lineno:>5} {line}\n");
total += next.len();
out.push_str(&next);
if total >= budget {
out.push_str("```\n_… truncated …_\n\n");
return;
}
}
out.push_str("```\n\n");
}
}
}
fn merge_ranges(ranges: &[(i64, i64, String)]) -> Vec<(i64, i64, Vec<String>)> {
if ranges.is_empty() {
return Vec::new();
}
let mut out: Vec<(i64, i64, Vec<String>)> = Vec::new();
for (s, e, name) in ranges {
match out.last_mut() {
Some(last) if *s <= last.1 + 1 => {
last.1 = last.1.max(*e);
last.2.push(name.clone());
}
_ => out.push((*s, *e, vec![name.clone()])),
}
}
out
}
fn read_file(path: &str, source_roots: &[PathBuf]) -> Option<String> {
if let Ok(content) = std::fs::read_to_string(path) {
return Some(content);
}
for root in source_roots {
let candidate = root.join(path);
if let Ok(content) = std::fs::read_to_string(&candidate) {
return Some(content);
}
}
None
}
fn truncate(s: &str, n: usize) -> String {
if s.len() <= n {
return s.to_string();
}
let mut end = n;
while !s.is_char_boundary(end) && end > 0 {
end -= 1;
}
format!("{}…", &s[..end])
}