Skip to main content

atomcode_core/graph/
mod.rs

1pub mod indexer;
2pub mod persist;
3pub mod resolve;
4
5use std::collections::hash_map::DefaultHasher;
6use std::collections::{HashMap, HashSet, VecDeque};
7use std::hash::{Hash, Hasher};
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12/// Unique identifier for a symbol node in the graph.
13pub type SymbolId = u64;
14
15/// The kind of symbol (function, struct, trait, etc.).
16#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
17pub enum SymbolKind {
18    Function,
19    Method,
20    Struct,
21    Class,
22    Trait,
23    Interface,
24    Enum,
25    Constant,
26    Variable,
27    Module,
28    Import,
29    TypeAlias,
30    Other(String),
31}
32
33/// Visibility of a symbol.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub enum Visibility {
36    Public,
37    Private,
38    Protected,
39    Internal,
40    Unknown,
41}
42
43/// A node representing a code symbol.
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct SymbolNode {
46    pub id: SymbolId,
47    pub name: String,
48    pub kind: SymbolKind,
49    pub visibility: Visibility,
50    pub file: PathBuf,
51    pub start_line: usize,
52    pub end_line: usize,
53    /// Optional short signature or doc string.
54    pub signature: Option<String>,
55}
56
57/// The kind of relationship between two symbols.
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59pub enum EdgeKind {
60    Calls,
61    Imports,
62    Inherits,
63    Implements,
64    References,
65}
66
67/// A directed edge in the code graph.
68///
69/// In `edges_out`, `to` is the callee/target.
70/// In `edges_in`, `to` stores the caller/source (i.e. the "from" SymbolId).
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Edge {
73    pub to: SymbolId,
74    pub kind: EdgeKind,
75    pub line: usize,
76}
77
78/// Cross-file code knowledge graph indexing symbol relationships.
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct CodeGraph {
81    pub nodes: HashMap<SymbolId, SymbolNode>,
82    pub edges_out: HashMap<SymbolId, Vec<Edge>>,
83    pub edges_in: HashMap<SymbolId, Vec<Edge>>,
84    pub file_symbols: HashMap<PathBuf, Vec<SymbolId>>,
85    pub file_mtimes: HashMap<PathBuf, u64>,
86}
87
88impl CodeGraph {
89    /// Create an empty graph.
90    pub fn new() -> Self {
91        Self {
92            nodes: HashMap::new(),
93            edges_out: HashMap::new(),
94            edges_in: HashMap::new(),
95            file_symbols: HashMap::new(),
96            file_mtimes: HashMap::new(),
97        }
98    }
99
100    /// Deterministic symbol ID from file path, name, and start line.
101    pub fn make_id(file: &PathBuf, name: &str, start_line: usize) -> SymbolId {
102        let mut hasher = DefaultHasher::new();
103        file.hash(&mut hasher);
104        name.hash(&mut hasher);
105        start_line.hash(&mut hasher);
106        hasher.finish()
107    }
108
109    /// Add a symbol node to the graph.
110    pub fn add_symbol(&mut self, node: SymbolNode) {
111        let id = node.id;
112        let file = node.file.clone();
113        self.nodes.insert(id, node);
114        self.file_symbols.entry(file).or_default().push(id);
115    }
116
117    /// Add a directed edge from `from` to the target in `edge`.
118    pub fn add_edge(&mut self, from: SymbolId, edge: Edge) {
119        let to = edge.to;
120        let kind = edge.kind.clone();
121        let line = edge.line;
122
123        self.edges_out.entry(from).or_default().push(edge);
124        self.edges_in.entry(to).or_default().push(Edge {
125            to: from,
126            kind,
127            line,
128        });
129    }
130
131    /// Look up a node by ID.
132    pub fn node(&self, id: SymbolId) -> Option<&SymbolNode> {
133        self.nodes.get(&id)
134    }
135
136    /// Get all symbol IDs in a file.
137    pub fn symbols_in_file(&self, file: &PathBuf) -> Option<&Vec<SymbolId>> {
138        self.file_symbols.get(file)
139    }
140
141    /// Get outgoing edges (callees / targets) from a symbol.
142    pub fn callees(&self, id: SymbolId) -> Option<&Vec<Edge>> {
143        self.edges_out.get(&id)
144    }
145
146    /// Get incoming edges (callers / sources) to a symbol.
147    pub fn callers(&self, id: SymbolId) -> Option<&Vec<Edge>> {
148        self.edges_in.get(&id)
149    }
150
151    /// Remove all symbols belonging to a file and clean up associated edges.
152    pub fn remove_file(&mut self, file: &PathBuf) {
153        let symbol_ids = match self.file_symbols.remove(file) {
154            Some(ids) => ids,
155            None => return,
156        };
157
158        for &id in &symbol_ids {
159            self.nodes.remove(&id);
160
161            // Remove outgoing edges and clean corresponding incoming entries
162            if let Some(out_edges) = self.edges_out.remove(&id) {
163                for edge in &out_edges {
164                    if let Some(in_list) = self.edges_in.get_mut(&edge.to) {
165                        in_list.retain(|e| e.to != id);
166                        if in_list.is_empty() {
167                            self.edges_in.remove(&edge.to);
168                        }
169                    }
170                }
171            }
172
173            // Remove incoming edges and clean corresponding outgoing entries
174            if let Some(in_edges) = self.edges_in.remove(&id) {
175                for edge in &in_edges {
176                    if let Some(out_list) = self.edges_out.get_mut(&edge.to) {
177                        out_list.retain(|e| e.to != id);
178                        if out_list.is_empty() {
179                            self.edges_out.remove(&edge.to);
180                        }
181                    }
182                }
183            }
184        }
185
186        self.file_mtimes.remove(file);
187    }
188
189    /// Find symbols by name (exact match).
190    pub fn find_by_name(&self, name: &str) -> Vec<&SymbolNode> {
191        self.nodes.values().filter(|n| n.name == name).collect()
192    }
193
194    /// Total number of symbol nodes.
195    pub fn node_count(&self) -> usize {
196        self.nodes.len()
197    }
198
199    /// Total number of indexed files.
200    pub fn file_count(&self) -> usize {
201        self.file_symbols.len()
202    }
203
204    /// Whether the graph has any data.
205    pub fn is_ready(&self) -> bool {
206        !self.nodes.is_empty()
207    }
208
209    /// BFS reverse traversal — find all transitive callers up to `max_depth`.
210    pub fn trace_callers(&self, id: SymbolId, max_depth: usize) -> Vec<(SymbolId, usize)> {
211        let mut visited = HashSet::new();
212        let mut queue = VecDeque::new();
213        let mut result = Vec::new();
214        visited.insert(id);
215        queue.push_back((id, 0usize));
216        while let Some((current, depth)) = queue.pop_front() {
217            if depth >= max_depth {
218                continue;
219            }
220            if let Some(edges) = self.callers(current) {
221                for edge in edges {
222                    if visited.insert(edge.to) {
223                        result.push((edge.to, depth + 1));
224                        queue.push_back((edge.to, depth + 1));
225                    }
226                }
227            }
228        }
229        result
230    }
231
232    /// BFS forward traversal — find all transitive callees up to `max_depth`.
233    pub fn trace_callees(&self, id: SymbolId, max_depth: usize) -> Vec<(SymbolId, usize)> {
234        let mut visited = HashSet::new();
235        let mut queue = VecDeque::new();
236        let mut result = Vec::new();
237        visited.insert(id);
238        queue.push_back((id, 0usize));
239        while let Some((current, depth)) = queue.pop_front() {
240            if depth >= max_depth {
241                continue;
242            }
243            if let Some(edges) = self.callees(current) {
244                for edge in edges {
245                    if visited.insert(edge.to) {
246                        result.push((edge.to, depth + 1));
247                        queue.push_back((edge.to, depth + 1));
248                    }
249                }
250            }
251        }
252        result
253    }
254
255    /// BFS shortest path from `from` to `to`, max 10 hops.
256    /// Returns the ordered path `[from, ..., to]` or `None`.
257    pub fn shortest_path(&self, from: SymbolId, to: SymbolId) -> Option<Vec<SymbolId>> {
258        if from == to {
259            return Some(vec![from]);
260        }
261        let max_hops = 10;
262        let mut visited = HashSet::new();
263        let mut queue = VecDeque::new();
264        let mut parent: HashMap<SymbolId, SymbolId> = HashMap::new();
265        visited.insert(from);
266        queue.push_back((from, 0usize));
267        while let Some((current, depth)) = queue.pop_front() {
268            if depth >= max_hops {
269                continue;
270            }
271            if let Some(edges) = self.callees(current) {
272                for edge in edges {
273                    if visited.insert(edge.to) {
274                        parent.insert(edge.to, current);
275                        if edge.to == to {
276                            // Reconstruct path
277                            let mut path = vec![to];
278                            let mut cur = to;
279                            while let Some(&p) = parent.get(&cur) {
280                                path.push(p);
281                                cur = p;
282                            }
283                            path.reverse();
284                            return Some(path);
285                        }
286                        queue.push_back((edge.to, depth + 1));
287                    }
288                }
289            }
290        }
291        None
292    }
293
294    /// Find all files that depend on (call into) symbols in the given file.
295    pub fn file_dependents(&self, file: &Path, max_depth: usize) -> Vec<PathBuf> {
296        let file_buf = file.to_path_buf();
297        let symbol_ids = match self.file_symbols.get(&file_buf) {
298            Some(ids) => ids.clone(),
299            None => return Vec::new(),
300        };
301        let mut dependent_files = HashSet::new();
302        for &sym_id in &symbol_ids {
303            for (caller_id, _depth) in self.trace_callers(sym_id, max_depth) {
304                if let Some(node) = self.node(caller_id) {
305                    if node.file != file_buf {
306                        dependent_files.insert(node.file.clone());
307                    }
308                }
309            }
310        }
311        dependent_files.into_iter().collect()
312    }
313
314    /// Generate a dependency summary for a file: what it uses, what uses it.
315    pub fn file_dependency_summary(&self, filename: &str) -> Option<String> {
316        let (path, sym_ids) = self.file_symbols.iter().find(|(p, _)| {
317            p.file_name()
318                .map(|f| f.to_string_lossy() == filename)
319                .unwrap_or(false)
320        })?;
321        let path = path.clone();
322
323        let mut uses: Vec<String> = Vec::new();
324        for &sid in sym_ids.iter().take(10) {
325            if let Some(edges) = self.callees(sid) {
326                for edge in edges {
327                    if let Some(node) = self.node(edge.to) {
328                        if node.file != path {
329                            let f = node
330                                .file
331                                .file_name()
332                                .map(|n| n.to_string_lossy().to_string())
333                                .unwrap_or_default();
334                            if !uses.contains(&f) {
335                                uses.push(f);
336                            }
337                        }
338                    }
339                }
340            }
341        }
342
343        let deps = self.file_dependents(&path, 2);
344        let dep_names: Vec<String> = deps
345            .iter()
346            .filter_map(|p| p.file_name().map(|f| f.to_string_lossy().to_string()))
347            .collect();
348
349        if uses.is_empty() && dep_names.is_empty() {
350            return None;
351        }
352
353        let mut info = format!("[Graph: {}]", filename);
354        if !uses.is_empty() {
355            info.push_str(&format!(" uses: {}", uses.join(", ")));
356        }
357        if !dep_names.is_empty() {
358            info.push_str(&format!(" | used by: {}", dep_names.join(", ")));
359        }
360        Some(info)
361    }
362
363    /// Generate a call chain summary for a function.
364    /// Skips struct/enum/trait nodes (they don't have call edges).
365    /// If multiple functions match, picks the one with the most callees.
366    pub fn call_chain_summary(&self, fn_name: &str) -> Option<String> {
367        let symbols = self.find_by_name(fn_name);
368        // Filter to functions/methods only, pick the one with most callees
369        let sym = symbols
370            .iter()
371            .filter(|s| matches!(s.kind, SymbolKind::Function | SymbolKind::Method))
372            .max_by_key(|s| self.callees(s.id).map(|e| e.len()).unwrap_or(0))?;
373
374        let callees = self.trace_callees(sym.id, 3);
375        if callees.is_empty() {
376            return None;
377        }
378
379        let mut chain = format!("[Call chain: {}()", fn_name);
380        for (callee_id, depth) in &callees {
381            if let Some(node) = self.node(*callee_id) {
382                let short_file = node
383                    .file
384                    .file_name()
385                    .map(|f| f.to_string_lossy().to_string())
386                    .unwrap_or_default();
387                let indent = " → ".repeat(*depth);
388                chain.push_str(&format!(
389                    "\n  {}{}() ({}:{})",
390                    indent, node.name, short_file, node.start_line
391                ));
392            }
393        }
394        chain.push_str("]\n");
395        chain.push_str(
396            "[SCOPE: The issue is in ONE of these functions. \
397            Read ONLY these files — do not explore outside this chain.]",
398        );
399        Some(chain)
400    }
401}
402
403impl Default for CodeGraph {
404    fn default() -> Self {
405        Self::new()
406    }
407}