Skip to main content

code_repo_wiki/analysis/
graph.rs

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
11/// 从 FileInsight 列表构建完整知识图谱
12pub fn build(insights: &[FileInsight]) -> Result<KnowledgeGraph> {
13    let mut kg = KnowledgeGraph::default();
14    let g = &mut kg.graph;
15
16    // v32 8.2 大仓优化:name_map/path_map 改为**全局增量构建**。
17    // 原实现每文件调用 build_import_edges/build_impl_edges 时都会
18    // collect_node_names 全图遍历重建索引,复杂度 O(文件数² × 图大小),
19    // cal.com 5054 文件实测 200s;现改为在 add_node 处增量插入,
20    // 每文件 O(新增节点数),总复杂度 O(图大小)。语义等价:增量索引
21    // 在每个文件处理时刻恰好包含「已处理文件 + 当前文件」的全部节点,
22    // 与原 collect_node_names 在该时刻的遍历结果一致(path_map 后插
23    // 覆盖、name_map 按添加序 push 均与原语义相同)。
24    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    // 跨文件调用边候选:(实体, 节点, 函数体文本),全图实体构建完成后统一匹配
42    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        // 增量索引(v32 8.2):File 节点入 path_map(module_path=目录段)
91        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                // 增量索引(v32 8.2):实体节点入 name_map(同名 push)+ path_map
118                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        // 收集 (实体, 节点, 函数体文本) —— 跨文件调用边需在全图实体
142        // 构建完成后统一匹配(每个函数用其函数体文本找被调用函数名)
143        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    // 全部实体构建完成后统一构建调用边:此时 name_map 覆盖全图符号,
152    // 跨文件调用(本文件函数调用其他文件函数)才能被解析
153    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。
167    // 生成层(generate/mod.rs)、渲染层(markdown/html/mermaid)均以
168    // graph.modules 为模块分组的唯一来源;此前仅 lib.rs 显式调用
169    // detect_modules 且结果只进 stats,modules 恒空导致按模块生成
170    // 从未生效。检测失败向上传播(无兜底)。
171    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        // 增量索引(v32 8.2):与原 collect_node_names 遍历语义一致
215        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        // v19 t03:parser 合法产出 kind="mod"(Rust mod 声明 rust.rs、
225        // C# namespace csharp.rs),此前落入默认分支产生「未知实体类型」
226        // warn 并误标 Function。Module 为容器节点,api.md 渲染已跳过。
227        "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
246/// 构建 import 边:把实体的 import 语句连接到目标实体。
247///
248/// v32 8.2:name_map/path_map 由 build() 全局增量构建后传入,
249/// 不再每文件全图遍历重建(原实现 O(文件数²),cal.com 实测 200s)。
250fn 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        // name_map 返回 Vec<NodeId>,遍历所有同名实体(函数重载、同名结构体等)
267        if let Some(nids) = name_map.get(*target_name) {
268            targets = nids.clone();
269        }
270
271        // name 匹配失败时尝试路径后缀匹配
272        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
303/// 构建 impl 边:把 impl 实体连接到其 trait 目标。
304///
305/// v32 8.2:name_map 由 build() 全局增量构建后传入(同 import 边)。
306fn 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    // entity.name 的格式可能是 "impl MyTrait for MyStruct" 或 "MyTrait for MyStruct"
338    // 提取 " for " 之前的部分作为 trait 名
339    if let Some(for_idx) = name.find(" for ") {
340        // 跳过 "impl " 前缀(如果存在)
341        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        // NOTE: for_idx is relative to the original name; subtract the "impl " prefix length
348        // to index into after_impl. Fixes a pre-existing off-by-prefix bug (v32 8.2).
349        if !trait_name.is_empty() {
350            return Some(trait_name);
351        }
352    }
353    // 如果名字中不含 " for ",无法确定 trait 名,返回 None
354    // 不猜测(例如把 struct 名当作 trait 名)
355    None
356}
357
358/// 按行号区间从文件源码中提取函数体文本(供调用边匹配使用)
359///
360/// line_start/line_end 为 1-based 行号;越界时安全截断(不 panic)。
361fn 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
370/// 构建调用边(函数 → 被调用函数)
371///
372/// 在**全图实体构建完成后**调用一次:name_map 此时包含所有文件的函数符号,
373/// 才能解析跨文件调用(此前逐文件构建时 name_map 只含已处理文件,跨文件
374/// 调用全部丢失,真实图上 Calls 边几乎为零)。
375/// 匹配载体 = 函数体文本(按行号从文件源码切片),而非仅签名+文档注释
376/// (签名几乎不含调用信息,旧实现导致 Calls 边数量失真)。
377/// 跨文件调用边构建(v32 8.2 大仓优化)。
378///
379/// 原实现为 O(候选数 × 全图名字数) 双重循环(对每个函数体逐一尝试所有
380/// 实体名的 `name(` 子串匹配),cal.com 5.9 万实体时约 9 亿次迭代,
381/// 实测 287s。现改为按函数体「标识符 token 化」检索:只对函数体中
382/// 实际出现的标识符查询名字表,复杂度降为 O(候选 × 函数体长度),
383/// 同一仓库实测 ~1s。
384///
385/// 语义保持与原实现逐点等价:
386///   - 原「find("name(") 且 name 前一字符非标识符」⇔ token 化后 name
387///     为完整标识符 token 且其后紧跟 '('(token 化天然保证前边界);
388///   - 原「每个名字首次边界通过后 break」⇔ 每个名字每个函数体只处理
389///     一次(seen 集合)。
390///
391/// 唯一行为差异:原实现会在 `xfoo(` 中误把子串 `foo(` 当作候选(因
392/// 前字符 `x` 非边界而放弃)——若 `foo` 恰为实体名则漏连 xfoo 的调用
393/// 边;新实现按完整标识符匹配,此场景正确建立调用边(修复而非回归)。
394/// 另一差异(已知限制):token 化只认 ASCII 标识符起始字节,含非 ASCII
395/// 字符的实体名(如 `fn 测试()`)不会建立 Calls 边——此类标识符在实际
396/// 代码库中极为罕见,且原实现对其边界判定本就不一致,故不为此扩展。
397fn 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        // 每个函数体对每个名字只处理一次(等价原实现的 break 语义)
404        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                // 与 find("name(") 等价:标识符后必须紧跟 '(' 才视为调用点
416                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    /// v19 t03:parser 合法产出 kind="mod"(Rust mod / C# namespace),
476    /// 此前落入默认分支产生「未知实体类型」warn 并误标 Function。
477    #[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    /// v21 t06:parser 合法产出 kind="static"(Rust static_item 静态变量)
485    /// 与 kind="property"(C# 属性)——此前落入默认分支产生「未知实体
486    /// 类型 'static'/'property'」warn 并误标 Function。static 语义上是
487    /// 常量,property 语义上是字段/变量,归入对应 NodeKind。
488    #[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    /// t04:build_call_edges 跨文件调用边——a.rs 定义 callee,b.rs 的 caller
630    /// 正文含 "callee(" 应产生 Calls 边(此前该核心功能零单测)
631    #[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        // call_candidates:(实体, 节点, 函数体源码)
655        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    /// t04:单词边界——mycallee( 不应误匹配 callee(
681    #[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    /// t04:同名自调用排除——callee 调用同名函数不建边;多次出现只建一条边
728    #[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        // 两个候选都调用 callee(同名实体跳过自身;同一模式重复出现去重)
742        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        // 自调用(callee→callee)不建边
783        assert_eq!(
784            g.edges_connecting(callee, callee).count(),
785            0,
786            "同名实体(自调用)应跳过"
787        );
788        // other→callee 只建一条(去重)
789        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    /// t04:无调用时零边
798    #[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    }