Skip to main content

oxilean_parse/incremental/
ast_diff.rs

1//! Decl-granular Myers-diff AST diffing for incremental invalidation.
2//!
3//! This module compares two sequences of top-level declarations (`Vec<Located<Decl>>`)
4//! using the classic Myers O(ND) LCS (Longest Common Subsequence) algorithm.
5//! Each declaration is first reduced to a stable, span-independent `DeclFingerprint`
6//! (name + kind + structural body hash), and the diff is performed over those
7//! fingerprints. The resulting edit script is a `Vec<DeclEdit>`.
8//!
9//! # Example
10//!
11//! ```ignore
12//! use oxilean_parse::incremental::{diff_modules, EditKind};
13//!
14//! let old = parse_decls("def foo : Nat := 0").unwrap();
15//! let new = parse_decls("def foo : Nat := 1").unwrap();
16//! let edits = diff_modules(&old, &new);
17//! assert_eq!(edits.len(), 1);
18//! assert_eq!(edits[0].kind, EditKind::Modified);
19//! ```
20
21use crate::ast_impl::{Decl, Located};
22use crate::prettyprint::print_decl;
23
24// ── Inline FNV-1a 64-bit hash ────────────────────────────────────────────────
25
26/// Compute a 64-bit FNV-1a hash over a byte slice.
27///
28/// This is a span-independent structural hash: the caller is responsible for
29/// providing a span-free byte representation of the value to hash.
30fn fnv1a_hash(data: &[u8]) -> u64 {
31    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
32    for &b in data {
33        hash ^= b as u64;
34        hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
35    }
36    hash
37}
38
39// ── DeclKind ─────────────────────────────────────────────────────────────────
40
41/// A coarse kind tag for a top-level declaration, used as part of
42/// `DeclFingerprint` equality. Two decls with the same name but different
43/// kinds are treated as unrelated (Delete + Insert rather than Modified).
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
45pub enum DeclKind {
46    /// `def` / `noncomputable def`
47    Definition,
48    /// `theorem` / `lemma`
49    Theorem,
50    /// `axiom`
51    Axiom,
52    /// `inductive`
53    Inductive,
54    /// `structure`
55    Structure,
56    /// `class`
57    Class,
58    /// `instance`
59    Instance,
60    /// `namespace … end`
61    Namespace,
62    /// `section … end`
63    Section,
64    /// `import`
65    Import,
66    /// `variable`
67    Variable,
68    /// `open`
69    Open,
70    /// `attribute`
71    Attribute,
72    /// `#check`, `#eval`, `#print`, …
73    HashCmd,
74    /// `mutual { … }`
75    Mutual,
76    /// `deriving`
77    Derive,
78    /// `notation` / `infixl` / `infixr` / …
79    Notation,
80    /// `universe`
81    Universe,
82    /// Any other / anonymous declaration
83    Other,
84}
85
86impl DeclKind {
87    /// Derive the `DeclKind` from a `Decl` value.
88    pub fn of(decl: &Decl) -> Self {
89        match decl {
90            Decl::Definition { .. } => DeclKind::Definition,
91            Decl::Theorem { .. } => DeclKind::Theorem,
92            Decl::Axiom { .. } => DeclKind::Axiom,
93            Decl::Inductive { .. } => DeclKind::Inductive,
94            Decl::Structure { .. } => DeclKind::Structure,
95            Decl::ClassDecl { .. } => DeclKind::Class,
96            Decl::InstanceDecl { .. } => DeclKind::Instance,
97            Decl::Namespace { .. } => DeclKind::Namespace,
98            Decl::SectionDecl { .. } => DeclKind::Section,
99            Decl::Import { .. } => DeclKind::Import,
100            Decl::Variable { .. } => DeclKind::Variable,
101            Decl::Open { .. } => DeclKind::Open,
102            Decl::Attribute { .. } => DeclKind::Attribute,
103            Decl::HashCmd { .. } => DeclKind::HashCmd,
104            Decl::Mutual { .. } => DeclKind::Mutual,
105            Decl::Derive { .. } => DeclKind::Derive,
106            Decl::NotationDecl { .. } => DeclKind::Notation,
107            Decl::Universe { .. } => DeclKind::Universe,
108        }
109    }
110}
111
112// ── DeclFingerprint ──────────────────────────────────────────────────────────
113
114/// A stable, span-independent identity hash for a top-level declaration.
115///
116/// Two declarations with the same `DeclFingerprint` are structurally identical
117/// modulo source positions. The `body_hash` is computed over the `Debug`
118/// representation of the inner `Decl` value (not the surrounding `Located<_>`),
119/// so spans do not contribute to the hash.
120#[derive(Debug, Clone, PartialEq, Eq, Hash)]
121pub struct DeclFingerprint {
122    /// Declaration name (empty string for anonymous or unnamed decls).
123    pub name: String,
124    /// Coarse kind of the declaration.
125    pub kind: DeclKind,
126    /// FNV-1a hash of `format!("{:?}", located_decl.value)`.
127    ///
128    /// Because we hash `.value` (not the `Located` wrapper), the span is not
129    /// included in the hash.
130    pub body_hash: u64,
131}
132
133impl DeclFingerprint {
134    /// Compute the fingerprint of a located declaration.
135    ///
136    /// The body hash is derived from the pretty-printed representation of the
137    /// inner `Decl` value via `print_decl`. This is truly span-independent
138    /// because `print_decl` takes `&Decl` (not `&Located<Decl>`) and does not
139    /// print any source positions.
140    ///
141    /// Using `format!("{:?}", decl.value)` would be incorrect here because
142    /// `Decl` contains `Located<SurfaceExpr>` sub-expressions whose `Debug`
143    /// output includes span information, making the hash span-dependent.
144    pub fn of(decl: &Located<Decl>) -> Self {
145        let kind = DeclKind::of(&decl.value);
146        let name = decl.value.name().unwrap_or("").to_owned();
147        // Use the pretty-printer which operates on `&Decl` (no span data).
148        let repr = print_decl(&decl.value);
149        let body_hash = fnv1a_hash(repr.as_bytes());
150        DeclFingerprint {
151            name,
152            kind,
153            body_hash,
154        }
155    }
156
157    /// Returns `true` if `self` and `other` have the same name and kind but
158    /// a different body hash — i.e. the body was modified.
159    pub fn is_modified_version_of(&self, other: &DeclFingerprint) -> bool {
160        self.name == other.name && self.kind == other.kind && self.body_hash != other.body_hash
161    }
162}
163
164// ── DeclEdit ─────────────────────────────────────────────────────────────────
165
166/// The kind of change that affected a declaration.
167#[derive(Debug, Clone, PartialEq, Eq)]
168pub enum EditKind {
169    /// The declaration is unchanged between old and new.
170    Unchanged,
171    /// The declaration was inserted in the new sequence (not present in old).
172    Inserted,
173    /// The declaration was deleted from the old sequence (not present in new).
174    Deleted,
175    /// Same name + kind but the body changed.
176    Modified,
177}
178
179/// A single entry in the diff edit script produced by [`diff_modules`].
180#[derive(Debug, Clone)]
181pub struct DeclEdit {
182    /// Kind of change.
183    pub kind: EditKind,
184    /// Index in the **old** declaration sequence, or `None` for `Inserted`.
185    pub old_idx: Option<usize>,
186    /// Index in the **new** declaration sequence, or `None` for `Deleted`.
187    pub new_idx: Option<usize>,
188    /// Fingerprint of the "surviving" declaration:
189    /// - `Unchanged`, `Inserted`, `Modified` → fingerprint of the new decl.
190    /// - `Deleted` → fingerprint of the old decl.
191    pub fingerprint: DeclFingerprint,
192}
193
194// ── Myers O(ND) LCS diff ─────────────────────────────────────────────────────
195
196/// Compare two slices of located declarations and produce an edit script.
197///
198/// The algorithm is the classic Myers O(ND) diff over the fingerprint sequences.
199/// After computing the LCS, consecutive `Deleted`/`Inserted` pairs that share
200/// the same name and kind are folded into a single `Modified` edit.
201///
202/// # Complexity
203///
204/// Time: O(N·D) where N = max(|old|, |new|) and D = edit distance.
205/// Space: O(N + D²) for the DP frontier.
206///
207/// # Output invariants
208///
209/// - Every old index appears in exactly one edit with `old_idx = Some(_)`.
210/// - Every new index appears in exactly one edit with `new_idx = Some(_)`.
211/// - `Unchanged` and `Deleted` counts sum to `old.len()`.
212/// - `Unchanged` and `Inserted` counts sum to `new.len()`.
213pub fn diff_modules(old: &[Located<Decl>], new: &[Located<Decl>]) -> Vec<DeclEdit> {
214    let old_fps: Vec<DeclFingerprint> = old.iter().map(DeclFingerprint::of).collect();
215    let new_fps: Vec<DeclFingerprint> = new.iter().map(DeclFingerprint::of).collect();
216
217    let raw = myers_diff(&old_fps, &new_fps);
218    merge_modified(raw)
219}
220
221/// Core Myers O(ND) diff returning raw `Deleted`/`Inserted`/`Unchanged` edits.
222///
223/// Implements the Myers algorithm from "An O(ND) Difference Algorithm and Its
224/// Variations" (Myers, 1986) using the standard trace-and-backtrack approach.
225///
226/// Convention: x = index into `old`, y = index into `new`, diagonal k = x − y.
227/// v[k + offset] = furthest-reaching x on diagonal k after d non-diagonal moves.
228///
229/// This does **not** detect `Modified`; that is handled by the post-processing
230/// step in `merge_modified`.
231fn myers_diff(old: &[DeclFingerprint], new: &[DeclFingerprint]) -> Vec<DeclEdit> {
232    let n = old.len();
233    let m = new.len();
234
235    // Fast path: both empty.
236    if n == 0 && m == 0 {
237        return Vec::new();
238    }
239
240    // Fast path: old is empty → everything is Inserted.
241    if n == 0 {
242        return new
243            .iter()
244            .enumerate()
245            .map(|(j, fp)| DeclEdit {
246                kind: EditKind::Inserted,
247                old_idx: None,
248                new_idx: Some(j),
249                fingerprint: fp.clone(),
250            })
251            .collect();
252    }
253
254    // Fast path: new is empty → everything is Deleted.
255    if m == 0 {
256        return old
257            .iter()
258            .enumerate()
259            .map(|(i, fp)| DeclEdit {
260                kind: EditKind::Deleted,
261                old_idx: Some(i),
262                new_idx: None,
263                fingerprint: fp.clone(),
264            })
265            .collect();
266    }
267
268    let max_d = n + m;
269    let offset = max_d as isize; // v[k + offset] for diagonal k
270    let size = 2 * max_d + 2; // +2 for sentinel at k=+1 boundary
271
272    // v[k + offset] = furthest x on diagonal k.
273    // Standard Myers sentinel: v[offset + 1] = 0 handles the d=0, k=0 case
274    // where the loop selects "come from k+1" (insert path).
275    let mut v: Vec<isize> = vec![0_isize; size];
276
277    // trace[d] = snapshot of v AFTER completing step d.
278    let mut trace: Vec<Vec<isize>> = Vec::with_capacity(max_d + 1);
279    let mut found = false;
280
281    'outer: for d in 0..=(max_d as isize) {
282        let mut k = -d;
283        while k <= d {
284            let ki = (k + offset) as usize;
285
286            // Determine x by choosing the better prior diagonal.
287            // "Insert" = came from diagonal k+1 (x unchanged, y+1).
288            // "Delete" = came from diagonal k-1 (x+1, y unchanged).
289            let mut x: isize = if k == -d || (k != d && v[ki + 1] > v[ki - 1]) {
290                // Come from k+1 (insert).
291                v[ki + 1]
292            } else {
293                // Come from k-1 (delete).
294                v[ki - 1] + 1
295            };
296            let mut y: isize = x - k;
297
298            // Advance along the snake (matching elements).
299            while x < n as isize && y < m as isize && old[x as usize] == new[y as usize] {
300                x += 1;
301                y += 1;
302            }
303            v[ki] = x;
304
305            if x >= n as isize && y >= m as isize {
306                // Record the snapshot at this step d before breaking.
307                trace.push(v.clone());
308                found = true;
309                break 'outer;
310            }
311            k += 2;
312        }
313        // Snapshot after each complete step d.
314        trace.push(v.clone());
315    }
316
317    if !found {
318        // Fallback: should not happen for finite inputs; return empty.
319        return Vec::new();
320    }
321
322    // Backtrack from (n, m) to (0, 0) using the trace.
323    // trace[d] = v after step d.
324    backtrack_myers(old, new, &trace, offset)
325}
326
327/// Reconstruct the edit script by walking the trace backward from the endpoint.
328///
329/// For each step d (from the last down to 1), we look at `trace[d-1]` (the
330/// v-array after step d-1, before step d) to determine whether the move at
331/// step d was a delete (right) or insert (down). We then emit the snake
332/// (Unchanged) and the single non-snake edit.
333fn backtrack_myers(
334    old: &[DeclFingerprint],
335    new: &[DeclFingerprint],
336    trace: &[Vec<isize>],
337    offset: isize,
338) -> Vec<DeclEdit> {
339    let n = old.len() as isize;
340    let m = new.len() as isize;
341
342    let mut x = n;
343    let mut y = m;
344    let mut edits: Vec<DeclEdit> = Vec::new();
345
346    // d ranges from trace.len()-1 down to 1 (step 0 has no non-snake edit).
347    // `trace[d]` = v after step d.
348    // When going from step d-1 to d, we use `trace[d-1]` to decide direction.
349    for d in (1..trace.len()).rev() {
350        let v_prev = &trace[d - 1];
351        let k = x - y;
352        let ki = (k + offset) as usize;
353
354        // Which diagonal did we come from at step d?
355        // Must use the same condition as the forward pass to be consistent.
356        let came_from_insert = if k == -(d as isize) {
357            true // forced: only diagonal k+1 was valid
358        } else if k == d as isize {
359            false // forced: only diagonal k-1 was valid
360        } else {
361            // Same rule as forward pass: insert if v[ki+1] > v[ki-1]
362            v_prev[ki + 1] > v_prev[ki - 1]
363        };
364
365        // The endpoint BEFORE the non-snake edit at step d.
366        let (mid_x, mid_y) = if came_from_insert {
367            // Came from diagonal k+1: x unchanged, y decreased by 1.
368            let px = v_prev[ki + 1];
369            let py = px - (k + 1);
370            (px, py)
371        } else {
372            // Came from diagonal k-1: x decreased by 1.
373            let px = v_prev[ki - 1];
374            let py = px - (k - 1);
375            (px, py)
376        };
377
378        // The snake runs from (mid_x + delta_x, mid_y + delta_y) to (x, y).
379        // After the non-snake move, we are at:
380        let (after_x, after_y) = if came_from_insert {
381            (mid_x, mid_y + 1) // insert advances y by 1
382        } else {
383            (mid_x + 1, mid_y) // delete advances x by 1
384        };
385
386        // Emit snake (Unchanged) edits — backward order, we reverse later.
387        // Snake: from (after_x, after_y) to (x, y), all matching.
388        let mut sx = x - 1;
389        let mut sy = y - 1;
390        while sx >= after_x && sy >= after_y {
391            edits.push(DeclEdit {
392                kind: EditKind::Unchanged,
393                old_idx: Some(sx as usize),
394                new_idx: Some(sy as usize),
395                fingerprint: new[sy as usize].clone(),
396            });
397            sx -= 1;
398            sy -= 1;
399        }
400
401        // Emit the non-snake edit.
402        if came_from_insert {
403            // Insert: new[mid_y] was inserted (y moves from mid_y to mid_y+1).
404            if mid_y >= 0 && mid_y < m {
405                edits.push(DeclEdit {
406                    kind: EditKind::Inserted,
407                    old_idx: None,
408                    new_idx: Some(mid_y as usize),
409                    fingerprint: new[mid_y as usize].clone(),
410                });
411            }
412        } else {
413            // Delete: old[mid_x] was deleted (x moves from mid_x to mid_x+1).
414            if mid_x >= 0 && mid_x < n {
415                edits.push(DeclEdit {
416                    kind: EditKind::Deleted,
417                    old_idx: Some(mid_x as usize),
418                    new_idx: None,
419                    fingerprint: old[mid_x as usize].clone(),
420                });
421            }
422        }
423
424        x = mid_x;
425        y = mid_y;
426    }
427
428    // Emit any remaining snake at d=0 (prefix that matched from the start).
429    // At d=0 there are no non-snake edits; just the initial diagonal run.
430    let mut sx = x - 1;
431    let mut sy = y - 1;
432    while sx >= 0 && sy >= 0 {
433        edits.push(DeclEdit {
434            kind: EditKind::Unchanged,
435            old_idx: Some(sx as usize),
436            new_idx: Some(sy as usize),
437            fingerprint: new[sy as usize].clone(),
438        });
439        sx -= 1;
440        sy -= 1;
441    }
442
443    edits.reverse();
444    edits
445}
446
447/// Post-process raw `Deleted`/`Inserted`/`Unchanged` edits: fold consecutive
448/// `Deleted` + `Inserted` pairs that share the same `name` and `kind` into a
449/// single `Modified` edit.
450///
451/// The strategy: collect all Deleted edits and all Inserted edits by (name, kind)
452/// into lookup tables, then scan the output and match them up.
453fn merge_modified(raw: Vec<DeclEdit>) -> Vec<DeclEdit> {
454    use std::collections::HashMap;
455
456    // Index deleted edits by (name, kind) → queue of (position-in-raw, DeclEdit).
457    let mut deleted_by_key: HashMap<
458        (String, String),
459        std::collections::VecDeque<(usize, DeclEdit)>,
460    > = HashMap::new();
461
462    for (pos, edit) in raw.iter().enumerate() {
463        if edit.kind == EditKind::Deleted {
464            let key = (
465                edit.fingerprint.name.clone(),
466                format!("{:?}", edit.fingerprint.kind),
467            );
468            deleted_by_key
469                .entry(key)
470                .or_default()
471                .push_back((pos, edit.clone()));
472        }
473    }
474
475    // Index inserted edits similarly.
476    let mut inserted_by_key: HashMap<
477        (String, String),
478        std::collections::VecDeque<(usize, DeclEdit)>,
479    > = HashMap::new();
480
481    for (pos, edit) in raw.iter().enumerate() {
482        if edit.kind == EditKind::Inserted {
483            let key = (
484                edit.fingerprint.name.clone(),
485                format!("{:?}", edit.fingerprint.kind),
486            );
487            inserted_by_key
488                .entry(key)
489                .or_default()
490                .push_back((pos, edit.clone()));
491        }
492    }
493
494    // Build a set of positions to suppress (they get replaced by Modified).
495    let mut suppressed: std::collections::HashSet<usize> = std::collections::HashSet::new();
496    // Modified edits: (position where they should be inserted, DeclEdit).
497    // We use the position of the Deleted edit as the insertion point.
498    let mut modified_inserts: HashMap<usize, DeclEdit> = HashMap::new();
499
500    for ((name, kind_s), del_queue) in deleted_by_key.iter_mut() {
501        let key = (name.clone(), kind_s.clone());
502        if let Some(ins_queue) = inserted_by_key.get_mut(&key) {
503            // Pair up deleted and inserted edits, in FIFO order.
504            while !del_queue.is_empty() && !ins_queue.is_empty() {
505                let (del_pos, del_edit) = del_queue.pop_front().expect("non-empty");
506                let (ins_pos, ins_edit) = ins_queue.pop_front().expect("non-empty");
507
508                // Only merge if they are actually a body-change (body hashes differ).
509                if del_edit.fingerprint.body_hash != ins_edit.fingerprint.body_hash {
510                    suppressed.insert(del_pos);
511                    suppressed.insert(ins_pos);
512                    modified_inserts.insert(
513                        del_pos,
514                        DeclEdit {
515                            kind: EditKind::Modified,
516                            old_idx: del_edit.old_idx,
517                            new_idx: ins_edit.new_idx,
518                            fingerprint: ins_edit.fingerprint.clone(),
519                        },
520                    );
521                }
522            }
523        }
524    }
525
526    // Assemble the final edit list, respecting original order.
527    let mut result: Vec<DeclEdit> = Vec::with_capacity(raw.len());
528    for (pos, edit) in raw.into_iter().enumerate() {
529        if suppressed.contains(&pos) {
530            // If there is a Modified replacement at this position, emit it.
531            if let Some(modified) = modified_inserts.remove(&pos) {
532                result.push(modified);
533            }
534            // Otherwise skip (the paired Inserted half is suppressed too).
535        } else {
536            result.push(edit);
537        }
538    }
539    result
540}
541
542// ── Tests ────────────────────────────────────────────────────────────────────
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use crate::parser::functions::parse_decls;
548
549    /// Parse a Lean snippet into a Vec<Located<Decl>>.
550    ///
551    /// We cannot use `parse_decls` directly because its EOF detection uses
552    /// `ParseErrorKind::UnexpectedEof`, but the parser's catch-all arm produces
553    /// `ParseErrorKind::UnexpectedToken { got: Eof }` when the input is exhausted.
554    /// This helper drives the parser loop manually and treats any error at EOF
555    /// (including `UnexpectedToken { got: Eof }`) as a termination signal.
556    fn parse_lean(src: &str) -> Vec<Located<Decl>> {
557        use crate::lexer::Lexer;
558        use crate::parser_impl::Parser;
559        use crate::tokens::TokenKind;
560
561        let tokens = Lexer::new(src).tokenize();
562        let mut parser = Parser::new(tokens);
563        let mut decls = Vec::new();
564        loop {
565            // If already at EOF, stop before even trying to parse.
566            if parser.is_eof() {
567                break;
568            }
569            match parser.parse_decl() {
570                Ok(d) => decls.push(d),
571                Err(e) => {
572                    // Stop on any EOF-related error (both UnexpectedEof and
573                    // UnexpectedToken { got: Eof } from the catch-all arm).
574                    let is_eof_err = e.is_eof()
575                        || matches!(
576                            e.message().as_str(),
577                            s if s.contains("EOF") || s.contains("Eof") || s.contains("end of file")
578                        );
579                    if is_eof_err || parser.is_eof() {
580                        break;
581                    }
582                    // Non-EOF parse error: advance one token and continue
583                    // (best-effort; in tests we expect well-formed input).
584                    parser.advance();
585                }
586            }
587        }
588        decls
589    }
590
591    // ── 1. Identical source → all Unchanged ──────────────────────────────────
592
593    #[test]
594    fn test_diff_identical() {
595        let src = "def foo : Nat := 0\ndef bar : Nat := 1";
596        let old = parse_lean(src);
597        let new = parse_lean(src);
598        let edits = diff_modules(&old, &new);
599        assert!(
600            edits.iter().all(|e| e.kind == EditKind::Unchanged),
601            "identical sources should produce only Unchanged edits, got: {edits:?}"
602        );
603        assert_eq!(edits.len(), old.len());
604    }
605
606    // ── 2. Inserted declaration ───────────────────────────────────────────────
607
608    #[test]
609    fn test_diff_insert_decl() {
610        let old_src = "def foo : Nat := 0";
611        let new_src = "def foo : Nat := 0\ndef bar : Nat := 1";
612        let old = parse_lean(old_src);
613        let new = parse_lean(new_src);
614        let edits = diff_modules(&old, &new);
615
616        let unchanged: Vec<_> = edits
617            .iter()
618            .filter(|e| e.kind == EditKind::Unchanged)
619            .collect();
620        let inserted: Vec<_> = edits
621            .iter()
622            .filter(|e| e.kind == EditKind::Inserted)
623            .collect();
624
625        assert_eq!(unchanged.len(), 1, "foo should be Unchanged");
626        assert_eq!(inserted.len(), 1, "bar should be Inserted");
627        assert_eq!(inserted[0].fingerprint.name, "bar");
628    }
629
630    // ── 3. Deleted declaration ────────────────────────────────────────────────
631
632    #[test]
633    fn test_diff_delete_decl() {
634        let old_src = "def a : Nat := 0\ndef b : Nat := 1\ndef c : Nat := 2";
635        let new_src = "def a : Nat := 0\ndef c : Nat := 2";
636        let old = parse_lean(old_src);
637        let new = parse_lean(new_src);
638        let edits = diff_modules(&old, &new);
639
640        let deleted: Vec<_> = edits
641            .iter()
642            .filter(|e| e.kind == EditKind::Deleted)
643            .collect();
644        let unchanged: Vec<_> = edits
645            .iter()
646            .filter(|e| e.kind == EditKind::Unchanged)
647            .collect();
648
649        assert_eq!(deleted.len(), 1, "b should be Deleted");
650        assert_eq!(deleted[0].fingerprint.name, "b");
651        assert_eq!(unchanged.len(), 2, "a and c should be Unchanged");
652
653        // Verify coverage: every old index appears exactly once.
654        let mut seen_old: Vec<bool> = vec![false; old.len()];
655        for edit in &edits {
656            if let Some(i) = edit.old_idx {
657                assert!(!seen_old[i], "old index {i} appears more than once");
658                seen_old[i] = true;
659            }
660        }
661        assert!(seen_old.iter().all(|&b| b), "not all old indices covered");
662    }
663
664    // ── 4. Modified declaration (same name+kind, different body) ─────────────
665
666    #[test]
667    fn test_diff_modified_decl() {
668        let old_src = "def foo : Nat := 0";
669        let new_src = "def foo : Nat := 99";
670        let old = parse_lean(old_src);
671        let new = parse_lean(new_src);
672        let edits = diff_modules(&old, &new);
673
674        let modified: Vec<_> = edits
675            .iter()
676            .filter(|e| e.kind == EditKind::Modified)
677            .collect();
678        assert_eq!(modified.len(), 1, "foo body change should be Modified");
679        assert_eq!(modified[0].fingerprint.name, "foo");
680        assert_eq!(modified[0].old_idx, Some(0));
681        assert_eq!(modified[0].new_idx, Some(0));
682    }
683
684    // ── 5. Reorder two declarations ───────────────────────────────────────────
685
686    #[test]
687    fn test_diff_reorder() {
688        let old_src = "def a : Nat := 0\ndef b : Nat := 1";
689        let new_src = "def b : Nat := 1\ndef a : Nat := 0";
690        let old = parse_lean(old_src);
691        let new = parse_lean(new_src);
692        let edits = diff_modules(&old, &new);
693
694        // Reordering must be representable: at least one Unchanged, or both as
695        // Insert+Delete (Myers picks the minimal edit).
696        let total_old_coverage: usize = edits.iter().filter(|e| e.old_idx.is_some()).count();
697        let total_new_coverage: usize = edits.iter().filter(|e| e.new_idx.is_some()).count();
698        assert_eq!(
699            total_old_coverage,
700            old.len(),
701            "all old indices must be covered"
702        );
703        assert_eq!(
704            total_new_coverage,
705            new.len(),
706            "all new indices must be covered"
707        );
708
709        // The edit script must be valid: applying it should reconstruct `new`.
710        let reconstructed = apply_edit_script(&old, &new, &edits);
711        assert_eq!(reconstructed.len(), new.len());
712    }
713
714    // ── 6. Both empty → empty edit list ──────────────────────────────────────
715
716    #[test]
717    fn test_diff_empty() {
718        let edits = diff_modules(&[], &[]);
719        assert!(
720            edits.is_empty(),
721            "diffing empty sequences should produce no edits"
722        );
723    }
724
725    // ── 7. Coverage invariant property ───────────────────────────────────────
726
727    #[test]
728    fn test_index_coverage_invariant() {
729        let old_src = "def a : Nat := 0\ntheorem t : True := trivial\ndef b : Nat := 2";
730        let new_src = "def a : Nat := 0\ndef b : Nat := 99\ndef c : Nat := 3";
731        let old = parse_lean(old_src);
732        let new = parse_lean(new_src);
733        let edits = diff_modules(&old, &new);
734
735        // Every old index appears exactly once.
736        let mut old_seen = vec![false; old.len()];
737        for edit in &edits {
738            if let Some(i) = edit.old_idx {
739                assert!(!old_seen[i], "old_idx {i} appears twice");
740                old_seen[i] = true;
741            }
742        }
743        assert!(
744            old_seen.iter().all(|&b| b),
745            "some old indices not covered: {:?}",
746            old_seen
747        );
748
749        // Every new index appears exactly once.
750        let mut new_seen = vec![false; new.len()];
751        for edit in &edits {
752            if let Some(j) = edit.new_idx {
753                assert!(!new_seen[j], "new_idx {j} appears twice");
754                new_seen[j] = true;
755            }
756        }
757        assert!(
758            new_seen.iter().all(|&b| b),
759            "some new indices not covered: {:?}",
760            new_seen
761        );
762    }
763
764    // ── Helper: reconstruct new sequence from old + edit script ──────────────
765
766    /// Apply an edit script to reconstruct the new declaration sequence.
767    /// Used as a validity oracle in tests.
768    fn apply_edit_script<'a>(
769        old: &'a [Located<Decl>],
770        new: &'a [Located<Decl>],
771        edits: &[DeclEdit],
772    ) -> Vec<&'a Located<Decl>> {
773        let mut result = Vec::new();
774        for edit in edits {
775            match edit.kind {
776                EditKind::Unchanged | EditKind::Modified => {
777                    // Take from new (the surviving version).
778                    if let Some(j) = edit.new_idx {
779                        result.push(&new[j]);
780                    }
781                }
782                EditKind::Inserted => {
783                    if let Some(j) = edit.new_idx {
784                        result.push(&new[j]);
785                    }
786                }
787                EditKind::Deleted => {
788                    // Deleted: verify it came from old.
789                    if let Some(i) = edit.old_idx {
790                        let _ = &old[i]; // just assert in-bounds
791                    }
792                }
793            }
794        }
795        result
796    }
797}