1use std::collections::{HashMap, HashSet};
2use std::path::Path;
3
4use anyhow::Result;
5use petgraph::graph::EdgeIndex;
6use tracing::warn;
7
8use crate::ingest::parser::{Entity, FileInsight, ImportStmt};
9use crate::model::*;
10
11pub fn build(insights: &[FileInsight]) -> Result<KnowledgeGraph> {
13 let mut kg = KnowledgeGraph::default();
14 let g = &mut kg.graph;
15
16 let mut name_map: HashMap<String, Vec<NodeId>> = HashMap::new();
25 let mut path_map: HashMap<Vec<String>, NodeId> = HashMap::new();
26
27 let project_id = g.add_node(CodeNode {
28 id: NodeId::new(g.node_count()),
29 kind: NodeKind::Project,
30 name: "project".into(),
31 file_path: None,
32 line_range: None,
33 doc_comment: None,
34 signature: None, visibility: None,
35 module_path: Vec::new(),
36 });
37 name_map.entry("project".to_string()).or_default().push(project_id);
38 path_map.insert(Vec::new(), project_id);
39
40 let mut module_cache: HashMap<Vec<String>, NodeId> = HashMap::new();
41 let mut call_candidates: Vec<(Entity, NodeId, String)> = Vec::new();
43
44 for insight in insights {
45 let path = Path::new(&insight.path);
46 let dir_segments: Vec<String> = path
47 .parent()
48 .map(|p| {
49 p.components()
50 .filter_map(|c| match c {
51 std::path::Component::Normal(s) => {
52 Some(s.to_string_lossy().into_owned())
53 }
54 _ => None,
55 })
56 .collect()
57 })
58 .unwrap_or_default();
59
60 let file_module_id = ensure_module_chain(
61 g, &mut module_cache, project_id, &dir_segments, &mut name_map, &mut path_map,
62 );
63
64 let file_id = g.add_node(CodeNode {
65 id: NodeId::new(g.node_count()),
66 kind: NodeKind::File,
67 name: path
68 .file_name()
69 .map(|s| s.to_string_lossy().into_owned())
70 .unwrap_or_default(),
71 file_path: Some(insight.path.to_string_lossy().into_owned()),
72 line_range: None,
73 doc_comment: None,
74 signature: None, visibility: None,
75 module_path: dir_segments.clone(),
76 });
77
78 g.add_edge(
79 file_module_id,
80 file_id,
81 CodeEdge {
82 id: EdgeIndex::new(g.edge_count()),
83 kind: EdgeKind::Contains,
84 source: file_module_id,
85 target: file_id,
86 weight: 1.0,
87 location: None,
88 },
89 );
90 name_map
92 .entry(path.file_name().map(|s| s.to_string_lossy().into_owned()).unwrap_or_default())
93 .or_default()
94 .push(file_id);
95 path_map.insert(dir_segments.clone(), file_id);
96
97 let entity_ids: Vec<(Entity, NodeId)> = insight
98 .entities
99 .iter()
100 .map(|e| {
101 let kind = kind_from_str(&e.kind);
102 let mut module_path = dir_segments.clone();
103 if let Some(stem) = path.file_stem() {
104 module_path.push(stem.to_string_lossy().into_owned());
105 }
106 let id = g.add_node(CodeNode {
107 id: NodeId::new(g.node_count()),
108 kind,
109 name: e.name.clone(),
110 file_path: Some(insight.path.to_string_lossy().into_owned()),
111 line_range: Some((e.line_start, e.line_end)),
112 doc_comment: e.doc_comment.clone(),
113 signature: e.signature.clone(),
114 visibility: e.visibility.clone(),
115 module_path: module_path.clone(),
116 });
117 name_map.entry(e.name.clone()).or_default().push(id);
119 path_map.insert(module_path, id);
120 (e.clone(), id)
121 })
122 .collect();
123
124 for (_, eid) in &entity_ids {
125 g.add_edge(
126 file_id,
127 *eid,
128 CodeEdge {
129 id: EdgeIndex::new(g.edge_count()),
130 kind: EdgeKind::Contains,
131 source: file_id,
132 target: *eid,
133 weight: 1.0,
134 location: None,
135 },
136 );
137 }
138
139 build_import_edges(g, &insight.imports, &entity_ids, &name_map, &path_map);
140 build_impl_edges(g, &entity_ids, &name_map);
141 call_candidates.extend(entity_ids.iter().filter_map(|(e, eid)| {
144 if e.kind != "fn" && e.kind != "function" {
145 return None;
146 }
147 Some((e.clone(), *eid, extract_body(&insight.source, e.line_start, e.line_end)))
148 }));
149 }
150
151 build_call_edges(g, &call_candidates, &name_map);
154
155 if let Some(node) = g.node_weight_mut(project_id) {
156 node.id = project_id;
157 }
158
159 kg.graph = g.clone();
160
161 let cycles = kg.detect_cycles();
162 if !cycles.is_empty() {
163 warn!("检测到 {} 个循环依赖: {:?}", cycles.len(), cycles);
164 }
165
166 kg.modules = crate::analysis::detect_modules(&kg)?;
172
173 Ok(kg)
174}
175
176fn ensure_module_chain(
177 g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
178 cache: &mut HashMap<Vec<String>, NodeId>,
179 project_id: NodeId,
180 segments: &[String],
181 name_map: &mut HashMap<String, Vec<NodeId>>,
182 path_map: &mut HashMap<Vec<String>, NodeId>,
183) -> NodeId {
184 let mut parent = project_id;
185 for i in 0..segments.len() {
186 let prefix: Vec<String> = segments[..=i].to_vec();
187 if let Some(&cached) = cache.get(&prefix) {
188 parent = cached;
189 continue;
190 }
191 let id = g.add_node(CodeNode {
192 id: NodeId::new(g.node_count()),
193 kind: NodeKind::Module,
194 name: segments[i].clone(),
195 file_path: None,
196 line_range: None,
197 doc_comment: None,
198 signature: None, visibility: None,
199 module_path: prefix.clone(),
200 });
201 g.add_edge(
202 parent,
203 id,
204 CodeEdge {
205 id: EdgeIndex::new(g.edge_count()),
206 kind: EdgeKind::Contains,
207 source: parent,
208 target: id,
209 weight: 1.0,
210 location: None,
211 },
212 );
213 cache.insert(prefix.clone(), id);
214 name_map.entry(segments[i].clone()).or_default().push(id);
216 path_map.insert(prefix, id);
217 parent = id;
218 }
219 parent
220}
221
222fn kind_from_str(s: &str) -> NodeKind {
223 match s {
224 "mod" => NodeKind::Module,
228 "struct" => NodeKind::Struct,
229 "enum" => NodeKind::Enum,
230 "fn" | "function" => NodeKind::Function,
231 "trait" => NodeKind::Trait,
232 "impl" => NodeKind::Impl,
233 "type" => NodeKind::Type,
234 "const" | "constant" | "static" => NodeKind::Constant,
235 "variable" | "let" | "property" => NodeKind::Variable,
236 "interface" => NodeKind::Interface,
237 "class" => NodeKind::Class,
238 "macro" => NodeKind::Macro,
239 _ => {
240 warn!("未知实体类型 '{}',使用 Function 作为默认", s);
241 NodeKind::Function
242 }
243 }
244}
245
246fn build_import_edges(
251 g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
252 imports: &[ImportStmt],
253 entities: &[(Entity, NodeId)],
254 name_map: &HashMap<String, Vec<NodeId>>,
255 path_map: &HashMap<Vec<String>, NodeId>,
256) {
257 for imp in imports {
258 let parts: Vec<&str> = imp.source.split("::").collect();
259 if parts.is_empty() {
260 continue;
261 }
262
263 let target_name = parts.last().unwrap_or(&"");
264 let mut targets: Vec<NodeId> = Vec::new();
265
266 if let Some(nids) = name_map.get(*target_name) {
268 targets = nids.clone();
269 }
270
271 if targets.is_empty() {
273 let path_segments: Vec<String> = parts.iter().map(|s| s.to_string()).collect();
274 for (mp, &nid) in path_map.iter() {
275 if mp.ends_with(&path_segments) {
276 targets.push(nid);
277 break;
278 }
279 }
280 }
281
282 for target_id in &targets {
283 for (_, eid) in entities {
284 if g.edges_connecting(*eid, *target_id).count() == 0 {
285 g.add_edge(
286 *eid,
287 *target_id,
288 CodeEdge {
289 id: EdgeIndex::new(g.edge_count()),
290 kind: EdgeKind::Imports,
291 source: *eid,
292 target: *target_id,
293 weight: 0.8,
294 location: Some((imp.line, imp.line)),
295 },
296 );
297 }
298 }
299 }
300 }
301}
302
303fn build_impl_edges(
307 g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
308 entities: &[(Entity, NodeId)],
309 name_map: &HashMap<String, Vec<NodeId>>,
310) {
311 for (entity, eid) in entities {
312 if let Some(trait_name) = parse_impl_target(&entity.kind, &entity.name)
313 && let Some(trait_ids) = name_map.get(&trait_name)
314 {
315 for &trait_id in trait_ids {
316 g.add_edge(
317 *eid,
318 trait_id,
319 CodeEdge {
320 id: EdgeIndex::new(g.edge_count()),
321 kind: EdgeKind::Implements,
322 source: *eid,
323 target: trait_id,
324 weight: 1.0,
325 location: None,
326 },
327 );
328 }
329 }
330 }
331}
332
333fn parse_impl_target(kind: &str, name: &str) -> Option<String> {
334 if kind != "impl" && !kind.starts_with("impl_for") {
335 return None;
336 }
337 if let Some(for_idx) = name.find(" for ") {
340 let after_impl = if let Some(idx) = name.find("impl ") {
342 &name[idx + 5..]
343 } else {
344 name
345 };
346 let trait_name = after_impl[..for_idx.saturating_sub(name.len() - after_impl.len())].trim().to_string();
347 if !trait_name.is_empty() {
350 return Some(trait_name);
351 }
352 }
353 None
356}
357
358fn extract_body(source: &str, line_start: usize, line_end: usize) -> String {
362 source
363 .lines()
364 .skip(line_start.saturating_sub(1))
365 .take(line_end.saturating_sub(line_start).saturating_add(1))
366 .collect::<Vec<_>>()
367 .join("\n")
368}
369
370fn build_call_edges(
398 g: &mut petgraph::stable_graph::StableDiGraph<CodeNode, CodeEdge>,
399 call_candidates: &[(Entity, NodeId, String)],
400 name_map: &HashMap<String, Vec<NodeId>>,
401) {
402 for (entity, eid, body) in call_candidates {
403 let mut seen: HashSet<&str> = HashSet::new();
405 let bytes = body.as_bytes();
406 let mut i = 0;
407 while i < bytes.len() {
408 let b = bytes[i];
409 if b.is_ascii_alphanumeric() || b == b'_' {
410 let start = i;
411 i += 1;
412 while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'_') {
413 i += 1;
414 }
415 if i < bytes.len() && bytes[i] == b'(' {
417 let name = &body[start..i];
418 if name != entity.name && !seen.contains(name) {
419 seen.insert(name);
420 if let Some(callee_ids) = name_map.get(name) {
421 for &callee_id in callee_ids {
422 if callee_id == *eid {
423 continue;
424 }
425 if g.edges_connecting(*eid, callee_id).count() == 0 {
426 g.add_edge(
427 *eid,
428 callee_id,
429 CodeEdge {
430 id: EdgeIndex::new(g.edge_count()),
431 kind: EdgeKind::Calls,
432 source: *eid,
433 target: callee_id,
434 weight: 0.7,
435 location: None,
436 },
437 );
438 }
439 }
440 }
441 }
442 }
443 } else {
444 i += 1;
445 }
446 }
447 }
448}
449
450impl KnowledgeGraph {
451 pub fn detect_cycles(&self) -> Vec<Vec<String>> {
452 petgraph::algo::tarjan_scc(&self.graph)
453 .into_iter()
454 .filter(|scc| scc.len() > 1)
455 .map(|scc| scc.iter().map(|&n| self.graph[n].name.clone()).collect())
456 .collect()
457 }
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463 use crate::ingest::parser::{Entity, FileInsight, ImportStmt};
464 use crate::model::KnowledgeGraph;
465 use std::path::PathBuf;
466
467 #[test]
468 fn test_empty_insights() {
469 let kg = build(&[]).unwrap();
470 assert_eq!(kg.graph.node_count(), 1);
471 let root = kg.graph.node_weight(NodeId::new(0)).unwrap();
472 assert_eq!(root.kind, NodeKind::Project);
473 }
474
475 #[test]
478 fn test_kind_from_str_supports_mod() {
479 assert_eq!(kind_from_str("mod"), NodeKind::Module);
480 assert_eq!(kind_from_str("struct"), NodeKind::Struct);
481 assert_eq!(kind_from_str("fn"), NodeKind::Function);
482 }
483
484 #[test]
489 fn test_kind_from_str_supports_static_and_property() {
490 assert_eq!(kind_from_str("static"), NodeKind::Constant);
491 assert_eq!(kind_from_str("property"), NodeKind::Variable);
492 assert_eq!(kind_from_str("function"), NodeKind::Function);
493 }
494
495 #[test]
496 fn test_single_file_two_entities() {
497 let insights = vec![FileInsight {
498 path: PathBuf::from("src/lib.rs"),
499 language: "rust".into(),
500 entities: vec![
501 Entity {
502 name: "add".into(),
503 kind: "fn".into(),
504 line_start: 1,
505 line_end: 5,
506 doc_comment: None,
507 signature: Some("fn add(a: i32, b: i32) -> i32".into()),
508 visibility: None,
509 },
510 Entity {
511 name: "Sub".into(),
512 kind: "struct".into(),
513 line_start: 7,
514 line_end: 10,
515 doc_comment: None,
516 signature: Some("struct Sub".into()),
517 visibility: None,
518 },
519 ],
520 imports: vec![],
521 doc_comments: vec![],
522 source: String::new(),
523 }];
524 let kg = build(&insights).unwrap();
525 assert_eq!(kg.graph.node_count(), 5);
526 assert_eq!(kg.graph.edge_count(), 4);
527 }
528
529 #[test]
530 fn test_import_edge() {
531 let insights = vec![
532 FileInsight {
533 path: PathBuf::from("src/utils.rs"),
534 language: "rust".into(),
535 entities: vec![Entity {
536 name: "helper".into(),
537 kind: "fn".into(),
538 line_start: 1,
539 line_end: 3,
540 doc_comment: None,
541 signature: Some("fn helper()".into()),
542 visibility: None,
543 }],
544 imports: vec![],
545 doc_comments: vec![],
546 source: String::new(),
547 },
548 FileInsight {
549 path: PathBuf::from("src/main.rs"),
550 language: "rust".into(),
551 entities: vec![Entity {
552 name: "run".into(),
553 kind: "fn".into(),
554 line_start: 1,
555 line_end: 10,
556 doc_comment: None,
557 signature: Some("fn run()".into()),
558 visibility: None,
559 }],
560 imports: vec![ImportStmt {
561 source: "crate::utils::helper".into(),
562 alias: None,
563 line: 1,
564 }],
565 doc_comments: vec![],
566 source: String::new(),
567 },
568 ];
569 let kg = build(&insights).unwrap();
570 let has_import = kg
571 .graph
572 .edge_indices()
573 .any(|e| kg.graph.edge_weight(e).map(|w| w.kind == EdgeKind::Imports).unwrap_or(false));
574 assert!(has_import);
575 }
576
577 #[test]
578 fn test_detect_cycles_empty() {
579 let kg = KnowledgeGraph::default();
580 assert!(kg.detect_cycles().is_empty());
581 }
582
583 #[test]
584 fn test_detect_cycles_with_cycle() {
585 let mut kg = KnowledgeGraph::default();
586 let a = kg.graph.add_node(CodeNode {
587 id: NodeId::new(0),
588 kind: NodeKind::Function,
589 name: "func_a".into(),
590 file_path: None,
591 line_range: None,
592 doc_comment: None,
593 signature: None, visibility: None,
594 module_path: vec![],
595 });
596 let b = kg.graph.add_node(CodeNode {
597 id: NodeId::new(1),
598 kind: NodeKind::Function,
599 name: "func_b".into(),
600 file_path: None,
601 line_range: None,
602 doc_comment: None,
603 signature: None, visibility: None,
604 module_path: vec![],
605 });
606 kg.graph.add_edge(a, b, CodeEdge {
607 id: EdgeIndex::new(0),
608 kind: EdgeKind::Calls,
609 source: a,
610 target: b,
611 weight: 1.0,
612 location: None,
613 });
614 kg.graph.add_edge(b, a, CodeEdge {
615 id: EdgeIndex::new(1),
616 kind: EdgeKind::Calls,
617 source: b,
618 target: a,
619 weight: 1.0,
620 location: None,
621 });
622 let cycles = kg.detect_cycles();
623 assert_eq!(cycles.len(), 1);
624 assert!(cycles[0].contains(&"func_a".to_string()));
625 assert!(cycles[0].contains(&"func_b".to_string()));
626 }
627}
628
629 #[test]
632 fn test_build_call_edges_cross_file() {
633 let mut g = petgraph::stable_graph::StableDiGraph::<CodeNode, CodeEdge>::new();
634 let callee = g.add_node(CodeNode {
635 id: NodeId::new(0),
636 kind: NodeKind::Function,
637 name: "callee".into(),
638 file_path: Some("src/a.rs".into()),
639 line_range: Some((1, 3)),
640 doc_comment: None,
641 signature: None, visibility: None,
642 module_path: vec![],
643 });
644 let caller = g.add_node(CodeNode {
645 id: NodeId::new(1),
646 kind: NodeKind::Function,
647 name: "caller".into(),
648 file_path: Some("src/b.rs".into()),
649 line_range: Some((1, 3)),
650 doc_comment: None,
651 signature: None, visibility: None,
652 module_path: vec![],
653 });
654 let candidates = vec![(
656 Entity {
657 name: "caller".into(),
658 kind: "fn".into(),
659 line_start: 1,
660 line_end: 3,
661 doc_comment: None,
662 signature: None,
663 visibility: None,
664 },
665 caller,
666 "pub fn caller() { callee(42) }".to_string(),
667 )];
668 let mut tname_map: HashMap<String, Vec<NodeId>> = HashMap::new();
669 tname_map.entry("callee".to_string()).or_default().push(callee);
670 build_call_edges(&mut g, &candidates, &tname_map);
671 assert_eq!(
672 g.edges_connecting(caller, callee).count(),
673 1,
674 "跨文件调用应产生一条 Calls 边"
675 );
676 let edge = g.edges_connecting(caller, callee).next().unwrap();
677 assert_eq!(edge.weight().kind, EdgeKind::Calls);
678 }
679
680 #[test]
682 fn test_build_call_edges_word_boundary() {
683 let mut g = petgraph::stable_graph::StableDiGraph::<CodeNode, CodeEdge>::new();
684 let callee = g.add_node(CodeNode {
685 id: NodeId::new(0),
686 kind: NodeKind::Function,
687 name: "callee".into(),
688 file_path: Some("src/a.rs".into()),
689 line_range: Some((1, 3)),
690 doc_comment: None,
691 signature: None, visibility: None,
692 module_path: vec![],
693 });
694 let caller = g.add_node(CodeNode {
695 id: NodeId::new(1),
696 kind: NodeKind::Function,
697 name: "caller".into(),
698 file_path: Some("src/b.rs".into()),
699 line_range: Some((1, 3)),
700 doc_comment: None,
701 signature: None, visibility: None,
702 module_path: vec![],
703 });
704 let candidates = vec![(
705 Entity {
706 name: "caller".into(),
707 kind: "fn".into(),
708 line_start: 1,
709 line_end: 3,
710 doc_comment: None,
711 signature: None,
712 visibility: None,
713 },
714 caller,
715 "pub fn caller() { mycallee(1) }".to_string(),
716 )];
717 let mut tname_map: HashMap<String, Vec<NodeId>> = HashMap::new();
718 tname_map.entry("callee".to_string()).or_default().push(callee);
719 build_call_edges(&mut g, &candidates, &tname_map);
720 assert_eq!(
721 g.edges_connecting(caller, callee).count(),
722 0,
723 "mycallee( 不是对 callee 的调用(前缀字母不构成调用)"
724 );
725 }
726
727 #[test]
729 fn test_build_call_edges_self_name_skipped_and_dedup() {
730 let mut g = petgraph::stable_graph::StableDiGraph::<CodeNode, CodeEdge>::new();
731 let callee = g.add_node(CodeNode {
732 id: NodeId::new(0),
733 kind: NodeKind::Function,
734 name: "callee".into(),
735 file_path: Some("src/a.rs".into()),
736 line_range: Some((1, 3)),
737 doc_comment: None,
738 signature: None, visibility: None,
739 module_path: vec![],
740 });
741 let candidates = vec![
743 (
744 Entity {
745 name: "callee".into(),
746 kind: "fn".into(),
747 line_start: 1,
748 line_end: 3,
749 doc_comment: None,
750 signature: None,
751 visibility: None,
752 },
753 callee,
754 "pub fn callee() { callee(1); callee(2) }".to_string(),
755 ),
756 (
757 Entity {
758 name: "other".into(),
759 kind: "fn".into(),
760 line_start: 1,
761 line_end: 3,
762 doc_comment: None,
763 signature: None,
764 visibility: None,
765 },
766 g.add_node(CodeNode {
767 id: NodeId::new(1),
768 kind: NodeKind::Function,
769 name: "other".into(),
770 file_path: Some("src/c.rs".into()),
771 line_range: Some((1, 3)),
772 doc_comment: None,
773 signature: None, visibility: None,
774 module_path: vec![],
775 }),
776 "pub fn other() { callee(3) }".to_string(),
777 ),
778 ];
779 let mut tname_map: HashMap<String, Vec<NodeId>> = HashMap::new();
780 tname_map.entry("callee".to_string()).or_default().push(callee);
781 build_call_edges(&mut g, &candidates, &tname_map);
782 assert_eq!(
784 g.edges_connecting(callee, callee).count(),
785 0,
786 "同名实体(自调用)应跳过"
787 );
788 let other = g.node_indices().find(|&n| g[n].name == "other").unwrap();
790 assert_eq!(
791 g.edges_connecting(other, callee).count(),
792 1,
793 "同一调用模式多次出现只建一条边"
794 );
795 }
796
797 #[test]
799 fn test_build_call_edges_no_call() {
800 let mut g = petgraph::stable_graph::StableDiGraph::<CodeNode, CodeEdge>::new();
801 let _callee = g.add_node(CodeNode {
802 id: NodeId::new(0),
803 kind: NodeKind::Function,
804 name: "callee".into(),
805 file_path: Some("src/a.rs".into()),
806 line_range: Some((1, 3)),
807 doc_comment: None,
808 signature: None, visibility: None,
809 module_path: vec![],
810 });
811 let caller = g.add_node(CodeNode {
812 id: NodeId::new(1),
813 kind: NodeKind::Function,
814 name: "caller".into(),
815 file_path: Some("src/b.rs".into()),
816 line_range: Some((1, 3)),
817 doc_comment: None,
818 signature: None, visibility: None,
819 module_path: vec![],
820 });
821 let candidates = vec![(
822 Entity {
823 name: "caller".into(),
824 kind: "fn".into(),
825 line_start: 1,
826 line_end: 3,
827 doc_comment: None,
828 signature: None,
829 visibility: None,
830 },
831 caller,
832 "pub fn caller() { let x = 1; }".to_string(),
833 )];
834 let mut tname_map: HashMap<String, Vec<NodeId>> = HashMap::new();
835 tname_map.entry("caller".to_string()).or_default().push(caller);
836 build_call_edges(&mut g, &candidates, &tname_map);
837 assert_eq!(g.edge_count(), 0, "无调用应零边");
838 }