Skip to main content

harn_hostlib/code_index/
graph.rs

1//! Forward + reverse dependency graph over project files.
2//!
3//! Edges are "file A imports file B", built from the import strings the
4//! `imports` module surfaces. Forward answers "what does A depend on?";
5//! reverse answers "who depends on A?". The `unresolved_imports`
6//! side-table stores raw strings the resolver could not map back to a
7//! known file.
8
9use std::collections::{HashMap, HashSet};
10
11use super::file_table::FileId;
12
13/// Forward + reverse import graph plus the side-table of unresolved
14/// import strings (raw text we couldn't map back to a known file).
15#[derive(Debug, Default, Clone)]
16pub struct DepGraph {
17    forward: HashMap<FileId, HashSet<FileId>>,
18    reverse: HashMap<FileId, HashSet<FileId>>,
19    unresolved: HashMap<FileId, Vec<String>>,
20}
21
22impl DepGraph {
23    /// Construct an empty graph.
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Replace the outgoing edges for `file` with the resolved set.
29    /// Re-keys reverse edges atomically so `imports`/`importers` stay in
30    /// sync after every call.
31    pub fn set_edges(&mut self, file: FileId, resolved: HashSet<FileId>, unresolved: Vec<String>) {
32        if let Some(old) = self.forward.remove(&file) {
33            for target in &old {
34                if *target == file {
35                    continue;
36                }
37                if let Some(set) = self.reverse.get_mut(target) {
38                    set.remove(&file);
39                    if set.is_empty() {
40                        self.reverse.remove(target);
41                    }
42                }
43            }
44        }
45        for target in &resolved {
46            if *target == file {
47                continue;
48            }
49            self.reverse.entry(*target).or_default().insert(file);
50        }
51        self.forward.insert(file, resolved);
52        if unresolved.is_empty() {
53            self.unresolved.remove(&file);
54        } else {
55            self.unresolved.insert(file, unresolved);
56        }
57    }
58
59    /// Drop every edge touching `file` from both directions. Importers
60    /// of `file` keep their forward entry but lose the dangling target.
61    pub fn remove_file(&mut self, file: FileId) {
62        if let Some(old) = self.forward.remove(&file) {
63            for target in old {
64                if target == file {
65                    continue;
66                }
67                if let Some(set) = self.reverse.get_mut(&target) {
68                    set.remove(&file);
69                    if set.is_empty() {
70                        self.reverse.remove(&target);
71                    }
72                }
73            }
74        }
75        self.unresolved.remove(&file);
76        // Anyone who imported us keeps their forward edge but it now
77        // dangles. They'll re-resolve on their next reindex.
78        if let Some(rev) = self.reverse.remove(&file) {
79            for importer in rev {
80                if let Some(forward) = self.forward.get_mut(&importer) {
81                    forward.remove(&file);
82                }
83            }
84        }
85    }
86
87    /// Forward edges: every file `file` imports.
88    pub fn imports_of(&self, file: FileId) -> Vec<FileId> {
89        self.forward
90            .get(&file)
91            .map(|set| set.iter().copied().collect())
92            .unwrap_or_default()
93    }
94
95    /// Reverse edges: every file that imports `file`.
96    pub fn importers_of(&self, file: FileId) -> Vec<FileId> {
97        self.reverse
98            .get(&file)
99            .map(|set| set.iter().copied().collect())
100            .unwrap_or_default()
101    }
102
103    /// Raw import strings the resolver couldn't map back to a known file.
104    pub fn unresolved_imports(&self, file: FileId) -> &[String] {
105        self.unresolved.get(&file).map(Vec::as_slice).unwrap_or(&[])
106    }
107
108    /// Capture the forward edges + unresolved side-table as snapshot rows.
109    /// Sorted by source id for deterministic on-disk JSON.
110    pub fn snapshot_rows(&self) -> Vec<super::snapshot::DepRow> {
111        let mut keys: HashSet<FileId> = HashSet::new();
112        keys.extend(self.forward.keys().copied());
113        keys.extend(self.unresolved.keys().copied());
114        let mut keys: Vec<FileId> = keys.into_iter().collect();
115        keys.sort_unstable();
116        keys.into_iter()
117            .map(|from| {
118                let mut to: Vec<FileId> = self
119                    .forward
120                    .get(&from)
121                    .map(|s| s.iter().copied().collect())
122                    .unwrap_or_default();
123                to.sort_unstable();
124                let unresolved = self.unresolved.get(&from).cloned().unwrap_or_default();
125                super::snapshot::DepRow {
126                    from,
127                    to,
128                    unresolved,
129                }
130            })
131            .collect()
132    }
133
134    /// Rebuild a [`DepGraph`] from snapshot rows. Mirrors the per-file
135    /// `set_edges` rule so the reverse map stays in sync.
136    pub fn from_rows(rows: Vec<super::snapshot::DepRow>) -> Self {
137        let mut g = Self::new();
138        for row in rows {
139            let resolved: HashSet<FileId> = row.to.into_iter().collect();
140            g.set_edges(row.from, resolved, row.unresolved);
141        }
142        g
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn set_of(ids: &[FileId]) -> HashSet<FileId> {
151        ids.iter().copied().collect()
152    }
153
154    #[test]
155    fn set_edges_populates_both_sides() {
156        let mut g = DepGraph::new();
157        g.set_edges(1, set_of(&[2, 3]), vec![]);
158        let mut imports = g.imports_of(1);
159        imports.sort();
160        assert_eq!(imports, vec![2, 3]);
161        assert_eq!(g.importers_of(2), vec![1]);
162        assert_eq!(g.importers_of(3), vec![1]);
163    }
164
165    #[test]
166    fn re_setting_edges_drops_stale_reverse_edges() {
167        let mut g = DepGraph::new();
168        g.set_edges(1, set_of(&[2]), vec![]);
169        g.set_edges(1, set_of(&[3]), vec![]);
170        assert!(g.importers_of(2).is_empty());
171        assert_eq!(g.importers_of(3), vec![1]);
172    }
173
174    #[test]
175    fn remove_file_cleans_up_both_directions() {
176        let mut g = DepGraph::new();
177        g.set_edges(1, set_of(&[2]), vec!["unmatched".into()]);
178        g.set_edges(3, set_of(&[1]), vec![]);
179        g.remove_file(1);
180        assert!(g.imports_of(1).is_empty());
181        assert!(g.importers_of(2).is_empty());
182        // 3 still imports... nothing now (its forward edge dangling-cleared).
183        assert!(g.imports_of(3).is_empty());
184    }
185
186    #[test]
187    fn unresolved_imports_round_trip() {
188        let mut g = DepGraph::new();
189        g.set_edges(1, HashSet::new(), vec!["weird::path".into()]);
190        assert_eq!(g.unresolved_imports(1), &["weird::path".to_string()]);
191        g.set_edges(1, HashSet::new(), vec![]);
192        assert!(g.unresolved_imports(1).is_empty());
193    }
194}