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}