Skip to main content

lean_ctx/core/
call_graph.rs

1use std::collections::HashMap;
2use std::path::Path;
3use std::sync::atomic::{AtomicUsize, Ordering};
4use std::sync::{Arc, Mutex, OnceLock};
5
6use rayon::prelude::*;
7use serde::{Deserialize, Serialize};
8
9use super::deep_queries;
10use super::graph_provider::GraphProvider;
11use super::index_paths::normalize_project_root;
12
13// ---------------------------------------------------------------------------
14// Data types
15// ---------------------------------------------------------------------------
16
17/// On-disk format of the persisted call graph. Bump whenever the meaning of a
18/// persisted field changes; [`CallGraph::load`] discards any other version, so
19/// the next build starts from scratch instead of reusing stale edges.
20/// v2: 1-based `caller_line` (v1 was off by one), callee position/receiver/method.
21const CALL_GRAPH_FORMAT: u32 = 2;
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24pub struct CallGraph {
25    /// Missing in pre-v2 caches → 0 → rejected by `load`.
26    #[serde(default)]
27    pub format_version: u32,
28    pub project_root: String,
29    pub edges: Vec<CallEdge>,
30    pub file_hashes: HashMap<String, String>,
31    /// Size and mtime of each analyzed or skipped file when it was read, so a
32    /// persisted graph is rebuilt when a file changed in place. Missing in
33    /// older caches → empty → such a cache counts as stale once.
34    #[serde(default)]
35    pub file_stamps: HashMap<String, FileStamp>,
36    /// Indexed files the build skipped because they are not UTF-8 text. They
37    /// count as covered, so the cache is not rebuilt for them on every load.
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub skipped_files: Vec<String>,
40}
41
42/// `(size in bytes, mtime in ns since the Unix epoch)` of a file.
43pub type FileStamp = (u64, u64);
44
45/// One read file: relative path, stamp, and its content hash with call edges,
46/// or `None` when it is not UTF-8 text and was skipped.
47type FileResult = (String, Option<FileStamp>, Option<(String, Vec<CallEdge>)>);
48
49#[derive(Debug, Clone, Default, Serialize, Deserialize)]
50pub struct CallEdge {
51    pub caller_file: String,
52    pub caller_symbol: String,
53    /// 1-based line of the call expression.
54    pub caller_line: usize,
55    pub callee_name: String,
56    /// Callee identifier position `(1-based line, 0-based byte column)` — where
57    /// a semantic backend resolves the call (see `CallSite::callee_pos`).
58    #[serde(default)]
59    pub callee_pos: Option<(usize, usize)>,
60    #[serde(default)]
61    pub receiver: Option<String>,
62    #[serde(default)]
63    pub is_method: bool,
64}
65
66/// Call edges of one analyzed file, each attributed to its enclosing symbol.
67/// Call-site and symbol lines are both 1-based.
68fn edges_for_file(
69    rel_path: &str,
70    calls: &[deep_queries::CallSite],
71    file_symbols: Option<&Vec<SymbolSpan>>,
72) -> Vec<CallEdge> {
73    calls
74        .iter()
75        .map(|call| CallEdge {
76            caller_file: rel_path.to_string(),
77            caller_symbol: find_enclosing_symbol_owned(file_symbols, call.line),
78            caller_line: call.line,
79            callee_name: call.callee.clone(),
80            callee_pos: call.callee_pos,
81            receiver: call.receiver.clone(),
82            is_method: call.is_method,
83        })
84        .collect()
85}
86
87/// Minimal symbol span the call-graph builder needs to attribute a call site to
88/// its enclosing symbol — backend-agnostic, decoupled from any graph store.
89#[derive(Debug, Clone, Default)]
90pub struct SymbolSpan {
91    pub file: String,
92    pub name: String,
93    pub start_line: usize,
94    pub end_line: usize,
95    /// Signature kind (`fn`, `method`, `trait`, `interface`, …).
96    pub kind: String,
97}
98
99/// Everything the call-graph builder reads, sourced from the [`GraphProvider`]
100/// facade (PropertyGraph). Replaces the former direct `ProjectIndex`
101/// dependency (#696): the file inventory, the symbol table (for enclosing-symbol
102/// attribution) and the import/reexport adjacency (for scope-aware callee
103/// resolution). Source content itself is still read fresh from disk per file.
104#[derive(Debug, Clone, Default)]
105pub struct CallGraphInputs {
106    pub project_root: String,
107    pub file_paths: Vec<String>,
108    pub symbols: Vec<SymbolSpan>,
109    /// `(from, to)` pairs for `import`/`reexport` edges only.
110    pub import_edges: Vec<(String, String)>,
111}
112
113impl CallGraphInputs {
114    /// Open the project graph (PropertyGraph, falling back to legacy) and
115    /// materialize call-graph inputs. Returns empty inputs (rooted at
116    /// `project_root`) when no graph is available yet — matching the old
117    /// behaviour of building from an empty index.
118    pub fn open(project_root: &str) -> Self {
119        match crate::core::graph_provider::open_or_build(project_root) {
120            Some(open) => Self::from_provider(project_root, &open.provider),
121            None => Self {
122                project_root: normalize_project_root(project_root),
123                ..Default::default()
124            },
125        }
126    }
127
128    /// Bridge for callers that already hold a freshly-scanned
129    /// [`ProjectIndex`](super::graph_index::ProjectIndex)
130    /// (repomap, dashboard coordinator) and want call-graph inputs consistent
131    /// with *that* scan rather than a possibly-lagging PropertyGraph. Removed in
132    /// #696 Phase D once those callers move to the facade/extractor wholesale.
133    pub fn from_project_index(index: &super::graph_index::ProjectIndex) -> Self {
134        let symbols = index
135            .symbols
136            .values()
137            .map(|s| SymbolSpan {
138                file: s.file.clone(),
139                name: s.name.clone(),
140                start_line: s.start_line,
141                end_line: s.end_line,
142                kind: s.kind.clone(),
143            })
144            .collect();
145        let import_edges = index
146            .edges
147            .iter()
148            .filter(|e| e.kind == "import" || e.kind == "reexport")
149            .map(|e| (e.from.clone(), e.to.clone()))
150            .collect();
151        Self {
152            project_root: index.project_root.clone(),
153            file_paths: index.files.keys().cloned().collect(),
154            symbols,
155            import_edges,
156        }
157    }
158
159    /// Materialize the builder inputs from a [`GraphProvider`] facade.
160    pub fn from_provider(project_root: &str, provider: &GraphProvider) -> Self {
161        let symbols = provider
162            .all_symbols()
163            .into_iter()
164            .map(|s| SymbolSpan {
165                file: s.file,
166                name: s.name,
167                start_line: s.start_line,
168                end_line: s.end_line,
169                kind: s.kind,
170            })
171            .collect();
172        let import_edges = provider
173            .edges()
174            .into_iter()
175            .filter(|e| e.kind == "import" || e.kind == "reexport")
176            .map(|e| (e.from, e.to))
177            .collect();
178        Self {
179            project_root: normalize_project_root(project_root),
180            file_paths: provider.file_paths(),
181            symbols,
182            import_edges,
183        }
184    }
185}
186
187#[derive(Debug, Clone)]
188pub struct BfsNode {
189    pub symbol: String,
190    pub file: String,
191    pub line: usize,
192    pub depth: usize,
193    pub from_symbol: String,
194}
195
196#[derive(Debug, Clone)]
197pub struct PathHop {
198    pub symbol: String,
199    pub file: String,
200    pub line: usize,
201}
202
203#[derive(Clone, Copy)]
204enum BfsDirection {
205    Callers,
206    Callees,
207}
208
209#[derive(Debug, Clone, Copy, PartialEq, Eq)]
210pub enum RiskLevel {
211    Low,
212    Medium,
213    High,
214    Critical,
215}
216
217impl RiskLevel {
218    pub fn from_caller_count(count: usize) -> Self {
219        match count {
220            0..=1 => Self::Low,
221            2..=4 => Self::Medium,
222            5..=10 => Self::High,
223            _ => Self::Critical,
224        }
225    }
226
227    pub fn label(self) -> &'static str {
228        match self {
229            Self::Low => "LOW",
230            Self::Medium => "MEDIUM",
231            Self::High => "HIGH",
232            Self::Critical => "CRITICAL",
233        }
234    }
235}
236
237// ---------------------------------------------------------------------------
238// Background build state (singleton per process)
239// ---------------------------------------------------------------------------
240
241#[derive(Debug, Clone, Serialize)]
242pub struct BuildProgress {
243    pub status: &'static str,
244    pub files_total: usize,
245    pub files_done: usize,
246    pub edges_found: usize,
247}
248
249enum BuildState {
250    Idle,
251    Building {
252        files_total: usize,
253        files_done: Arc<AtomicUsize>,
254        edges_found: Arc<AtomicUsize>,
255    },
256    Ready(Arc<CallGraph>),
257    Failed(String),
258}
259
260/// What a resident call graph answers for: the project it was built from
261/// and the protection state its inputs were decided under. A graph is never
262/// handed to a request of another project, and one filled in Community
263/// developer mode is not handed to a protected request (or the reverse).
264/// The serve-time gate is not part of this: it stays with the request, which
265/// decides the inputs the resident graph is compared with.
266#[derive(Debug, Clone, PartialEq, Eq)]
267struct BuildKey {
268    root: String,
269    protected: bool,
270}
271
272impl BuildKey {
273    fn for_request(project_root: &str) -> Self {
274        Self {
275            root: normalize_project_root(project_root),
276            protected: crate::core::context_admission::stored::source_authority_required(),
277        }
278    }
279}
280
281/// Resident builds kept at once; the least recently requested one that is
282/// not building goes first.
283const MAX_RESIDENT_BUILDS: usize = 4;
284
285/// The state of a project nobody requested yet.
286static IDLE: BuildState = BuildState::Idle;
287
288/// The resident builds, least recently requested first.
289#[derive(Default)]
290struct Builds {
291    entries: Vec<(BuildKey, BuildState)>,
292}
293
294impl Builds {
295    fn state(&self, key: &BuildKey) -> &BuildState {
296        self.entries
297            .iter()
298            .find(|(k, _)| k == key)
299            .map_or(&IDLE, |(_, state)| state)
300    }
301
302    /// The most recently requested build.
303    fn latest(&self) -> &BuildState {
304        self.entries.last().map_or(&IDLE, |(_, state)| state)
305    }
306
307    fn set(&mut self, key: BuildKey, state: BuildState) {
308        self.entries.retain(|(k, _)| *k != key);
309        if self.entries.len() >= MAX_RESIDENT_BUILDS {
310            let evict = self
311                .entries
312                .iter()
313                .position(|(_, state)| !matches!(state, BuildState::Building { .. }))
314                .unwrap_or(0);
315            self.entries.remove(evict);
316        }
317        self.entries.push((key, state));
318    }
319}
320
321static BUILD: OnceLock<Mutex<Builds>> = OnceLock::new();
322
323fn global_state() -> &'static Mutex<Builds> {
324    BUILD.get_or_init(|| Mutex::new(Builds::default()))
325}
326
327impl CallGraph {
328    pub fn new(project_root: &str) -> Self {
329        Self {
330            format_version: CALL_GRAPH_FORMAT,
331            project_root: normalize_project_root(project_root),
332            edges: Vec::new(),
333            file_hashes: HashMap::new(),
334            file_stamps: HashMap::new(),
335            skipped_files: Vec::new(),
336        }
337    }
338
339    // -----------------------------------------------------------------------
340    // Parallel build — processes files via rayon thread pool
341    // -----------------------------------------------------------------------
342
343    pub fn build_parallel(
344        inputs: &CallGraphInputs,
345        progress: Option<(&AtomicUsize, &AtomicUsize)>,
346    ) -> Self {
347        let project_root = &inputs.project_root;
348        let symbols_by_file = group_symbols_by_file_owned(inputs);
349        let file_keys: Vec<String> = inputs.file_paths.clone();
350
351        let results: Vec<FileResult> = file_keys
352            .par_iter()
353            .filter_map(|rel_path| {
354                let abs_path = resolve_path(rel_path, project_root);
355                // Stamped before the read: an edit during the build leaves a
356                // stamp that no longer matches, so the next request rebuilds.
357                let stamp = file_stamp(&abs_path);
358                let Ok(content) = read_source(&abs_path)? else {
359                    return Some((rel_path.clone(), stamp, None));
360                };
361                let hash = simple_hash(&content);
362
363                let ext = Path::new(rel_path)
364                    .extension()
365                    .and_then(|e| e.to_str())
366                    .unwrap_or("");
367
368                let analysis = deep_queries::analyze(&content, ext);
369                let file_symbols = symbols_by_file.get(rel_path.as_str());
370
371                let edges = edges_for_file(rel_path, &analysis.calls, file_symbols);
372
373                if let Some((done, edge_count)) = progress {
374                    done.fetch_add(1, Ordering::Relaxed);
375                    edge_count.fetch_add(edges.len(), Ordering::Relaxed);
376                }
377
378                Some((rel_path.clone(), stamp, Some((hash, edges))))
379            })
380            .collect();
381
382        Self::from_results(project_root, results)
383    }
384
385    /// Assemble a graph from per-file build results.
386    fn from_results(project_root: &str, results: Vec<FileResult>) -> Self {
387        let mut graph = Self::new(project_root);
388        let edge_capacity: usize = results
389            .iter()
390            .filter_map(|(_, _, r)| r.as_ref().map(|(_, e)| e.len()))
391            .sum();
392        graph.edges.reserve(edge_capacity);
393        graph.file_hashes.reserve(results.len());
394        graph.file_stamps.reserve(results.len());
395
396        for (path, stamp, result) in results {
397            if let Some(stamp) = stamp {
398                graph.file_stamps.insert(path.clone(), stamp);
399            }
400            match result {
401                Some((hash, edges)) => {
402                    graph.file_hashes.insert(path, hash);
403                    graph.edges.extend(edges);
404                }
405                None => graph.skipped_files.push(path),
406            }
407        }
408        graph.skipped_files.sort();
409
410        graph
411    }
412
413    // -----------------------------------------------------------------------
414    // Incremental parallel build — only re-analyzes changed files
415    // -----------------------------------------------------------------------
416
417    pub fn build_incremental_parallel(
418        inputs: &CallGraphInputs,
419        previous: &CallGraph,
420        progress: Option<(&AtomicUsize, &AtomicUsize)>,
421    ) -> Self {
422        let project_root = &inputs.project_root;
423        let symbols_by_file = group_symbols_by_file_owned(inputs);
424        let file_keys: Vec<String> = inputs.file_paths.clone();
425
426        let prev_edges_by_file = group_edges_by_file(&previous.edges);
427
428        let results: Vec<FileResult> = file_keys
429            .par_iter()
430            .filter_map(|rel_path| {
431                let abs_path = resolve_path(rel_path, project_root);
432                let stamp = file_stamp(&abs_path);
433                let Ok(content) = read_source(&abs_path)? else {
434                    return Some((rel_path.clone(), stamp, None));
435                };
436                let hash = simple_hash(&content);
437                let changed = previous.file_hashes.get(rel_path.as_str()) != Some(&hash);
438
439                let edges = if changed {
440                    let ext = Path::new(rel_path)
441                        .extension()
442                        .and_then(|e| e.to_str())
443                        .unwrap_or("");
444
445                    let analysis = deep_queries::analyze(&content, ext);
446                    let file_symbols = symbols_by_file.get(rel_path.as_str());
447
448                    edges_for_file(rel_path, &analysis.calls, file_symbols)
449                } else {
450                    prev_edges_by_file
451                        .get(rel_path.as_str())
452                        .cloned()
453                        .unwrap_or_default()
454                };
455
456                if let Some((done, edge_count)) = progress {
457                    done.fetch_add(1, Ordering::Relaxed);
458                    edge_count.fetch_add(edges.len(), Ordering::Relaxed);
459                }
460
461                Some((rel_path.clone(), stamp, Some((hash, edges))))
462            })
463            .collect();
464
465        Self::from_results(project_root, results)
466    }
467
468    // -----------------------------------------------------------------------
469    // Public API: non-blocking access for the dashboard
470    // -----------------------------------------------------------------------
471
472    /// Returns the cached graph immediately, or `None` + starts a background build.
473    pub fn get_or_start_build(
474        project_root: &str,
475        inputs: Arc<CallGraphInputs>,
476    ) -> Result<Arc<CallGraph>, BuildProgress> {
477        let key = BuildKey::for_request(project_root);
478        let state = global_state();
479        let mut guard = state
480            .lock()
481            .unwrap_or_else(std::sync::PoisonError::into_inner);
482
483        match guard.state(&key) {
484            // The resident graph outlives the request that built it. It is
485            // found only under this request's project and protection state
486            // (`BuildKey`), and handed out only while every file it was built
487            // from is still in this request's inputs — those come from the
488            // gated graph facade (G04), so a file withdrawn since is missing
489            // there. Otherwise the graph is rebuilt over the current inputs.
490            BuildState::Ready(graph) if !holds_withdrawn_files(graph, &inputs) => {
491                return Ok(Arc::clone(graph));
492            }
493            BuildState::Ready(_) | BuildState::Idle => {}
494            BuildState::Building {
495                files_total,
496                files_done,
497                edges_found,
498            } => {
499                return Err(BuildProgress {
500                    status: "building",
501                    files_total: *files_total,
502                    files_done: files_done.load(Ordering::Relaxed),
503                    edges_found: edges_found.load(Ordering::Relaxed),
504                });
505            }
506            BuildState::Failed(msg) => {
507                tracing::warn!("[call_graph: previous build failed: {msg} — retrying]");
508            }
509        }
510
511        // Try serving from disk cache first (an exact file-set match, so a
512        // persisted graph with a withdrawn file is rebuilt, not served)
513        if let Some(cached) = Self::load(project_root)
514            && !cache_looks_stale(&cached, &inputs)
515        {
516            let arc = Arc::new(cached);
517            guard.set(key, BuildState::Ready(Arc::clone(&arc)));
518            return Ok(arc);
519        }
520
521        let files_total = inputs.file_paths.len();
522        let files_done = Arc::new(AtomicUsize::new(0));
523        let edges_found = Arc::new(AtomicUsize::new(0));
524
525        guard.set(
526            key.clone(),
527            BuildState::Building {
528                files_total,
529                files_done: Arc::clone(&files_done),
530                edges_found: Arc::clone(&edges_found),
531            },
532        );
533        drop(guard);
534
535        let root = key.root.clone();
536        let fd = Arc::clone(&files_done);
537        let ef = Arc::clone(&edges_found);
538
539        std::thread::spawn(move || {
540            let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
541                let previous = CallGraph::load(&root);
542                if let Some(prev) = &previous {
543                    CallGraph::build_incremental_parallel(&inputs, prev, Some((&fd, &ef)))
544                } else {
545                    CallGraph::build_parallel(&inputs, Some((&fd, &ef)))
546                }
547            }));
548
549            match result {
550                Ok(graph) => {
551                    let _ = graph.save();
552                    let arc = Arc::new(graph);
553                    if let Ok(mut g) = global_state().lock() {
554                        g.set(key, BuildState::Ready(Arc::clone(&arc)));
555                    }
556                    tracing::info!(
557                        "[call_graph: build complete — {} files, {} edges]",
558                        arc.file_hashes.len(),
559                        arc.edges.len()
560                    );
561                }
562                Err(e) => {
563                    let msg = format!("{e:?}");
564                    tracing::error!("[call_graph: build panicked: {msg}]");
565                    if let Ok(mut g) = global_state().lock() {
566                        g.set(key, BuildState::Failed(msg));
567                    }
568                }
569            }
570        });
571
572        Err(BuildProgress {
573            status: "building",
574            files_total,
575            files_done: 0,
576            edges_found: 0,
577        })
578    }
579
580    /// Returns the status of the most recently requested build without
581    /// starting anything: counts only, no file or symbol of any project.
582    pub fn build_status() -> BuildProgress {
583        let state = global_state();
584        let guard = state
585            .lock()
586            .unwrap_or_else(std::sync::PoisonError::into_inner);
587        match guard.latest() {
588            BuildState::Idle => BuildProgress {
589                status: "idle",
590                files_total: 0,
591                files_done: 0,
592                edges_found: 0,
593            },
594            BuildState::Building {
595                files_total,
596                files_done,
597                edges_found,
598            } => BuildProgress {
599                status: "building",
600                files_total: *files_total,
601                files_done: files_done.load(Ordering::Relaxed),
602                edges_found: edges_found.load(Ordering::Relaxed),
603            },
604            BuildState::Ready(graph) => BuildProgress {
605                status: "ready",
606                files_total: graph.file_hashes.len(),
607                files_done: graph.file_hashes.len(),
608                edges_found: graph.edges.len(),
609            },
610            BuildState::Failed(msg) => {
611                tracing::debug!("[call_graph: status check — failed: {msg}]");
612                BuildProgress {
613                    status: "error",
614                    files_total: 0,
615                    files_done: 0,
616                    edges_found: 0,
617                }
618            }
619        }
620    }
621
622    /// Drop the resident graphs of `project_root` (in every protection state)
623    /// so its next request checks the persisted graph against the files again
624    /// and rebuilds what changed. Called after the project's index was
625    /// rebuilt; a build in flight is kept.
626    pub fn invalidate(project_root: &str) {
627        let root = normalize_project_root(project_root);
628        if let Ok(mut g) = global_state().lock() {
629            g.entries.retain(|(key, state)| {
630                key.root != root || matches!(state, BuildState::Building { .. })
631            });
632        }
633    }
634
635    // -----------------------------------------------------------------------
636    // Legacy synchronous API (kept for non-dashboard callers)
637    // -----------------------------------------------------------------------
638
639    pub fn build(inputs: &CallGraphInputs) -> Self {
640        Self::build_parallel(inputs, None)
641    }
642
643    pub fn build_incremental(inputs: &CallGraphInputs, previous: &CallGraph) -> Self {
644        Self::build_incremental_parallel(inputs, previous, None)
645    }
646
647    /// Edges calling `symbol`, however the call site spells it: a query for
648    /// `write` also finds `crate::config_io::write(..)` (see [`callee_matches`]).
649    pub fn callers_of(&self, symbol: &str) -> Vec<&CallEdge> {
650        self.edges
651            .iter()
652            .filter(|e| callee_matches(&e.callee_name, symbol))
653            .collect()
654    }
655
656    pub fn callees_of(&self, symbol: &str) -> Vec<&CallEdge> {
657        let sym_lower = symbol.to_lowercase();
658        self.edges
659            .iter()
660            .filter(|e| e.caller_symbol.to_lowercase() == sym_lower)
661            .collect()
662    }
663
664    // -----------------------------------------------------------------------
665    // Multi-hop BFS traversal
666    // -----------------------------------------------------------------------
667
668    /// BFS callers up to `max_depth` hops. Returns (symbol, file, line, depth) per node.
669    pub fn bfs_callers(&self, symbol: &str, max_depth: usize) -> Vec<BfsNode> {
670        self.bfs_traverse(symbol, max_depth, BfsDirection::Callers)
671    }
672
673    /// BFS callees up to `max_depth` hops. Returns (symbol, file, line, depth) per node.
674    pub fn bfs_callees(&self, symbol: &str, max_depth: usize) -> Vec<BfsNode> {
675        self.bfs_traverse(symbol, max_depth, BfsDirection::Callees)
676    }
677
678    fn bfs_traverse(&self, symbol: &str, max_depth: usize, dir: BfsDirection) -> Vec<BfsNode> {
679        use std::collections::{HashSet, VecDeque};
680
681        let mut visited: HashSet<String> = HashSet::new();
682        let mut queue: VecDeque<(String, usize)> = VecDeque::new();
683        let mut result: Vec<BfsNode> = Vec::new();
684
685        let start = symbol.to_lowercase();
686        visited.insert(start.clone());
687        queue.push_back((start, 0));
688
689        while let Some((current, depth)) = queue.pop_front() {
690            if depth >= max_depth {
691                continue;
692            }
693
694            let neighbors: Vec<&CallEdge> = match dir {
695                BfsDirection::Callers => self
696                    .edges
697                    .iter()
698                    .filter(|e| callee_matches(&e.callee_name, &current))
699                    .collect(),
700                BfsDirection::Callees => self
701                    .edges
702                    .iter()
703                    .filter(|e| e.caller_symbol.to_lowercase() == callee_segment(&current))
704                    .collect(),
705            };
706
707            for edge in neighbors {
708                let next_sym = match dir {
709                    BfsDirection::Callers => &edge.caller_symbol,
710                    BfsDirection::Callees => &edge.callee_name,
711                };
712                // Caller symbols are bare names, so a path callee continues the
713                // walk (and dedups) under its last segment.
714                let next_lower = callee_segment(next_sym).to_lowercase();
715
716                if !visited.insert(next_lower.clone()) {
717                    continue;
718                }
719
720                result.push(BfsNode {
721                    symbol: next_sym.clone(),
722                    file: edge.caller_file.clone(),
723                    line: edge.caller_line,
724                    depth: depth + 1,
725                    from_symbol: if depth == 0 {
726                        symbol.to_string()
727                    } else {
728                        current.clone()
729                    },
730                });
731
732                queue.push_back((next_lower, depth + 1));
733            }
734        }
735
736        result
737    }
738
739    /// Find shortest call path from `from` to `to` using BFS.
740    /// Returns None if no path exists (searched up to depth 10).
741    /// Find shortest call path from `from` to `to` using BFS.
742    /// Returns None if no path exists (searched up to depth 10).
743    pub fn find_call_path(&self, from: &str, to: &str) -> Option<Vec<PathHop>> {
744        use std::collections::{HashMap as BfsMap, VecDeque};
745
746        // Walk by bare names: caller symbols are bare, path callees continue
747        // under their last segment.
748        let from_lower = callee_segment(from).to_lowercase();
749        let to_lower = callee_segment(to).to_lowercase();
750
751        if from_lower == to_lower {
752            return Some(vec![PathHop {
753                symbol: from.to_string(),
754                file: String::new(),
755                line: 0,
756            }]);
757        }
758
759        const MAX_TRACE_DEPTH: usize = 10;
760
761        // (parent_symbol, file, line, depth)
762        let mut visited: BfsMap<String, (String, String, usize, usize)> = BfsMap::new();
763        let mut queue: VecDeque<String> = VecDeque::new();
764
765        visited.insert(from_lower.clone(), (String::new(), String::new(), 0, 0));
766        queue.push_back(from_lower.clone());
767
768        while let Some(current) = queue.pop_front() {
769            let current_depth = visited.get(&current).map_or(0, |e| e.3);
770            if current_depth >= MAX_TRACE_DEPTH {
771                continue;
772            }
773
774            let callees: Vec<&CallEdge> = self
775                .edges
776                .iter()
777                .filter(|e| e.caller_symbol.to_lowercase() == current)
778                .collect();
779
780            for edge in callees {
781                let next = callee_segment(&edge.callee_name).to_lowercase();
782                if visited.contains_key(&next) {
783                    continue;
784                }
785
786                visited.insert(
787                    next.clone(),
788                    (
789                        current.clone(),
790                        edge.caller_file.clone(),
791                        edge.caller_line,
792                        current_depth + 1,
793                    ),
794                );
795
796                if next == to_lower {
797                    return Some(Self::reconstruct_path(
798                        &visited,
799                        &from_lower,
800                        &to_lower,
801                        from,
802                        to,
803                    ));
804                }
805
806                queue.push_back(next);
807            }
808        }
809
810        None
811    }
812
813    fn reconstruct_path(
814        visited: &std::collections::HashMap<String, (String, String, usize, usize)>,
815        from_lower: &str,
816        to_lower: &str,
817        from_orig: &str,
818        to_orig: &str,
819    ) -> Vec<PathHop> {
820        let mut path = Vec::new();
821        let mut current = to_lower.to_string();
822
823        while current != from_lower {
824            let (parent, file, line, _depth) = &visited[&current];
825            let sym_name = if current == to_lower {
826                to_orig.to_string()
827            } else {
828                current.clone()
829            };
830            path.push(PathHop {
831                symbol: sym_name,
832                file: file.clone(),
833                line: *line,
834            });
835            current = parent.clone();
836        }
837
838        path.push(PathHop {
839            symbol: from_orig.to_string(),
840            file: String::new(),
841            line: 0,
842        });
843
844        path.reverse();
845        path
846    }
847
848    /// Count unique transitive callers up to `max_depth`.
849    pub fn transitive_caller_count(&self, symbol: &str, max_depth: usize) -> usize {
850        let nodes = self.bfs_callers(symbol, max_depth);
851        let mut unique: std::collections::HashSet<String> = std::collections::HashSet::new();
852        for node in &nodes {
853            unique.insert(node.symbol.to_lowercase());
854        }
855        unique.len()
856    }
857
858    pub fn save(&self) -> Result<(), String> {
859        let dir = call_graph_dir(&self.project_root)
860            .ok_or_else(|| "Cannot determine home directory".to_string())?;
861        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
862        let json = serde_json::to_string(self).map_err(|e| e.to_string())?;
863        let compressed = zstd::encode_all(json.as_bytes(), 9).map_err(|e| format!("zstd: {e}"))?;
864        let target = dir.join("call_graph.json.zst");
865        let tmp = target.with_extension("zst.tmp");
866        std::fs::write(&tmp, &compressed).map_err(|e| e.to_string())?;
867        std::fs::rename(&tmp, &target).map_err(|e| e.to_string())?;
868        let _ = std::fs::remove_file(dir.join("call_graph.json"));
869        Ok(())
870    }
871
872    pub fn load(project_root: &str) -> Option<Self> {
873        Self::load_any_version(project_root).filter(|g| g.format_version == CALL_GRAPH_FORMAT)
874    }
875
876    fn load_any_version(project_root: &str) -> Option<Self> {
877        let dir = call_graph_dir(project_root)?;
878
879        let zst_path = dir.join("call_graph.json.zst");
880        if zst_path.exists() {
881            let compressed = std::fs::read(&zst_path).ok()?;
882            let data = zstd::decode_all(compressed.as_slice()).ok()?;
883            let content = String::from_utf8(data).ok()?;
884            return serde_json::from_str(&content).ok();
885        }
886
887        let json_path = dir.join("call_graph.json");
888        if json_path.exists() {
889            let content = std::fs::read_to_string(&json_path).ok()?;
890            let parsed: Self = serde_json::from_str(&content).ok()?;
891            // Auto-migrate: compress legacy JSON to zstd
892            if let Ok(compressed) = zstd::encode_all(content.as_bytes(), 9) {
893                let zst_tmp = zst_path.with_extension("zst.tmp");
894                if std::fs::write(&zst_tmp, &compressed).is_ok()
895                    && std::fs::rename(&zst_tmp, &zst_path).is_ok()
896                {
897                    let _ = std::fs::remove_file(&json_path);
898                }
899            }
900            return Some(parsed);
901        }
902
903        None
904    }
905
906    pub fn load_or_build(project_root: &str, inputs: &CallGraphInputs) -> Self {
907        if let Some(previous) = Self::load(project_root) {
908            Self::build_incremental(inputs, &previous)
909        } else {
910            Self::build(inputs)
911        }
912    }
913}
914
915// ---------------------------------------------------------------------------
916// Cache staleness check (fast — mtime-based, no content reads)
917// ---------------------------------------------------------------------------
918
919fn cache_looks_stale(cached: &CallGraph, inputs: &CallGraphInputs) -> bool {
920    if cached.file_hashes.len() + cached.skipped_files.len() != inputs.file_paths.len() {
921        return true;
922    }
923    let cached_files: std::collections::HashSet<&str> = cached
924        .file_hashes
925        .keys()
926        .chain(&cached.skipped_files)
927        .map(String::as_str)
928        .collect();
929    let index_files: std::collections::HashSet<&str> =
930        inputs.file_paths.iter().map(String::as_str).collect();
931    if cached_files != index_files {
932        return true;
933    }
934    // A file edited in place keeps its path: its size or mtime moved.
935    inputs.file_paths.iter().any(|rel| {
936        let stamp = file_stamp(&resolve_path(rel, &inputs.project_root));
937        stamp.is_none() || cached.file_stamps.get(rel) != stamp.as_ref()
938    })
939}
940
941/// The [`FileStamp`] of `path`; `None` when it cannot be read.
942fn file_stamp(path: &str) -> Option<FileStamp> {
943    let meta = std::fs::metadata(path).ok()?;
944    let mtime = meta
945        .modified()
946        .ok()?
947        .duration_since(std::time::UNIX_EPOCH)
948        .ok()?;
949    Some((
950        meta.len(),
951        u64::try_from(mtime.as_nanos()).unwrap_or(u64::MAX),
952    ))
953}
954
955/// Whether `cached` was built from a file that is not among `inputs` — the
956/// files the current request may use. Unlike [`cache_looks_stale`] this
957/// ignores files the build could not read, so a resident graph is not
958/// rebuilt on every request because of one unreadable file.
959fn holds_withdrawn_files(cached: &CallGraph, inputs: &CallGraphInputs) -> bool {
960    let current: std::collections::HashSet<&str> =
961        inputs.file_paths.iter().map(String::as_str).collect();
962    cached
963        .file_hashes
964        .keys()
965        .any(|file| !current.contains(file.as_str()))
966}
967
968// ---------------------------------------------------------------------------
969// Helpers
970// ---------------------------------------------------------------------------
971
972fn call_graph_dir(project_root: &str) -> Option<std::path::PathBuf> {
973    GraphProvider::index_dir(project_root)
974}
975
976fn group_edges_by_file(edges: &[CallEdge]) -> HashMap<&str, Vec<CallEdge>> {
977    let mut map: HashMap<&str, Vec<CallEdge>> = HashMap::new();
978    for edge in edges {
979        map.entry(edge.caller_file.as_str())
980            .or_default()
981            .push(edge.clone());
982    }
983    map
984}
985
986/// Owned version for safe `Send` across rayon threads.
987fn group_symbols_by_file_owned(inputs: &CallGraphInputs) -> HashMap<String, Vec<SymbolSpan>> {
988    let mut map: HashMap<String, Vec<SymbolSpan>> = HashMap::new();
989    for sym in &inputs.symbols {
990        map.entry(sym.file.clone()).or_default().push(sym.clone());
991    }
992    for syms in map.values_mut() {
993        syms.sort_by_key(|s| s.start_line);
994    }
995    map
996}
997
998fn find_enclosing_symbol_owned(file_symbols: Option<&Vec<SymbolSpan>>, line: usize) -> String {
999    let Some(syms) = file_symbols else {
1000        return "<module>".to_string();
1001    };
1002    let mut best: Option<&SymbolSpan> = None;
1003    for sym in syms {
1004        if line >= sym.start_line && line <= sym.end_line {
1005            match best {
1006                None => best = Some(sym),
1007                Some(prev) => {
1008                    if (sym.end_line - sym.start_line) < (prev.end_line - prev.start_line) {
1009                        best = Some(sym);
1010                    }
1011                }
1012            }
1013        }
1014    }
1015    best.map_or_else(|| "<module>".to_string(), |s| s.name.clone())
1016}
1017
1018fn resolve_path(relative: &str, project_root: &str) -> String {
1019    let p = Path::new(relative);
1020    if p.is_absolute() && p.exists() {
1021        return relative.to_string();
1022    }
1023    let relative = relative.trim_start_matches(['/', '\\']);
1024    let joined = Path::new(project_root).join(relative);
1025    joined.to_string_lossy().to_string()
1026}
1027
1028/// Text of an indexed file: `None` when it cannot be read, `Some(Err(_))` when
1029/// it is not UTF-8 (recorded in [`CallGraph::skipped_files`]).
1030fn read_source(abs_path: &str) -> Option<Result<String, std::string::FromUtf8Error>> {
1031    std::fs::read(abs_path).ok().map(String::from_utf8)
1032}
1033
1034fn simple_hash(content: &str) -> String {
1035    use std::hash::{Hash, Hasher};
1036    let mut hasher = std::collections::hash_map::DefaultHasher::new();
1037    content.hash(&mut hasher);
1038    format!("{:x}", hasher.finish())
1039}
1040
1041/// Current content hashes of the indexed files — the same values a
1042/// [`CallGraph`] records in `file_hashes` — without building or loading a
1043/// call graph. For callers that need only the hashes (semantic cache keys
1044/// and project revision), which is a fraction of a call-graph build.
1045pub fn current_file_hashes(inputs: &CallGraphInputs) -> HashMap<String, String> {
1046    inputs
1047        .file_paths
1048        .par_iter()
1049        .filter_map(|rel| {
1050            let content = std::fs::read_to_string(resolve_path(rel, &inputs.project_root)).ok()?;
1051            Some((rel.clone(), simple_hash(&content)))
1052        })
1053        .collect()
1054}
1055
1056// ---------------------------------------------------------------------------
1057// Scope-aware callee resolution (#321)
1058//
1059// Call edges store callees as bare names, so attributing `Run`/`Get`/`Handle`
1060// to a file by name alone links every same-named symbol (false positives).
1061// These helpers resolve a callee to its defining file using the caller's
1062// lexical scope and refuse to guess when a name stays ambiguous.
1063// ---------------------------------------------------------------------------
1064
1065/// Build a `file -> imported files` adjacency from the project index's import
1066/// and reexport edges, used to scope callee resolution to a caller's imports.
1067pub fn build_import_adjacency(
1068    inputs: &CallGraphInputs,
1069) -> HashMap<String, std::collections::HashSet<String>> {
1070    let mut adj: HashMap<String, std::collections::HashSet<String>> = HashMap::new();
1071    for (from, to) in &inputs.import_edges {
1072        adj.entry(from.clone()).or_default().insert(to.clone());
1073    }
1074    adj
1075}
1076
1077/// Pick the defining file for a callee from candidate `def_files`, ranked by the
1078/// caller's lexical scope (most specific first):
1079///   1. the caller's own file,
1080///   2. exactly one file the caller imports,
1081///   3. exactly one file project-wide.
1082///
1083/// Returns `None` when the name stays ambiguous (never guesses).
1084fn rank_callee_def_file(
1085    def_files: &[&str],
1086    caller_file: &str,
1087    imports: &HashMap<String, std::collections::HashSet<String>>,
1088) -> Option<(String, ScopeMatch)> {
1089    if def_files.is_empty() {
1090        return None;
1091    }
1092    if def_files.contains(&caller_file) {
1093        return Some((caller_file.to_string(), ScopeMatch::SameFile));
1094    }
1095    if let Some(imported) = imports.get(caller_file) {
1096        let mut in_scope = def_files.iter().filter(|f| imported.contains(**f));
1097        if let Some(first) = in_scope.next()
1098            && in_scope.next().is_none()
1099        {
1100            return Some(((*first).to_string(), ScopeMatch::UniqueImport));
1101        }
1102    }
1103    if def_files.len() == 1 {
1104        return Some((def_files[0].to_string(), ScopeMatch::UniqueInProject));
1105    }
1106    None
1107}
1108
1109/// The identifier a callee name ends in: `crate::db::save` → `save`,
1110/// `pkg.Func` → `Func`, `save` → `save`.
1111pub fn callee_segment(name: &str) -> &str {
1112    name.rsplit(|c: char| c == ':' || c == '.')
1113        .next()
1114        .unwrap_or(name)
1115}
1116
1117/// Lower-cased path segments of a callee or query, without the relative
1118/// anchors that do not name anything (`crate`, `self`, `super`, `Self`):
1119/// `crate::config_io::write` → `["config_io", "write"]`.
1120fn path_segments(name: &str) -> Vec<String> {
1121    name.split(|c: char| c == ':' || c == '.')
1122        .filter(|s| !s.is_empty())
1123        .map(str::to_lowercase)
1124        .filter(|s| !matches!(s.as_str(), "crate" | "self" | "super"))
1125        .collect()
1126}
1127
1128/// Whether a call to `callee_name` is a call to the symbol named by `query`.
1129///
1130/// Edges keep the callee as written, so `crate::config_io::write(..)` and
1131/// `config_io::write(..)` must match a query for `write`, and a qualified query
1132/// (`config_io::write`) must match the bare and the qualified spelling. The
1133/// shorter path must be a segment suffix of the longer one, so `a::save` never
1134/// matches `b::save`.
1135pub fn callee_matches(callee_name: &str, query: &str) -> bool {
1136    let callee = path_segments(callee_name);
1137    let query = path_segments(query);
1138    if callee.is_empty() || query.is_empty() {
1139        return false;
1140    }
1141    let (short, long) = if callee.len() <= query.len() {
1142        (&callee, &query)
1143    } else {
1144        (&query, &callee)
1145    };
1146    long.ends_with(short)
1147}
1148
1149/// Narrow `defs` (files defining the callee's last segment) to the files the
1150/// callee's own path names: `config_io::write` → `…/config_io.rs` or
1151/// `…/config_io/mod.rs`; `CallGraph::build` → the file that also defines
1152/// `CallGraph`. Returns the single named file, or `None` when the qualifier
1153/// names no definition file or several (never guesses).
1154fn module_path_def_file<'a>(
1155    callee_name: &str,
1156    defs: &[&'a str],
1157    type_files: &HashMap<&str, Vec<&str>>,
1158) -> Option<&'a str> {
1159    let segments: Vec<&str> = callee_name
1160        .split(|c: char| c == ':' || c == '.')
1161        .filter(|s| !s.is_empty() && !matches!(*s, "crate" | "self" | "super" | "Self"))
1162        .collect();
1163    let qualifier = *segments
1164        .len()
1165        .checked_sub(2)
1166        .and_then(|i| segments.get(i))?;
1167    let names_module = |file: &str| -> bool {
1168        let path = Path::new(file);
1169        let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or("");
1170        if stem == qualifier {
1171            return true;
1172        }
1173        matches!(stem, "mod" | "index" | "__init__" | "lib")
1174            && path
1175                .parent()
1176                .and_then(|p| p.file_name())
1177                .and_then(|n| n.to_str())
1178                == Some(qualifier)
1179    };
1180    let type_defs = type_files.get(qualifier);
1181    let mut named = defs
1182        .iter()
1183        .copied()
1184        .filter(|d| names_module(d) || type_defs.is_some_and(|files| files.contains(d)));
1185    let first = named.next()?;
1186    named.next().is_none().then_some(first)
1187}
1188
1189/// Which scope step resolved a callee structurally — the strength of the
1190/// evidence. Same-file and imported definitions are bound by the caller's own
1191/// scope; a name that is merely unique project-wide may still be an external
1192/// function of the same name.
1193#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1194pub enum ScopeMatch {
1195    SameFile,
1196    UniqueImport,
1197    UniqueInProject,
1198    /// The call's own path names the defining module or type
1199    /// (`config_io::write` → `config_io.rs`), and exactly one such file
1200    /// defines the callee.
1201    ModulePath,
1202}
1203
1204/// Structural view of one call edge's callee.
1205#[derive(Debug, Clone, PartialEq, Eq)]
1206pub enum StructuralTarget {
1207    Resolved {
1208        file: String,
1209        via: ScopeMatch,
1210    },
1211    /// Several project definitions remain in scope; structure cannot decide.
1212    Ambiguous,
1213    /// No project symbol has this name (std, dependency, dynamic dispatch).
1214    Unknown,
1215}
1216
1217impl StructuralTarget {
1218    pub fn file(&self) -> Option<&str> {
1219        match self {
1220            Self::Resolved { file, .. } => Some(file),
1221            Self::Ambiguous | Self::Unknown => None,
1222        }
1223    }
1224}
1225
1226/// Resolve a single callee name to its defining file in the scope of `caller_file`.
1227pub fn resolve_callee_file(
1228    callee_name: &str,
1229    caller_file: &str,
1230    inputs: &CallGraphInputs,
1231    imports: &HashMap<String, std::collections::HashSet<String>>,
1232) -> Option<String> {
1233    let mut def_files: Vec<&str> = inputs
1234        .symbols
1235        .iter()
1236        .filter(|s| s.name == callee_name)
1237        .map(|s| s.file.as_str())
1238        .collect();
1239    def_files.sort_unstable();
1240    def_files.dedup();
1241    rank_callee_def_file(&def_files, caller_file, imports).map(|(file, _)| file)
1242}
1243
1244/// Resolve each edge's callee to its defining file in *that edge's own* caller
1245/// scope (index-aligned with `edges`). `None` = ambiguous or unknown; never
1246/// guesses. Use this for per-edge consumers (graph edges); the name-keyed
1247/// [`resolve_callee_files`] additionally drops names that differ across scopes.
1248pub fn resolve_edge_callee_files(
1249    inputs: &CallGraphInputs,
1250    edges: &[CallEdge],
1251) -> Vec<Option<String>> {
1252    resolve_edge_callee_targets(inputs, edges)
1253        .into_iter()
1254        .map(|t| t.file().map(str::to_string))
1255        .collect()
1256}
1257
1258/// Like [`resolve_edge_callee_files`], but keeps *why* each edge resolved —
1259/// or that it is ambiguous vs. unknown — for evidence grading and semantic
1260/// escalation.
1261pub fn resolve_edge_callee_targets(
1262    inputs: &CallGraphInputs,
1263    edges: &[CallEdge],
1264) -> Vec<StructuralTarget> {
1265    let callee_names: std::collections::HashSet<&str> = edges
1266        .iter()
1267        .flat_map(|e| [e.callee_name.as_str(), callee_segment(&e.callee_name)])
1268        .collect();
1269    if callee_names.is_empty() {
1270        return Vec::new();
1271    }
1272
1273    // Definition files per name: callee names, their last path segment, and
1274    // capitalised names (types a path like `CallGraph::build` may qualify by).
1275    let mut name_files: HashMap<&str, Vec<&str>> = HashMap::new();
1276    let mut type_files: HashMap<&str, Vec<&str>> = HashMap::new();
1277    for sym in &inputs.symbols {
1278        if callee_names.contains(sym.name.as_str()) {
1279            name_files
1280                .entry(sym.name.as_str())
1281                .or_default()
1282                .push(sym.file.as_str());
1283        }
1284        if sym.name.starts_with(char::is_uppercase) {
1285            type_files
1286                .entry(sym.name.as_str())
1287                .or_default()
1288                .push(sym.file.as_str());
1289        }
1290    }
1291    for files in name_files.values_mut().chain(type_files.values_mut()) {
1292        files.sort_unstable();
1293        files.dedup();
1294    }
1295
1296    let imports = build_import_adjacency(inputs);
1297    edges
1298        .iter()
1299        .map(|e| {
1300            // Only definitions the caller's language can call directly: a Rust
1301            // `run()` never binds to a shell script's `run`.
1302            let family = call_family(&e.caller_file);
1303            let segment = callee_segment(&e.callee_name);
1304            if segment.len() < e.callee_name.len() {
1305                // Path callee (`db::save`): bind it only when its own path names
1306                // exactly one defining file; otherwise it is a semantic
1307                // candidate, never a structural guess by bare name.
1308                let Some(defs) = name_files
1309                    .get(segment)
1310                    .map(|defs| same_family_defs(defs, family))
1311                    .filter(|d| !d.is_empty())
1312                else {
1313                    return StructuralTarget::Unknown;
1314                };
1315                // `Self::new()` / `self::helper()` name the caller's own type or
1316                // module: bound when the caller's file defines the callee.
1317                let own_scope = ["Self::", "self::"]
1318                    .iter()
1319                    .any(|p| e.callee_name.starts_with(p));
1320                if own_scope && defs.contains(&e.caller_file.as_str()) {
1321                    return StructuralTarget::Resolved {
1322                        file: e.caller_file.clone(),
1323                        via: ScopeMatch::SameFile,
1324                    };
1325                }
1326                return match module_path_def_file(&e.callee_name, &defs, &type_files) {
1327                    Some(file) => StructuralTarget::Resolved {
1328                        file: file.to_string(),
1329                        via: ScopeMatch::ModulePath,
1330                    },
1331                    None => StructuralTarget::Ambiguous,
1332                };
1333            }
1334            let defs = name_files
1335                .get(e.callee_name.as_str())
1336                .map(|defs| same_family_defs(defs, family))
1337                .filter(|d| !d.is_empty());
1338            match defs {
1339                None => StructuralTarget::Unknown,
1340                Some(defs) => match rank_callee_def_file(&defs, &e.caller_file, &imports) {
1341                    Some((file, via)) => StructuralTarget::Resolved { file, via },
1342                    None => StructuralTarget::Ambiguous,
1343                },
1344            }
1345        })
1346        .collect()
1347}
1348
1349/// Only definitions the caller's language `family` can call directly: a Rust
1350/// `run()` never binds to a shell script's `run`.
1351fn same_family_defs<'a>(defs: &[&'a str], family: Option<&str>) -> Vec<&'a str> {
1352    defs.iter()
1353        .copied()
1354        .filter(|d| family.is_none() || call_family(d).is_none_or(|f| Some(f) == family))
1355        .collect()
1356}
1357
1358/// Languages that call each other's definitions directly, by file; `None`
1359/// for files whose language is unknown (never filtered out).
1360fn call_family(path: &str) -> Option<&'static str> {
1361    let id = crate::core::language_capabilities::language_for_path(path)?.id_str();
1362    Some(match id {
1363        "typescript" | "javascript" | "vue" | "svelte" => "js",
1364        "c" | "cpp" => "c",
1365        "java" | "kotlin" | "scala" => "jvm",
1366        "lua" | "luau" => "lua",
1367        other => other,
1368    })
1369}
1370
1371/// Resolve callee names to a single defining file *when scope makes it
1372/// unambiguous across all call sites*. Names that resolve to different files
1373/// from different scopes are omitted, so callers never attribute a call to the
1374/// wrong file. Keyed by callee name to match the call graph's name-keyed nodes.
1375pub fn resolve_callee_files(
1376    inputs: &CallGraphInputs,
1377    edges: &[CallEdge],
1378) -> HashMap<String, String> {
1379    use std::collections::HashSet;
1380
1381    let mut resolved: HashMap<&str, HashSet<String>> = HashMap::new();
1382    for (e, file) in edges.iter().zip(resolve_edge_callee_files(inputs, edges)) {
1383        if let Some(file) = file {
1384            resolved
1385                .entry(e.callee_name.as_str())
1386                .or_default()
1387                .insert(file);
1388        }
1389    }
1390
1391    resolved
1392        .into_iter()
1393        .filter_map(|(name, files)| {
1394            (files.len() == 1).then(|| {
1395                (
1396                    name.to_string(),
1397                    files
1398                        .into_iter()
1399                        .next()
1400                        .expect("files.len() == 1 guarantees one element"),
1401                )
1402            })
1403        })
1404        .collect()
1405}
1406
1407#[cfg(test)]
1408#[path = "call_graph_tests.rs"]
1409pub mod tests;