1use std::collections::{HashMap, HashSet, VecDeque};
2use std::path::Path;
3
4use crate::compress::{
5 compressor_language_from_ir_string, CompressorClient, CompressorConfig,
6};
7use crate::graph::{
8 class_body_spans_for_file, function_body_spans_for_file, property_body_spans_for_file,
9};
10use crate::scanner::FileScanConfig;
11use crate::scanner_incremental::scan_and_parse_incremental_vector;
12use crate::ir::{
13 api_endpoint_key, external_api_key, module_key, ApiEndpointIr, BehaviourIr, CallbackIr,
14 ClassIr, EdgeIr, EdgeKind, ExternalApiIr, FileIr, FunctionIr, ModuleIr, PropertyIr, ProjectIr,
15};
16use crate::schema::NodeLabel;
17
18#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
20pub struct SymbolRef {
21 pub label: String,
22 pub key: String,
23 pub name: Option<String>,
24 pub path: Option<String>,
25}
26
27#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
29pub struct FileSymbols {
30 pub path: String,
31 pub classes: Vec<SymbolRef>,
32 pub functions: Vec<SymbolRef>,
33 pub modules: Vec<SymbolRef>,
34 pub api_endpoints: Vec<SymbolRef>,
35}
36
37#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
39pub struct ImpactReport {
40 pub symbol: String,
41 pub depth: u32,
42 pub callers: Vec<String>,
43 pub affected_files: Vec<String>,
44 pub truncated: bool,
45}
46
47#[derive(Debug, Clone, Copy)]
48pub struct QueryLimits {
49 pub max_depth: u32,
50 pub max_results: usize,
51}
52
53impl Default for QueryLimits {
54 fn default() -> Self {
55 Self {
56 max_depth: 2,
57 max_results: 50,
58 }
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum ExplainSourceOrigin {
66 Decompressed,
67 FileSpan,
68 Unavailable,
69}
70
71#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
72pub struct ExplainOptions {
73 #[serde(default)]
74 pub include_callers: bool,
75 #[serde(default)]
76 pub include_callees: bool,
77}
78
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80pub struct ExplainSymbolResult {
81 pub symbol: SymbolRef,
82 pub source: Option<String>,
83 pub source_origin: ExplainSourceOrigin,
84 #[serde(skip_serializing_if = "Option::is_none")]
85 pub error: Option<String>,
86 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub callers: Vec<SymbolRef>,
88 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub callees: Vec<SymbolRef>,
90}
91
92#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
93pub struct RefreshReport {
94 pub cleanup_targets: usize,
95 pub parse_targets: usize,
96 pub nodes_merged: usize,
97 pub edges_merged: usize,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
101enum NodeKind {
102 File,
103 Module,
104 Class,
105 Property,
106 Function,
107 Behaviour,
108 Callback,
109 ApiEndpoint,
110 ExternalApi,
111}
112
113#[derive(Debug, Clone)]
114struct NodeRecord {
115 kind: NodeKind,
116 key: String,
117 label: String,
118 name: Option<String>,
119 path: Option<String>,
120 language: Option<String>,
121 code_bytes: Option<Vec<u8>>,
122}
123
124type AdjKey = (usize, EdgeKind);
125
126#[derive(Debug, Default)]
128pub struct InMemoryGraph {
129 nodes: Vec<NodeRecord>,
130 nodes_by_key: HashMap<(NodeKind, String), usize>,
131 symbols_by_path: HashMap<String, HashSet<usize>>,
132 by_simple_name: HashMap<String, Vec<usize>>,
133 outgoing: HashMap<AdjKey, Vec<usize>>,
134 incoming: HashMap<AdjKey, Vec<usize>>,
135}
136
137pub trait GraphStore {
138 fn callers(&self, fqn: &str) -> Vec<SymbolRef>;
139 fn callees(&self, fqn: &str) -> Vec<SymbolRef>;
140 fn file_dependencies(&self, path: &str) -> Vec<String>;
141 fn impact(&self, fqn: &str, limits: QueryLimits) -> ImpactReport;
142 fn symbols_in_file(&self, path: &str) -> FileSymbols;
143 fn find_symbol(&self, query: &str) -> Vec<SymbolRef>;
144 fn node_count(&self) -> usize;
145 fn edge_count(&self) -> usize;
146}
147
148impl InMemoryGraph {
149 pub fn from_ir(ir: ProjectIr) -> Self {
150 let mut g = Self::default();
151 g.merge_ir(ir);
152 g
153 }
154
155 pub fn merge_ir(&mut self, delta: ProjectIr) {
156 for f in delta.files {
157 self.upsert_file(f);
158 }
159 for m in delta.modules {
160 self.upsert_module(m);
161 }
162 for c in delta.classes {
163 self.upsert_class(c);
164 }
165 for p in delta.properties {
166 self.upsert_property(p);
167 }
168 for f in delta.functions {
169 self.upsert_function(f);
170 }
171 for b in delta.behaviours {
172 self.upsert_behaviour(b);
173 }
174 for c in delta.callbacks {
175 self.upsert_callback(c);
176 }
177 for a in delta.api_endpoints {
178 self.upsert_api_endpoint(a);
179 }
180 for e in delta.external_apis {
181 self.upsert_external_api(e);
182 }
183 for edge in delta.edges {
184 self.add_edge(edge);
185 }
186 }
187
188 pub fn remove_file(&mut self, path: &str) {
189 let normalized = path.replace('\\', "/");
190 let to_remove: Vec<usize> = self
191 .nodes
192 .iter()
193 .enumerate()
194 .filter(|(_, n)| n.path.as_deref() == Some(normalized.as_str()))
195 .map(|(i, _)| i)
196 .collect();
197
198 if to_remove.is_empty() {
199 return;
200 }
201
202 let remove_set: HashSet<usize> = to_remove.into_iter().collect();
203 self.remove_nodes(&remove_set);
204 }
205
206 fn remove_nodes(&mut self, remove_set: &HashSet<usize>) {
207 let mut remap: HashMap<usize, Option<usize>> = HashMap::new();
208 let mut new_nodes: Vec<NodeRecord> = Vec::new();
209
210 for (old_id, node) in self.nodes.iter().enumerate() {
211 if remove_set.contains(&old_id) {
212 remap.insert(old_id, None);
213 self.nodes_by_key.remove(&(node.kind, node.key.clone()));
214 if let Some(p) = &node.path {
215 if let Some(set) = self.symbols_by_path.get_mut(p) {
216 set.remove(&old_id);
217 }
218 }
219 if let Some(name) = &node.name {
220 if let Some(ids) = self.by_simple_name.get_mut(name) {
221 ids.retain(|id| *id != old_id);
222 }
223 }
224 } else {
225 let new_id = new_nodes.len();
226 remap.insert(old_id, Some(new_id));
227 new_nodes.push(node.clone());
228 }
229 }
230
231 self.nodes = new_nodes;
232 self.rebuild_adjacency(&remap);
233 self.rebuild_indexes();
234 }
235
236 fn rebuild_adjacency(&mut self, remap: &HashMap<usize, Option<usize>>) {
237 let mut new_out: HashMap<AdjKey, Vec<usize>> = HashMap::new();
238 let mut new_in: HashMap<AdjKey, Vec<usize>> = HashMap::new();
239
240 for ((from, kind), targets) in &self.outgoing {
241 let Some(&Some(new_from)) = remap.get(from) else {
242 continue;
243 };
244 for to in targets {
245 if let Some(Some(new_to)) = remap.get(to) {
246 new_out.entry((new_from, *kind)).or_default().push(*new_to);
247 new_in.entry((*new_to, *kind)).or_default().push(new_from);
248 }
249 }
250 }
251 self.outgoing = new_out;
252 self.incoming = new_in;
253 }
254
255 fn rebuild_indexes(&mut self) {
256 self.nodes_by_key.clear();
257 self.symbols_by_path.clear();
258 self.by_simple_name.clear();
259 for (id, node) in self.nodes.iter().enumerate() {
260 self.nodes_by_key.insert((node.kind, node.key.clone()), id);
261 if let Some(p) = &node.path {
262 self.symbols_by_path.entry(p.clone()).or_default().insert(id);
263 }
264 if let Some(name) = &node.name {
265 self.by_simple_name.entry(name.clone()).or_default().push(id);
266 }
267 }
268 }
269
270 fn upsert_file(&mut self, f: FileIr) {
271 let _id = self.ensure_node(
272 NodeKind::File,
273 f.path.clone(),
274 NodeLabel::File.to_string(),
275 None,
276 Some(f.path),
277 );
278 }
279
280 fn upsert_module(&mut self, m: ModuleIr) {
281 let key = module_key(&m.name, &m.path);
282 let id = self.ensure_node(
283 NodeKind::Module,
284 key,
285 NodeLabel::Module.to_string(),
286 Some(m.name),
287 Some(m.path),
288 );
289 self.apply_symbol_metadata(id, Some(&m.language), m.code_bytes);
290 }
291
292 fn upsert_class(&mut self, c: ClassIr) {
293 let id = self.ensure_node(
294 NodeKind::Class,
295 c.fqn.clone(),
296 "Class".to_string(),
297 Some(c.name),
298 Some(c.path),
299 );
300 self.apply_symbol_metadata(id, Some(&c.language), c.code_bytes);
301 }
302
303 fn upsert_property(&mut self, p: PropertyIr) {
304 let id = self.ensure_node(
305 NodeKind::Property,
306 p.fqn.clone(),
307 "Property".to_string(),
308 Some(p.name),
309 Some(p.path),
310 );
311 self.apply_symbol_metadata(id, Some(&p.language), p.code_bytes);
312 }
313
314 fn upsert_function(&mut self, f: FunctionIr) {
315 let id = self.ensure_node(
316 NodeKind::Function,
317 f.fqn.clone(),
318 NodeLabel::Function.to_string(),
319 Some(f.name),
320 Some(f.path),
321 );
322 self.apply_symbol_metadata(id, Some(&f.language), f.code_bytes);
323 }
324
325 fn apply_symbol_metadata(
326 &mut self,
327 id: usize,
328 language: Option<&str>,
329 code_bytes: Option<Vec<u8>>,
330 ) {
331 let Some(n) = self.nodes.get_mut(id) else {
332 return;
333 };
334 if let Some(lang) = language {
335 n.language = Some(lang.to_string());
336 }
337 if let Some(bytes) = code_bytes {
338 n.code_bytes = Some(bytes);
339 }
340 }
341
342 fn upsert_behaviour(&mut self, b: BehaviourIr) {
343 let _id = self.ensure_node(
344 NodeKind::Behaviour,
345 b.name.clone(),
346 NodeLabel::Behaviour.to_string(),
347 Some(b.name.clone()),
348 b.path,
349 );
350 }
351
352 fn upsert_callback(&mut self, c: CallbackIr) {
353 let _id = self.ensure_node(
354 NodeKind::Callback,
355 c.fqn.clone(),
356 NodeLabel::Callback.to_string(),
357 Some(c.name),
358 None,
359 );
360 }
361
362 fn upsert_api_endpoint(&mut self, a: ApiEndpointIr) {
363 let key = api_endpoint_key(&a.methods, &a.path);
364 let _id = self.ensure_node(
365 NodeKind::ApiEndpoint,
366 key,
367 NodeLabel::ApiEndpoint.to_string(),
368 Some(a.path.clone()),
369 None,
370 );
371 }
372
373 fn upsert_external_api(&mut self, e: ExternalApiIr) {
374 let key = if let (Some(base), Some(norm)) = (&e.base_url, &e.norm_path) {
375 external_api_key(base, norm)
376 } else {
377 e.name.clone()
378 };
379 let _id = self.ensure_node(
380 NodeKind::ExternalApi,
381 key,
382 NodeLabel::ExternalApi.to_string(),
383 Some(e.name),
384 None,
385 );
386 }
387
388 fn ensure_node(
389 &mut self,
390 kind: NodeKind,
391 key: String,
392 label: String,
393 name: Option<String>,
394 path: Option<String>,
395 ) -> usize {
396 if let Some(&id) = self.nodes_by_key.get(&(kind, key.clone())) {
397 if let Some(n) = self.nodes.get_mut(id) {
398 if name.is_some() {
399 n.name = name.clone();
400 }
401 if let Some(new_path) = path.clone() {
402 if n.path.as_deref() != Some(new_path.as_str()) {
403 if let Some(old_path) = n.path.take() {
404 if let Some(set) = self.symbols_by_path.get_mut(&old_path) {
405 set.remove(&id);
406 }
407 }
408 n.path = Some(new_path.clone());
409 self.symbols_by_path
410 .entry(new_path)
411 .or_default()
412 .insert(id);
413 }
414 }
415 }
416 return id;
417 }
418 let id = self.nodes.len();
419 let record = NodeRecord {
420 kind,
421 key: key.clone(),
422 label,
423 name: name.clone(),
424 path: path.clone(),
425 language: None,
426 code_bytes: None,
427 };
428 self.nodes.push(record);
429 self.nodes_by_key.insert((kind, key), id);
430 if let Some(p) = path {
431 self.symbols_by_path.entry(p).or_default().insert(id);
432 }
433 if let Some(n) = name {
434 self.by_simple_name.entry(n).or_default().push(id);
435 }
436 id
437 }
438
439 fn add_edge(&mut self, edge: EdgeIr) {
440 let Some(from_id) = self.lookup_id(&edge.from_label, &edge.from_key) else {
441 return;
442 };
443 let Some(to_id) = self.lookup_id(&edge.to_label, &edge.to_key) else {
444 return;
445 };
446 let out_list = self.outgoing.entry((from_id, edge.kind)).or_default();
447 if !out_list.contains(&to_id) {
448 out_list.push(to_id);
449 }
450 let in_list = self.incoming.entry((to_id, edge.kind)).or_default();
451 if !in_list.contains(&from_id) {
452 in_list.push(from_id);
453 }
454 }
455
456 fn lookup_id(&self, label: &str, key: &str) -> Option<usize> {
457 let kind = node_kind_from_label(label)?;
458 self.nodes_by_key.get(&(kind, key.to_string())).copied()
459 }
460
461 fn function_id(&self, fqn: &str) -> Option<usize> {
462 if let Some(&id) = self
463 .nodes_by_key
464 .get(&(NodeKind::Function, fqn.to_string()))
465 {
466 return Some(id);
467 }
468 let suffix = format!("::{fqn}");
469 let mut suffix_matches = self
470 .nodes
471 .iter()
472 .enumerate()
473 .filter(|(_, n)| n.kind == NodeKind::Function && n.key.ends_with(&suffix));
474 if let Some((id, _)) = suffix_matches.next() {
475 if suffix_matches.next().is_none() {
476 return Some(id);
477 }
478 return None;
479 }
480 if let Some(ids) = self.by_simple_name.get(fqn) {
481 let fn_ids: Vec<usize> = ids
482 .iter()
483 .copied()
484 .filter(|id| self.nodes.get(*id).is_some_and(|n| n.kind == NodeKind::Function))
485 .collect();
486 if fn_ids.len() == 1 {
487 return Some(fn_ids[0]);
488 }
489 }
490 None
491 }
492
493 fn node_to_symbol(&self, id: usize) -> Option<SymbolRef> {
494 self.nodes.get(id).map(|n| SymbolRef {
495 label: n.label.clone(),
496 key: n.key.clone(),
497 name: n.name.clone(),
498 path: n.path.clone(),
499 })
500 }
501
502 #[cfg(test)]
503 fn code_bytes_for_symbol(&self, query: &str) -> Option<Vec<u8>> {
504 let id = self.resolve_symbol(query).ok()?;
505 self.nodes[id].code_bytes.clone()
506 }
507
508 #[cfg(test)]
509 fn language_for_symbol(&self, query: &str) -> Option<String> {
510 let id = self.resolve_symbol(query).ok()?;
511 self.nodes[id].language.clone()
512 }
513
514 fn resolve_symbol(&self, query: &str) -> Result<usize, String> {
516 let q = query.trim();
517 if q.is_empty() {
518 return Err("empty symbol query".into());
519 }
520
521 for kind in [
522 NodeKind::Function,
523 NodeKind::Class,
524 NodeKind::Property,
525 NodeKind::Module,
526 ] {
527 if let Some(&id) = self.nodes_by_key.get(&(kind, q.to_string())) {
528 return Ok(id);
529 }
530 }
531
532 if let Some(id) = self.function_id(q) {
533 return Ok(id);
534 }
535
536 let suffix = format!("::{q}");
537 let mut class_matches = self
538 .nodes
539 .iter()
540 .enumerate()
541 .filter(|(_, n)| n.kind == NodeKind::Class && n.key.ends_with(&suffix));
542 if let Some((id, _)) = class_matches.next() {
543 if class_matches.next().is_none() {
544 return Ok(id);
545 }
546 return Err(format!("ambiguous class match for `{q}`"));
547 }
548
549 if let Some(ids) = self.by_simple_name.get(q) {
550 let symbol_ids: Vec<usize> = ids
551 .iter()
552 .copied()
553 .filter(|id| {
554 self.nodes.get(*id).is_some_and(|n| {
555 matches!(
556 n.kind,
557 NodeKind::Function | NodeKind::Class | NodeKind::Property
558 )
559 })
560 })
561 .collect();
562 if symbol_ids.len() == 1 {
563 return Ok(symbol_ids[0]);
564 }
565 if symbol_ids.len() > 1 {
566 return Err(format!("ambiguous symbol match for `{q}`"));
567 }
568 }
569
570 if q.contains('@') {
571 if let Some(&id) = self.nodes_by_key.get(&(NodeKind::Module, q.to_string())) {
572 return Ok(id);
573 }
574 }
575
576 Err(format!("symbol not found: `{q}`"))
577 }
578
579 fn slice_source_span(source: &str, span: (usize, usize)) -> Option<String> {
580 let (lo, hi) = span;
581 let lo = lo.min(source.len());
582 let hi = hi.min(source.len());
583 if lo >= hi {
584 return None;
585 }
586 Some(source[lo..hi].to_string())
587 }
588
589 fn span_for_node_in_file(
590 node: &NodeRecord,
591 file: &crate::scanner::ParsedFile,
592 source: &str,
593 ) -> Option<(usize, usize)> {
594 let path = node.path.as_deref()?;
595 match node.kind {
596 NodeKind::Function => {
597 let spans = function_body_spans_for_file(file, path, source);
598 spans.get(&node.key).copied()
599 }
600 NodeKind::Class => {
601 let spans = class_body_spans_for_file(file, source);
602 spans.get(&node.key).copied()
603 }
604 NodeKind::Property => {
605 let spans = property_body_spans_for_file(file, source);
606 spans.get(&node.key).copied()
607 }
608 NodeKind::Module => Some((0, source.len())),
609 _ => None,
610 }
611 }
612
613 async fn source_from_file_span(
614 &self,
615 node: &NodeRecord,
616 root: &Path,
617 ) -> Result<String, String> {
618 let rel_path = node
619 .path
620 .as_deref()
621 .ok_or_else(|| "symbol has no file path".to_string())?;
622 let full_path = root.join(rel_path);
623 let source = std::fs::read_to_string(&full_path)
624 .map_err(|e| format!("failed to read {}: {e}", full_path.display()))?;
625
626 let config = FileScanConfig::new(root);
627 let paths = vec![std::path::PathBuf::from(rel_path)];
628 let files = scan_and_parse_incremental_vector(&config, &paths)
629 .map_err(|e| format!("failed to parse {rel_path}: {e}"))?;
630 let file = files
631 .first()
632 .ok_or_else(|| format!("no parse result for {rel_path}"))?;
633
634 let span = Self::span_for_node_in_file(node, file, &source).ok_or_else(|| {
635 format!(
636 "no AST span for {} `{}` in {rel_path}",
637 node.label, node.key
638 )
639 })?;
640 Self::slice_source_span(&source, span)
641 .ok_or_else(|| format!("empty source span for `{}`", node.key))
642 }
643
644 pub async fn explain_symbol_logic(
646 &self,
647 fqn: &str,
648 root: &Path,
649 compressor: &CompressorConfig,
650 opts: ExplainOptions,
651 ) -> ExplainSymbolResult {
652 let empty_symbol = SymbolRef {
653 label: String::new(),
654 key: fqn.to_string(),
655 name: None,
656 path: None,
657 };
658
659 let node_id = match self.resolve_symbol(fqn) {
660 Ok(id) => id,
661 Err(e) => {
662 return ExplainSymbolResult {
663 symbol: empty_symbol,
664 source: None,
665 source_origin: ExplainSourceOrigin::Unavailable,
666 error: Some(e),
667 callers: Vec::new(),
668 callees: Vec::new(),
669 };
670 }
671 };
672
673 let node = &self.nodes[node_id];
674 let symbol = self.node_to_symbol(node_id).unwrap_or(empty_symbol);
675
676 let mut result = ExplainSymbolResult {
677 symbol,
678 source: None,
679 source_origin: ExplainSourceOrigin::Unavailable,
680 error: None,
681 callers: Vec::new(),
682 callees: Vec::new(),
683 };
684
685 if node.kind == NodeKind::Function {
686 if opts.include_callers {
687 result.callers = self.callers(&node.key);
688 }
689 if opts.include_callees {
690 result.callees = self.callees(&node.key);
691 }
692 }
693
694 if let (Some(blob), Some(lang_str)) = (&node.code_bytes, &node.language) {
695 if compressor.enabled {
696 if let Some(api_lang) = compressor_language_from_ir_string(lang_str) {
697 if let Ok(client) = CompressorClient::from_config(compressor) {
698 match client.decompress_code(blob, api_lang).await {
699 Ok(code) => {
700 result.source = Some(code);
701 result.source_origin = ExplainSourceOrigin::Decompressed;
702 return result;
703 }
704 Err(e) => {
705 result.error = Some(format!("decompress failed: {e}"));
706 }
707 }
708 }
709 }
710 }
711 }
712
713 match self.source_from_file_span(node, root).await {
714 Ok(source) => {
715 result.source = Some(source);
716 result.source_origin = ExplainSourceOrigin::FileSpan;
717 result.error = None;
718 }
719 Err(e) => {
720 if result.error.is_none() {
721 result.error = Some(e);
722 } else {
723 result.error = Some(format!("{}; {}", result.error.unwrap(), e));
724 }
725 }
726 }
727
728 result
729 }
730}
731
732impl GraphStore for InMemoryGraph {
733 fn callers(&self, fqn: &str) -> Vec<SymbolRef> {
734 let Some(fn_id) = self.function_id(fqn) else {
735 return Vec::new();
736 };
737 self.incoming
738 .get(&(fn_id, EdgeKind::CallsFunction))
739 .map(|ids| {
740 ids.iter()
741 .filter_map(|id| self.node_to_symbol(*id))
742 .collect()
743 })
744 .unwrap_or_default()
745 }
746
747 fn callees(&self, fqn: &str) -> Vec<SymbolRef> {
748 let Some(fn_id) = self.function_id(fqn) else {
749 return Vec::new();
750 };
751 self.outgoing
752 .get(&(fn_id, EdgeKind::CallsFunction))
753 .map(|ids| {
754 ids.iter()
755 .filter_map(|id| self.node_to_symbol(*id))
756 .collect()
757 })
758 .unwrap_or_default()
759 }
760
761 fn file_dependencies(&self, path: &str) -> Vec<String> {
762 let normalized = path.replace('\\', "/");
763 let Some(&file_id) = self.nodes_by_key.get(&(NodeKind::File, normalized.clone())) else {
764 return Vec::new();
765 };
766 self.outgoing
767 .get(&(file_id, EdgeKind::DependsOnFile))
768 .map(|ids| {
769 ids.iter()
770 .filter_map(|id| self.nodes.get(*id).and_then(|n| n.path.clone()))
771 .collect()
772 })
773 .unwrap_or_default()
774 }
775
776 fn impact(&self, fqn: &str, limits: QueryLimits) -> ImpactReport {
777 let mut report = ImpactReport {
778 symbol: fqn.to_string(),
779 depth: limits.max_depth,
780 ..Default::default()
781 };
782 let Some(start) = self.function_id(fqn) else {
783 return report;
784 };
785
786 let mut visited: HashSet<usize> = HashSet::new();
787 let mut queue: VecDeque<(usize, u32)> = VecDeque::new();
788 queue.push_back((start, 0));
789 visited.insert(start);
790
791 while let Some((node_id, depth)) = queue.pop_front() {
792 if depth >= limits.max_depth {
793 continue;
794 }
795 if let Some(callers) = self.incoming.get(&(node_id, EdgeKind::CallsFunction)) {
796 for caller_id in callers {
797 if visited.contains(caller_id) {
798 continue;
799 }
800 if report.callers.len() >= limits.max_results {
801 report.truncated = true;
802 return report;
803 }
804 visited.insert(*caller_id);
805 if let Some(sym) = self.node_to_symbol(*caller_id) {
806 if sym.label == NodeLabel::Function.to_string() {
807 report.callers.push(sym.key.clone());
808 }
809 if let Some(p) = sym.path {
810 if !report.affected_files.contains(&p) {
811 report.affected_files.push(p);
812 }
813 }
814 }
815 queue.push_back((*caller_id, depth + 1));
816 }
817 }
818 }
819 report
820 }
821
822 fn symbols_in_file(&self, path: &str) -> FileSymbols {
823 let normalized = path.replace('\\', "/");
824 let mut out = FileSymbols {
825 path: normalized.clone(),
826 ..Default::default()
827 };
828 let Some(ids) = self.symbols_by_path.get(&normalized) else {
829 return out;
830 };
831 for id in ids {
832 let Some(n) = self.nodes.get(*id) else {
833 continue;
834 };
835 let sym = SymbolRef {
836 label: n.label.clone(),
837 key: n.key.clone(),
838 name: n.name.clone(),
839 path: n.path.clone(),
840 };
841 match n.kind {
842 NodeKind::Class => out.classes.push(sym),
843 NodeKind::Function => out.functions.push(sym),
844 NodeKind::Module => out.modules.push(sym),
845 NodeKind::ApiEndpoint => out.api_endpoints.push(sym),
846 _ => {}
847 }
848 }
849 out
850 }
851
852 fn find_symbol(&self, query: &str) -> Vec<SymbolRef> {
853 let q = query.trim();
854 if q.is_empty() {
855 return Vec::new();
856 }
857
858 if let Some(&id) = self.nodes_by_key.get(&(NodeKind::Function, q.to_string())) {
859 return vec![self.node_to_symbol(id).unwrap()];
860 }
861 if let Some(&id) = self.nodes_by_key.get(&(NodeKind::Class, q.to_string())) {
862 return vec![self.node_to_symbol(id).unwrap()];
863 }
864
865 let q_lower = q.to_lowercase();
866 let mut results = Vec::new();
867 for (id, node) in self.nodes.iter().enumerate() {
868 if node.key.to_lowercase().contains(&q_lower)
869 || node
870 .name
871 .as_ref()
872 .is_some_and(|n| n.to_lowercase().contains(&q_lower))
873 {
874 if let Some(sym) = self.node_to_symbol(id) {
875 results.push(sym);
876 }
877 }
878 }
879 results.sort_by(|a, b| a.key.cmp(&b.key));
880 results.truncate(50);
881 results
882 }
883
884 fn node_count(&self) -> usize {
885 self.nodes.len()
886 }
887
888 fn edge_count(&self) -> usize {
889 self.outgoing.values().map(|v| v.len()).sum()
890 }
891}
892
893fn node_kind_from_label(label: &str) -> Option<NodeKind> {
894 match label {
895 "File" => Some(NodeKind::File),
896 "Module" => Some(NodeKind::Module),
897 "Class" => Some(NodeKind::Class),
898 "Property" => Some(NodeKind::Property),
899 "Function" => Some(NodeKind::Function),
900 "Behaviour" => Some(NodeKind::Behaviour),
901 "Callback" => Some(NodeKind::Callback),
902 "ApiEndpoint" => Some(NodeKind::ApiEndpoint),
903 "ExternalApi" => Some(NodeKind::ExternalApi),
904 _ => None,
905 }
906}
907
908#[cfg(test)]
909mod tests {
910 use super::*;
911 use crate::ir::{EdgeKind, ProjectIr};
912
913 fn sample_ir() -> ProjectIr {
914 let mut ir = ProjectIr::empty();
915 ir.files.push(FileIr {
916 path: "src/a.rs".into(),
917 language: "rust".into(),
918 framework: None,
919 project_name: None,
920 });
921 ir.functions.push(FunctionIr {
922 name: "main".into(),
923 fqn: "src/a.rs::main".into(),
924 path: "src/a.rs".into(),
925 language: "rust".into(),
926 framework: None,
927 project_name: None,
928 arity: None,
929 return_type: None,
930 param_count: None,
931 param_types: vec![],
932 code_bytes: None,
933 });
934 ir.functions.push(FunctionIr {
935 name: "helper".into(),
936 fqn: "src/a.rs::helper".into(),
937 path: "src/a.rs".into(),
938 language: "rust".into(),
939 framework: None,
940 project_name: None,
941 arity: None,
942 return_type: None,
943 param_count: None,
944 param_types: vec![],
945 code_bytes: None,
946 });
947 ir.edges.push(EdgeIr {
948 kind: EdgeKind::DeclaresFunction,
949 from_label: "File".into(),
950 from_key: "src/a.rs".into(),
951 to_label: "Function".into(),
952 to_key: "src/a.rs::main".into(),
953 });
954 ir.edges.push(EdgeIr {
955 kind: EdgeKind::CallsFunction,
956 from_label: "Function".into(),
957 from_key: "src/a.rs::main".into(),
958 to_label: "Function".into(),
959 to_key: "src/a.rs::helper".into(),
960 });
961 ir
962 }
963
964 #[test]
965 fn in_memory_graph_queries_callers_and_callees() {
966 let graph = InMemoryGraph::from_ir(sample_ir());
967 let callees = graph.callees("src/a.rs::main");
968 assert_eq!(callees.len(), 1);
969 assert_eq!(callees[0].key, "src/a.rs::helper");
970 let callers = graph.callers("src/a.rs::helper");
971 assert_eq!(callers.len(), 1);
972 assert_eq!(callers[0].key, "src/a.rs::main");
973 }
974
975 #[test]
976 fn in_memory_graph_callers_by_short_name_when_unique() {
977 let graph = InMemoryGraph::from_ir(sample_ir());
978 let callers = graph.callers("helper");
979 assert_eq!(callers.len(), 1);
980 assert_eq!(callers[0].key, "src/a.rs::main");
981 }
982
983 #[test]
984 fn in_memory_graph_callees_by_short_name_when_unique() {
985 let graph = InMemoryGraph::from_ir(sample_ir());
986 let callees = graph.callees("main");
987 assert_eq!(callees.len(), 1);
988 assert_eq!(callees[0].key, "src/a.rs::helper");
989 }
990
991 #[test]
992 fn ensure_node_reindexes_symbols_by_path_on_path_change() {
993 fn fn_ir(path: &str) -> FunctionIr {
994 FunctionIr {
995 name: "dup".into(),
996 fqn: "dup".into(),
997 path: path.into(),
998 language: "rust".into(),
999 framework: None,
1000 project_name: None,
1001 arity: None,
1002 return_type: None,
1003 param_count: None,
1004 param_types: vec![],
1005 code_bytes: None,
1006 }
1007 }
1008 let mut graph = InMemoryGraph::default();
1009 let mut ir = ProjectIr::empty();
1010 ir.functions.push(fn_ir("src/a.rs"));
1011 graph.merge_ir(ir);
1012 assert_eq!(graph.symbols_in_file("src/a.rs").functions.len(), 1);
1013 let mut ir2 = ProjectIr::empty();
1014 ir2.functions.push(fn_ir("src/b.rs"));
1015 graph.merge_ir(ir2);
1016 assert!(graph.symbols_in_file("src/a.rs").functions.is_empty());
1017 assert_eq!(graph.symbols_in_file("src/b.rs").functions.len(), 1);
1018 }
1019
1020 #[test]
1021 fn remove_file_strips_symbols() {
1022 let mut graph = InMemoryGraph::from_ir(sample_ir());
1023 graph.remove_file("src/a.rs");
1024 assert!(graph.find_symbol("main").is_empty());
1025 }
1026
1027 #[test]
1028 fn merge_ir_preserves_code_bytes_on_function() {
1029 let mut ir = sample_ir();
1030 ir.functions[0].code_bytes = Some(vec![1, 2, 3]);
1031 let graph = InMemoryGraph::from_ir(ir);
1032 assert_eq!(
1033 graph.code_bytes_for_symbol("src/a.rs::main").as_deref(),
1034 Some(&[1, 2, 3][..])
1035 );
1036 assert_eq!(graph.language_for_symbol("src/a.rs::main").as_deref(), Some("rust"));
1037 }
1038
1039 #[test]
1040 fn merge_ir_coalesces_code_bytes_when_delta_omits_blob() {
1041 let mut ir = sample_ir();
1042 ir.functions[0].code_bytes = Some(vec![9, 8, 7]);
1043 let mut graph = InMemoryGraph::from_ir(ir);
1044 let mut ir2 = sample_ir();
1045 ir2.functions[0].code_bytes = None;
1046 graph.merge_ir(ir2);
1047 assert_eq!(
1048 graph.code_bytes_for_symbol("src/a.rs::main").as_deref(),
1049 Some(&[9, 8, 7][..])
1050 );
1051 }
1052
1053 #[tokio::test]
1054 async fn explain_symbol_logic_file_span_fallback() {
1055 let dir = tempfile::tempdir().unwrap();
1056 let root = dir.path();
1057 std::fs::create_dir_all(root.join("src")).unwrap();
1058 std::fs::write(
1059 root.join("src/a.rs"),
1060 "fn main() {\n helper();\n}\nfn helper() {}\n",
1061 )
1062 .unwrap();
1063
1064 let mut ir = ProjectIr::empty();
1065 ir.files.push(FileIr {
1066 path: "src/a.rs".into(),
1067 language: "rust".into(),
1068 framework: None,
1069 project_name: None,
1070 });
1071 ir.functions.push(FunctionIr {
1072 name: "main".into(),
1073 fqn: "src/a.rs::main".into(),
1074 path: "src/a.rs".into(),
1075 language: "rust".into(),
1076 framework: None,
1077 project_name: None,
1078 arity: None,
1079 return_type: None,
1080 param_count: None,
1081 param_types: vec![],
1082 code_bytes: None,
1083 });
1084
1085 let graph = InMemoryGraph::from_ir(ir);
1086 let compressor = CompressorConfig {
1087 enabled: false,
1088 ..Default::default()
1089 };
1090 let result = graph
1091 .explain_symbol_logic(
1092 "src/a.rs::main",
1093 root,
1094 &compressor,
1095 ExplainOptions::default(),
1096 )
1097 .await;
1098 assert_eq!(result.source_origin, ExplainSourceOrigin::FileSpan);
1099 let source = result.source.expect("source");
1100 assert!(
1101 source.contains("helper()"),
1102 "expected function body span, got: {source:?}"
1103 );
1104 }
1105
1106}