Skip to main content

mago_codex/
differ.rs

1use foldhash::HashMap;
2use foldhash::HashSet;
3
4use mago_database::file::FileId;
5use mago_word::empty_word;
6
7use crate::diff::CodebaseDiff;
8use crate::signature::DefSignatureNode;
9use crate::signature::FileSignature;
10
11/// Computes the difference between an old file signature and a new file signature.
12///
13/// This function uses the Myers diff algorithm to efficiently identify changes between
14/// two versions of a file's AST. Unlike Hakana which differentiates between signature
15/// and body changes, we use a single hash approach: any change triggers re-analysis.
16///
17/// # Arguments
18///
19/// * `file_id` - The identifier of the file being compared
20/// * `old_signature` - The previous file signature (None if this is a new file)
21/// * `new_signature` - The current file signature
22///
23/// # Returns
24///
25/// A `CodebaseDiff` containing:
26/// - `keep`: Symbols that are unchanged
27/// - `changed`: Symbols that are new, deleted, or modified
28#[must_use]
29pub fn compute_file_diff(
30    file_id: FileId,
31    old_signature: Option<&FileSignature>,
32    new_signature: Option<&FileSignature>,
33) -> CodebaseDiff {
34    match (old_signature, new_signature) {
35        (None, None) => CodebaseDiff::new(),
36        (Some(old_sig), None) => mark_all_as_changed(old_sig),
37        (None, Some(new_sig)) => mark_all_as_changed(new_sig),
38        (Some(old_sig), Some(new_sig)) => myers_diff(file_id, old_sig, new_sig),
39    }
40}
41
42/// Marks all symbols in a file signature as changed (used for new files).
43fn mark_all_as_changed(signature: &FileSignature) -> CodebaseDiff {
44    let mut changed = HashSet::default();
45
46    for node in &signature.ast_nodes {
47        // Add top-level symbol
48        changed.insert((node.name, empty_word()));
49
50        // Add all children (methods, properties, etc.)
51        for child in &node.children {
52            changed.insert((node.name, child.name));
53        }
54    }
55
56    CodebaseDiff::new().with_changed(changed)
57}
58
59/// Computes a detailed diff between two file signatures using Myers algorithm.
60///
61/// This function implements a two-level Myers diff:
62/// 1. Top-level diff: compares classes, functions, and constants
63/// 2. Member-level diff: compares methods, properties, and class constants within each class
64///
65/// Uses `signature_hash` (which excludes function/method bodies) for the keep/changed
66/// decision. This means body-only changes to a function or method do NOT mark it as
67/// "changed" in the diff, so they don't trigger cascade invalidation of dependents.
68/// The changed file itself is still re-analyzed because its content hash changed.
69///
70/// # Arguments
71///
72/// * `file_id` - File being compared
73/// * `old_signature` - Previous file signature
74/// * `new_signature` - Current file signature
75///
76/// # Returns
77///
78/// A `CodebaseDiff` with:
79/// - `keep`: Symbols whose signatures are unchanged
80/// - `changed`: Added, removed, or signature-modified symbols
81fn myers_diff(file_id: FileId, old_signature: &FileSignature, new_signature: &FileSignature) -> CodebaseDiff {
82    let mut keep = HashSet::default();
83    let mut changed = HashSet::default();
84
85    let Ok((trace, x, y)) = calculate_trace(&old_signature.ast_nodes, &new_signature.ast_nodes) else {
86        tracing::warn!("Myers diff algorithm failed to converge for file {file_id:?}, marking all symbols as changed");
87
88        return mark_all_as_changed(new_signature);
89    };
90
91    let diff = extract_diff(&trace, x, y, &old_signature.ast_nodes, &new_signature.ast_nodes);
92
93    for diff_elem in diff {
94        match diff_elem {
95            AstDiffElem::Keep(a, b) => {
96                let mut has_child_sig_change = false;
97
98                let Ok((class_trace, class_x, class_y)) = calculate_trace(&a.children, &b.children) else {
99                    changed.insert((a.name, empty_word()));
100                    for child in &a.children {
101                        changed.insert((a.name, child.name));
102                    }
103
104                    for child in &b.children {
105                        changed.insert((b.name, child.name));
106                    }
107
108                    continue;
109                };
110
111                let class_diff = extract_diff(&class_trace, class_x, class_y, &a.children, &b.children);
112
113                for class_diff_elem in class_diff {
114                    match class_diff_elem {
115                        AstDiffElem::Keep(a_child, b_child) => {
116                            // Use signature_hash for cascade decision: body-only changes
117                            // don't cascade to dependents, only signature changes do.
118                            if a_child.signature_hash == b_child.signature_hash {
119                                keep.insert((a.name, a_child.name));
120                            } else {
121                                has_child_sig_change = true;
122                                changed.insert((a.name, a_child.name));
123                            }
124                        }
125                        AstDiffElem::Remove(child_node) => {
126                            has_child_sig_change = true;
127                            changed.insert((a.name, child_node.name));
128                        }
129                        AstDiffElem::Add(child_node) => {
130                            has_child_sig_change = true;
131                            changed.insert((a.name, child_node.name));
132                        }
133                    }
134                }
135
136                if has_child_sig_change || a.signature_hash != b.signature_hash {
137                    changed.insert((a.name, empty_word()));
138                } else {
139                    keep.insert((a.name, empty_word()));
140                }
141            }
142            AstDiffElem::Remove(node) => {
143                changed.insert((node.name, empty_word()));
144
145                // Also mark all children as removed
146                for child in &node.children {
147                    changed.insert((node.name, child.name));
148                }
149            }
150            AstDiffElem::Add(node) => {
151                changed.insert((node.name, empty_word()));
152
153                // Also mark all children as added
154                for child in &node.children {
155                    changed.insert((node.name, child.name));
156                }
157            }
158        }
159    }
160
161    CodebaseDiff::new().with_keep(keep).with_changed(changed)
162}
163
164/// Type alias for the Myers diff trace structure.
165///
166/// - Vec<`HashMap`<isize, usize>>: The trace of the search path
167/// - usize: Final position in the old sequence
168/// - usize: Final position in the new sequence
169type DiffTrace = (Vec<HashMap<isize, usize>>, usize, usize);
170
171/// Implements the Myers diff algorithm.
172///
173/// Borrows from:
174/// - <https://github.com/nikic/PHP-Parser/blob/master/lib/PhpParser/Internal/Differ.php>
175/// - <https://github.com/slackhq/hakana/blob/35890f99ded7897e4203a896fd1636bda300bad6/src/orchestrator/ast_differ.rs#L151-L159>
176///
177/// Myers, Eugene W. "An O(ND) difference algorithm and its variations."
178/// Algorithmica 1.1 (1986): 251-266.
179///
180/// Returns a Result containing a tuple of (trace, x, y) where:
181/// - trace: A vector of hash maps representing the search path
182/// - x: Final position in the old sequence
183/// - y: Final position in the new sequence
184///
185/// Returns Err if the algorithm fails to converge (theoretically impossible but handled gracefully).
186fn calculate_trace(a_nodes: &[DefSignatureNode], b_nodes: &[DefSignatureNode]) -> Result<DiffTrace, &'static str> {
187    let n = a_nodes.len();
188    let m = b_nodes.len();
189    let max = n + m;
190    let mut v: HashMap<isize, usize> = HashMap::default();
191    v.insert(1, 0);
192    let mut trace = vec![];
193
194    for d in 0..=(max as isize) {
195        trace.push(v.clone());
196        let mut k = -d;
197        while k <= d {
198            let mut x = if k == -d || (k != d && v[&(k - 1)] < v[&(k + 1)]) { v[&(k + 1)] } else { v[&(k - 1)] + 1 };
199
200            let mut y = (x as isize - k) as usize;
201
202            // Advance along the diagonal while nodes are equal
203            while x < n && y < m && is_equal(&a_nodes[x], &b_nodes[y]) {
204                x += 1;
205                y += 1;
206            }
207
208            v.insert(k, x);
209
210            // Found the end
211            if x >= n && y >= m {
212                return Ok((trace, x, y));
213            }
214
215            k += 2;
216        }
217    }
218
219    Err("Myers diff algorithm failed to converge")
220}
221
222/// Checks if two `DefSignatureNode` instances can be matched for diffing.
223///
224/// Two nodes are considered matchable if they have the same:
225/// - name
226/// - `is_function` flag
227///
228/// We don't check hash here because we want to match nodes even if their
229/// content changed. The hash difference will be detected later to determine
230/// if they belong in the "keep" or "changed" set.
231fn is_equal(a_node: &DefSignatureNode, b_node: &DefSignatureNode) -> bool {
232    a_node.name == b_node.name && a_node.is_function == b_node.is_function
233}
234
235/// Extracts the diff elements from the Myers trace.
236///
237/// Walks backward through the trace to build a sequence of Keep, Remove, and Add operations.
238fn extract_diff<'nodes>(
239    trace: &[HashMap<isize, usize>],
240    mut x: usize,
241    mut y: usize,
242    a_nodes: &'nodes [DefSignatureNode],
243    b_nodes: &'nodes [DefSignatureNode],
244) -> Vec<AstDiffElem<'nodes>> {
245    let mut result = vec![];
246    let mut d = trace.len() as isize - 1;
247
248    while d >= 0 {
249        let v = &trace[d as usize];
250        let k = (x as isize) - (y as isize);
251
252        let prev_k = if k == -d || (k != d && v[&(k - 1)] < v[&(k + 1)]) { k + 1 } else { k - 1 };
253
254        let prev_x = v[&prev_k];
255        let prev_y = prev_x as isize - prev_k;
256
257        // Walk diagonals (unchanged elements)
258        while x > prev_x && y as isize > prev_y {
259            result.push(AstDiffElem::Keep(&a_nodes[x - 1], &b_nodes[y - 1]));
260            x -= 1;
261            y -= 1;
262        }
263
264        if d == 0 {
265            break;
266        }
267
268        // Deletions
269        while x > prev_x {
270            result.push(AstDiffElem::Remove(&a_nodes[x - 1]));
271            x -= 1;
272        }
273
274        // Additions
275        while y as isize > prev_y {
276            result.push(AstDiffElem::Add(&b_nodes[y - 1]));
277            y -= 1;
278        }
279
280        d -= 1;
281    }
282
283    result.reverse();
284    result
285}
286
287/// Represents a single element in the AST diff.
288#[derive(Debug)]
289enum AstDiffElem<'nodes> {
290    /// Node unchanged in both old and new versions
291    Keep(&'nodes DefSignatureNode, &'nodes DefSignatureNode),
292    /// Node was removed in the new version
293    Remove(&'nodes DefSignatureNode),
294    /// Node was added in the new version
295    Add(&'nodes DefSignatureNode),
296}