quillmark_content/delta.rs
1//! The per-field edit surface: a [`Delta`] of text splices over the USV content,
2//! plus the **stale-text writer** path — cold-parse a full new markdown document,
3//! char-diff it against the base, and rebase the base's identity marks
4//! (anchors/comments) through the diff so annotations survive an LLM
5//! full-document rewrite with no preservation contract on the LLM.
6//!
7//! ## Text splices, not attributed ops
8//!
9//! [`Delta`] is `retain` / `insert` / `delete` over the character sequence —
10//! CodeMirror `ChangeSet` / OT text semantics, **not** Quill-Delta. It carries
11//! no formatting attributes: marks and islands are separate `(range, kind)` data
12//! that *rebase through* a delta ([`Delta::map_pos`]), they do not ride it as op
13//! attributes. This is deliberate — an attribute map is a per-character property
14//! map and cannot represent overlapping same-kind marks or two distinct
15//! identity anchors over one range, the exact algebra the content model keeps
16//! (Peritext free overlap + identity handles). Editing marks and line/block
17//! attributes are their own op channels, not attributes on this delta. The
18//! positional channel stays isomorphic to a text CRDT's op stream — the shape
19//! real-time collaborative editing would need.
20//!
21//! [`diff`] computes a Myers/LCS minimal edit script and pairs it with a
22//! **move detector** that re-homes an anchor across a verbatim block move.
23//! Position mapping ([`Delta::map_pos`]) follows CodeMirror's
24//! `ChangeDesc.mapPos` / ProseMirror mapping semantics. Anchoring a captured
25//! position across edits is the editor's job (its own transaction mapping); the
26//! content carries no session-side change log.
27//!
28//! ## The move weak spot (documented limit)
29//!
30//! A paragraph reorder is delete-here + insert-there to any char differ, so a
31//! naive rebase collapses an anchor in the moved text to the deletion point. The
32//! detector re-homes an anchor onto a **single, verbatim block move** by locating
33//! the moved text in the new content. Text both *moved and rewritten* in one round
34//! (the match is lost) drops the anchor — the accepted residual, stated not
35//! hidden. Tightening verbatim → fuzzy (longest-common-substring) is a hardening
36//! follow-up.
37
38use crate::model::{Mark, MarkKind, Content};
39use serde::{Deserialize, Serialize};
40use similar::{ChangeTag, TextDiff};
41
42/// A per-field edit against a base content. Ops apply left-to-right, consuming
43/// base positions; `Retain`/`Delete` advance the base cursor, `Insert` adds new
44/// text. USV throughout.
45///
46/// Serializes as `{ "ops": [ {"retain": n} | {"insert": s} | {"delete": n} ] }`
47/// — plain, structured-clone-able data an editor bridge stores in a change
48/// record and maps its own positions through ([`map_pos`](Self::map_pos)). The
49/// serde shape is the wire the `rebase` codec and `applyChange` bundle carry
50/// across the language bindings.
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Delta {
53 pub ops: Vec<Op>,
54}
55
56/// One delta operation. Serializes externally-tagged with a lowercase key
57/// (`{"retain": 5}`, `{"insert": "x"}`, `{"delete": 2}`).
58#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "lowercase")]
60pub enum Op {
61 /// Keep `n` chars of the base unchanged.
62 Retain(usize),
63 /// Insert this text at the cursor.
64 Insert(String),
65 /// Drop `n` chars of the base.
66 Delete(usize),
67}
68
69/// Which side of a same-position insertion a mapped point lands on. Serializes
70/// as `"before"` / `"after"`.
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
72#[serde(rename_all = "lowercase")]
73pub enum Assoc {
74 /// Stay before inserted text.
75 Before,
76 /// Move after inserted text.
77 After,
78}
79
80/// A delta's expected base length disagreed with the text it was applied to —
81/// the delta was built against a different revision of the base.
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub struct BaseLengthMismatch {
84 pub expected: usize,
85 pub actual: usize,
86}
87
88impl Delta {
89 /// Chars of base the `Retain`/`Delete` ops together consume — the base
90 /// length this delta was built against.
91 pub fn expected_base_len(&self) -> usize {
92 self.ops
93 .iter()
94 .map(|op| match op {
95 Op::Retain(n) | Op::Delete(n) => *n,
96 Op::Insert(_) => 0,
97 })
98 .sum()
99 }
100
101 /// Apply to `base`, producing the new text. Base beyond what the ops
102 /// consume is retained implicitly — a *short* delta names only the region
103 /// it changes (a bare prepend, an edit near the start) and the untouched
104 /// remainder carries through. **Panics** if the ops consume *more* base than
105 /// exists (`expected_base_len() > base.chars().count()`): a delta built
106 /// against a longer revision. This is the trusted-provenance path; clamping
107 /// an over-long delta silently is corruption, so where the base's provenance
108 /// isn't already trusted use [`Self::try_apply`], which returns the mismatch
109 /// as an error instead.
110 pub fn apply(&self, base: &str) -> String {
111 let chars: Vec<char> = base.chars().collect();
112 let mut out = String::new();
113 let mut i = 0usize;
114 for op in &self.ops {
115 match op {
116 // Over-long Retain/Delete index past `chars` and panic here —
117 // the intended failure on a wrong-revision base.
118 Op::Retain(n) => {
119 out.extend(&chars[i..i + n]);
120 i += n;
121 }
122 Op::Delete(n) => i += n,
123 Op::Insert(s) => out.push_str(s),
124 }
125 }
126 out.extend(&chars[i..]);
127 out
128 }
129
130 /// [`Self::apply`], but returns [`BaseLengthMismatch`] instead of panicking
131 /// when the ops consume *more* base than `base` has — a delta built against
132 /// a longer revision. Implicit trailing retain is the contract: a *short*
133 /// delta (ops consuming less than `base`) is accepted and the untouched
134 /// remainder is retained, matching [`map_pos`](Self::map_pos)'s implicit
135 /// trailing retain so a producer that names only the changed region need not
136 /// pad a bare trailing [`Op::Retain`].
137 ///
138 /// Cost of the leniency: a short delta carries no full-base-length check, so
139 /// one replayed against a wrong but *longer* base applies silently instead
140 /// of failing. An abbreviated delta forfeits that tripwire by construction;
141 /// over-consumption still fails.
142 pub fn try_apply(&self, base: &str) -> Result<String, BaseLengthMismatch> {
143 let expected = self.expected_base_len();
144 let actual = base.chars().count();
145 if expected > actual {
146 return Err(BaseLengthMismatch { expected, actual });
147 }
148 Ok(self.apply(base))
149 }
150
151 /// Map a base char position to its new position. `assoc` decides the side of
152 /// a same-position insertion (`After` moves past it).
153 pub fn map_pos(&self, pos: usize, assoc: Assoc) -> usize {
154 let mut old = 0usize;
155 let mut new = 0usize;
156 for op in &self.ops {
157 match op {
158 Op::Retain(n) => {
159 // Strictly inside the retain resolves here; the right
160 // boundary (pos == old + n) falls through, so a following
161 // Insert can apply its `assoc`.
162 if pos < old + n {
163 return new + (pos - old);
164 }
165 old += n;
166 new += n;
167 }
168 Op::Delete(n) => {
169 if pos < old + n {
170 // Inside (or at the start of) the deletion — collapse to
171 // the deletion point.
172 return new;
173 }
174 old += n;
175 }
176 Op::Insert(s) => {
177 let len = s.chars().count();
178 if pos == old {
179 match assoc {
180 Assoc::Before => return new,
181 Assoc::After => new += len, // fall through past insert
182 }
183 } else {
184 new += len;
185 }
186 }
187 }
188 }
189 new + pos.saturating_sub(old)
190 }
191
192 /// Whether base position `pos` sits strictly inside a deleted span. The
193 /// deletion's left edge (`pos == old`) survives — a point anchor there stays
194 /// put — so only `old < pos < old + n` counts as deleted.
195 fn is_deleted(&self, pos: usize) -> bool {
196 let mut old = 0usize;
197 for op in &self.ops {
198 match op {
199 Op::Retain(n) => old += n,
200 Op::Delete(n) => {
201 if pos > old && pos < old + n {
202 return true;
203 }
204 old += n;
205 }
206 Op::Insert(_) => {}
207 }
208 }
209 false
210 }
211
212 /// New-text char ranges covered by `Insert` ops — the only regions an anchor
213 /// may be re-homed into (moved text must have been *inserted*, not merely
214 /// present in surviving text elsewhere).
215 fn inserted_spans(&self) -> Vec<(usize, usize)> {
216 let mut spans = Vec::new();
217 let mut new = 0usize;
218 for op in &self.ops {
219 match op {
220 Op::Retain(n) => new += n,
221 Op::Insert(s) => {
222 let len = s.chars().count();
223 if len > 0 {
224 spans.push((new, new + len));
225 }
226 new += len;
227 }
228 Op::Delete(_) => {}
229 }
230 }
231 spans
232 }
233}
234
235/// A relocation match shorter than this many chars is too weak to trust — the
236/// verbatim-move detector's length floor (mirrors the spike's `MIN_MOVE`).
237const MIN_MOVE: usize = 4;
238
239/// Above this many USV chars, the single-line path skips `similar`'s
240/// char-level Myers diff and falls back to [`coarse_replace`] (issue #849).
241/// `TextDiff::from_chars` is O(N·D) with no deadline; on two long, unrelated
242/// single-line strings (no newlines to fall back to line granularity — the
243/// realistic shape of an LLM full-document rewrite) D grows with N, so cost
244/// is effectively quadratic. Two unrelated 30,000-char lines measured 86s in
245/// a debug build. This threshold sits comfortably below that (6x headroom)
246/// while still covering a real single-paragraph field, which plausibly runs
247/// to a few thousand chars. A fixed cutoff was chosen over
248/// `TextDiffConfig::timeout` — nothing in this crate uses `TextDiffConfig`
249/// today, and a char budget is deterministic (no wall-clock flakiness in
250/// CI, no partial-diff result to reason about).
251const CHAR_DIFF_LIMIT: usize = 5_000;
252
253/// Char-level Myers/LCS diff over USV: a minimal `Retain` / `Delete` / `Insert`
254/// script. Disjoint edits stay separate ops rather than collapsing the span
255/// between them into one delete+insert, so anchors sitting in unchanged middle
256/// text survive rebase without relying on the move detector.
257///
258/// Single-line text diffs at char granularity; multi-line text diffs at line
259/// granularity so a paragraph reorder surfaces as whole-line insert spans the
260/// move detector can match (char Myers fragments reordered blocks). Above
261/// `CHAR_DIFF_LIMIT` chars, the single-line path skips Myers entirely and
262/// uses `coarse_replace` instead.
263pub fn diff(base: &str, new: &str) -> Delta {
264 let multiline = base.contains('\n') || new.contains('\n');
265 if !multiline
266 && (base.chars().count() > CHAR_DIFF_LIMIT || new.chars().count() > CHAR_DIFF_LIMIT)
267 {
268 return coarse_replace(base, new);
269 }
270 let text_diff = if multiline {
271 TextDiff::from_lines(base, new)
272 } else {
273 TextDiff::from_chars(base, new)
274 };
275 let mut ops = Vec::new();
276 for change in text_diff.iter_all_changes() {
277 match change.tag() {
278 ChangeTag::Equal => push_retain(&mut ops, change.value().chars().count()),
279 ChangeTag::Delete => push_delete(&mut ops, change.value().chars().count()),
280 ChangeTag::Insert => push_insert(&mut ops, change.value()),
281 }
282 }
283 Delta { ops }
284}
285
286/// Linear-time fallback for [`diff`] above [`CHAR_DIFF_LIMIT`]: trims the
287/// longest common prefix and suffix (plain char comparison, no Myers) and
288/// replaces only the middle. Not a minimal edit script, but still useful for
289/// anchor rebasing — an anchor sitting in the untouched prefix or suffix maps
290/// through a real `Retain` exactly as it would from a full diff; only an
291/// anchor inside the replaced middle depends on the move detector.
292fn coarse_replace(base: &str, new: &str) -> Delta {
293 let base_chars: Vec<char> = base.chars().collect();
294 let new_chars: Vec<char> = new.chars().collect();
295 let max_common = base_chars.len().min(new_chars.len());
296
297 let mut prefix = 0;
298 while prefix < max_common && base_chars[prefix] == new_chars[prefix] {
299 prefix += 1;
300 }
301 let mut suffix = 0;
302 while suffix < max_common - prefix
303 && base_chars[base_chars.len() - 1 - suffix] == new_chars[new_chars.len() - 1 - suffix]
304 {
305 suffix += 1;
306 }
307
308 let mut ops = Vec::new();
309 push_retain(&mut ops, prefix);
310 push_delete(&mut ops, base_chars.len() - prefix - suffix);
311 let inserted: String = new_chars[prefix..new_chars.len() - suffix].iter().collect();
312 push_insert(&mut ops, &inserted);
313 push_retain(&mut ops, suffix);
314 Delta { ops }
315}
316
317fn push_retain(ops: &mut Vec<Op>, n: usize) {
318 if n == 0 {
319 return;
320 }
321 if let Some(Op::Retain(last)) = ops.last_mut() {
322 *last += n;
323 } else {
324 ops.push(Op::Retain(n));
325 }
326}
327
328fn push_delete(ops: &mut Vec<Op>, n: usize) {
329 if n == 0 {
330 return;
331 }
332 if let Some(Op::Delete(last)) = ops.last_mut() {
333 *last += n;
334 } else {
335 ops.push(Op::Delete(n));
336 }
337}
338
339fn push_insert(ops: &mut Vec<Op>, s: &str) {
340 if s.is_empty() {
341 return;
342 }
343 if let Some(Op::Insert(last)) = ops.last_mut() {
344 last.push_str(s);
345 } else {
346 ops.push(Op::Insert(s.to_owned()));
347 }
348}
349
350/// The stale-text writer path: cold-parse `new_markdown`, char-diff it against
351/// `base`, and carry `base`'s identity marks (anchors) forward, rebased through
352/// the diff (re-homing verbatim block moves). The returned content is `new_rt`
353/// (structure/marks/islands from the fresh import) plus the surviving anchors.
354///
355/// Returns the new content and the [`Delta`] used — the text change an editor
356/// bridge can map its own positions through.
357pub fn diff_import(
358 base: &Content,
359 new_markdown: &str,
360) -> Result<(Content, Delta), crate::import::ImportError> {
361 let mut new_rt = crate::import::from_markdown(new_markdown)?;
362 let delta = diff(&base.text, &new_rt.text);
363
364 let base_chars: Vec<char> = base.text.chars().collect();
365 let new_chars: Vec<char> = new_rt.text.chars().collect();
366 let inserted = delta.inserted_spans();
367 for m in &base.marks {
368 // Only identity marks live in the content but not in markdown; formatting
369 // marks are re-derived by the fresh import, so we do not carry them.
370 let MarkKind::Anchor { .. } = &m.kind else {
371 continue;
372 };
373 if let Some((ns, ne)) = rebase_anchor(&delta, &base_chars, &new_chars, &inserted, m) {
374 new_rt.marks.push(Mark {
375 start: ns,
376 end: ne,
377 kind: m.kind.clone(),
378 });
379 }
380 // else: detached — the accepted residual drop.
381 }
382 new_rt.normalize();
383 Ok((new_rt, delta))
384}
385
386/// Rebase one anchor through the delta. Returns its new range, or `None` if it
387/// detaches (its text was deleted and no verbatim move re-homes it).
388fn rebase_anchor(
389 delta: &Delta,
390 base_chars: &[char],
391 new_chars: &[char],
392 inserted: &[(usize, usize)],
393 m: &Mark,
394) -> Option<(usize, usize)> {
395 if m.start == m.end {
396 // Zero-width point anchor.
397 if !delta.is_deleted(m.start) {
398 let p = delta.map_pos(m.start, Assoc::Before);
399 return Some((p, p));
400 }
401 // Its surrounding text was deleted — relocate only if that text was
402 // re-inserted verbatim elsewhere (a move).
403 return relocate_point(base_chars, new_chars, inserted, m.start);
404 }
405
406 let ns = delta.map_pos(m.start, Assoc::After);
407 let ne = delta.map_pos(m.end, Assoc::Before);
408 if ns < ne {
409 return Some((ns, ne)); // survived a surrounding edit
410 }
411 // Collapsed — try a verbatim block move: the annotated span must reappear
412 // inside inserted text (not merely somewhere in the surviving content).
413 relocate_span(base_chars, new_chars, inserted, m.start, m.end)
414}
415
416/// Find the annotated span `base[start..end]` inside an inserted region of the
417/// new text. Requires a length floor and containment in inserted text, so an
418/// unrelated surviving occurrence of the same words cannot capture the anchor.
419fn relocate_span(
420 base_chars: &[char],
421 new_chars: &[char],
422 inserted: &[(usize, usize)],
423 start: usize,
424 end: usize,
425) -> Option<(usize, usize)> {
426 if end > base_chars.len() {
427 return None;
428 }
429 let needle = &base_chars[start..end];
430 find_in_spans(new_chars, needle, inserted).map(|pos| (pos, pos + needle.len()))
431}
432
433/// Relocate a point anchor by its left context (text immediately before it),
434/// but only if that context reappears inside inserted text — the same
435/// move-only, length-floored discipline as [`relocate_span`].
436fn relocate_point(
437 base_chars: &[char],
438 new_chars: &[char],
439 inserted: &[(usize, usize)],
440 pos: usize,
441) -> Option<(usize, usize)> {
442 const K: usize = 24;
443 let l0 = pos.saturating_sub(K);
444 let left = &base_chars[l0..pos];
445 if let Some(p) = find_in_spans(new_chars, left, inserted) {
446 return Some((p + left.len(), p + left.len()));
447 }
448 let r1 = (pos + K).min(base_chars.len());
449 let right = &base_chars[pos..r1];
450 if let Some(p) = find_in_spans(new_chars, right, inserted) {
451 return Some((p, p));
452 }
453 None
454}
455
456/// First index where `needle` occurs in `hay` while *overlapping* an inserted
457/// span — i.e. the match touches text the rewrite actually inserted, not purely
458/// surviving text. Overlap (not full containment) is required because a diff
459/// can split a moved block across an inserted region and the retained
460/// suffix; demanding containment would miss real moves, while demanding overlap
461/// still rejects an unrelated occurrence sitting entirely in retained text.
462/// Enforces [`MIN_MOVE`]. O(hay × needle) naive scan — fine at memo/document
463/// scale; a large-document target would want a substring-search algorithm
464/// (e.g. KMP) here.
465fn find_in_spans(hay: &[char], needle: &[char], spans: &[(usize, usize)]) -> Option<usize> {
466 if needle.len() < MIN_MOVE || needle.len() > hay.len() {
467 return None;
468 }
469 (0..=hay.len() - needle.len()).find(|&i| {
470 &hay[i..i + needle.len()] == needle
471 && spans.iter().any(|&(s, e)| i < e && i + needle.len() > s)
472 })
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478 use crate::import::from_markdown;
479 use crate::model::MarkKind;
480
481 #[test]
482 fn diff_apply_round_trips() {
483 let d = diff("the quick brown fox", "the slow brown fox");
484 assert_eq!(d.apply("the quick brown fox"), "the slow brown fox");
485 }
486
487 #[test]
488 fn map_pos_insertion() {
489 // Insert "XY" at position 3 of "abcdef".
490 let d = diff("abcdef", "abcXYdef");
491 assert_eq!(d.apply("abcdef"), "abcXYdef");
492 // A point before the insert is unmoved; after it shifts by 2.
493 assert_eq!(d.map_pos(2, Assoc::After), 2);
494 assert_eq!(d.map_pos(4, Assoc::Before), 6);
495 }
496
497 #[test]
498 fn try_apply_accepts_short_delta_with_implicit_trailing_retain() {
499 // A bare prepend consumes no base; the untouched remainder is retained
500 // implicitly rather than tripping the base-length check.
501 let short = Delta {
502 ops: vec![Op::Insert("NEW ".into())],
503 };
504 assert_eq!(short.expected_base_len(), 0);
505 assert_eq!(short.try_apply("hello").unwrap(), "NEW hello");
506
507 // An edit near the start, naming only its region, applies against the
508 // whole base — same result whether or not a trailing retain is written.
509 let partial = Delta {
510 ops: vec![Op::Retain(1), Op::Insert("X".into())],
511 };
512 assert_eq!(partial.try_apply("hello").unwrap(), "hXello");
513 }
514
515 #[test]
516 fn try_apply_rejects_over_long_delta() {
517 // Consuming more base than exists is a wrong-revision delta, not an
518 // abbreviated one — it errors, it does not clamp.
519 let over = Delta {
520 ops: vec![Op::Retain(9)],
521 };
522 assert_eq!(
523 over.try_apply("hello"),
524 Err(BaseLengthMismatch {
525 expected: 9,
526 actual: 5,
527 })
528 );
529
530 // Over-consumption via Delete fails the same way.
531 let over_del = Delta {
532 ops: vec![Op::Delete(9)],
533 };
534 assert!(over_del.try_apply("hello").is_err());
535 }
536
537 #[test]
538 #[should_panic]
539 fn apply_panics_on_over_long_delta() {
540 // The trusted-provenance path panics rather than clamping an over-long
541 // delta to silent garbage.
542 let over = Delta {
543 ops: vec![Op::Retain(9)],
544 };
545 let _ = over.apply("hello");
546 }
547
548 #[test]
549 fn anchor_rehomed_on_block_move() {
550 // Two paragraphs; anchor on the first; the rewrite swaps their order.
551 let mut base = from_markdown("first para here\n\nsecond para here").unwrap();
552 // "first para here" is chars 0..15
553 base.marks.push(Mark {
554 start: 0,
555 end: 15,
556 kind: MarkKind::Anchor { id: "c1".into() },
557 });
558 base.normalize();
559 let (new_rt, _) = diff_import(&base, "second para here\n\nfirst para here").unwrap();
560 let anchor = new_rt
561 .marks
562 .iter()
563 .find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
564 .expect("anchor re-homed onto moved block");
565 assert_eq!(
566 new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
567 .to_string(),
568 "first para here"
569 );
570 }
571
572 #[test]
573 fn anchor_dropped_when_text_deleted() {
574 let mut base = from_markdown("keep this and drop that").unwrap();
575 // Anchor on "drop that" (14..23).
576 base.marks.push(Mark {
577 start: 14,
578 end: 23,
579 kind: MarkKind::Anchor { id: "c1".into() },
580 });
581 base.normalize();
582 let (new_rt, _) = diff_import(&base, "keep this").unwrap();
583 assert!(
584 !new_rt
585 .marks
586 .iter()
587 .any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")),
588 "anchor on deleted text detaches (accepted residual)"
589 );
590 }
591
592 #[test]
593 fn anchor_not_rehomed_onto_unrelated_survivor() {
594 // Regression (review finding 5): an anchor on deleted text must NOT
595 // capture an unrelated *surviving* occurrence of the same words.
596 let mut base = from_markdown("target one to drop\n\nkeep the target two").unwrap();
597 base.marks.push(Mark {
598 start: 0,
599 end: 6, // "target" in the first (deleted) paragraph
600 kind: MarkKind::Anchor { id: "c1".into() },
601 });
602 base.normalize();
603 // First paragraph deleted; the second (with its own "target") survives
604 // as retained text — the anchor must drop, not jump to it.
605 let (new_rt, _) = diff_import(&base, "keep the target two").unwrap();
606 assert!(
607 !new_rt
608 .marks
609 .iter()
610 .any(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1")),
611 "anchor wrongly re-homed onto surviving unrelated text"
612 );
613 }
614
615 #[test]
616 fn map_pos_after_moves_past_boundary_insertion() {
617 // Regression (finding 7): a point at a retain|insert boundary with
618 // Assoc::After lands after the inserted text.
619 let d = diff("abcdef", "abcXYdef");
620 assert_eq!(d.map_pos(3, Assoc::After), 5);
621 assert_eq!(d.map_pos(3, Assoc::Before), 3);
622 }
623
624 #[test]
625 fn point_anchor_at_deletion_left_edge_survives() {
626 // Regression (finding 13): the deletion's left edge is not "deleted".
627 let d = diff("abcdef", "abef"); // delete "cd" (span [2,4))
628 assert!(!d.is_deleted(2), "left edge of deletion survives");
629 assert!(d.is_deleted(3), "interior of deletion is deleted");
630 }
631
632 #[test]
633 fn disjoint_edits_are_separate_ops() {
634 // Myers/LCS (char): prefix and suffix edits must not collapse the middle.
635 let d = diff("aaaMIDDLEbbb", "AAAMIDDLEZZZ");
636 assert_eq!(d.apply("aaaMIDDLEbbb"), "AAAMIDDLEZZZ");
637 let retained: usize = d
638 .ops
639 .iter()
640 .filter_map(|op| match op {
641 Op::Retain(n) => Some(*n),
642 _ => None,
643 })
644 .sum();
645 assert!(
646 retained >= 6,
647 "unchanged middle span retained ({retained} USV): {ops:?}",
648 ops = d.ops
649 );
650 assert!(
651 !matches!(d.ops.as_slice(), [Op::Delete(_), Op::Insert(_)]),
652 "coarse single replace: {ops:?}",
653 ops = d.ops
654 );
655 }
656
657 #[test]
658 fn anchor_survives_between_disjoint_edits() {
659 let mut base = from_markdown("aaaMIDDLEbbb").unwrap();
660 base.marks.push(Mark {
661 start: 3,
662 end: 9,
663 kind: MarkKind::Anchor { id: "c1".into() },
664 });
665 base.normalize();
666 let (new_rt, _) = diff_import(&base, "AAAMIDDLEZZZ").unwrap();
667 let anchor = new_rt
668 .marks
669 .iter()
670 .find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
671 .expect("anchor between disjoint edits survives without move detector");
672 assert_eq!(
673 new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
674 .to_string(),
675 "MIDDLE"
676 );
677 }
678
679 fn byte(s: &str, char_idx: usize) -> usize {
680 crate::usv::char_to_byte(s, char_idx)
681 }
682
683 /// Deterministic filler with no long common substring between the two
684 /// variants — worst case for a char-level Myers diff (issue #849).
685 fn filler(n: usize, offset: u8) -> String {
686 (0..n)
687 .map(|i| char::from(b'a' + ((i as u8).wrapping_mul(7).wrapping_add(offset)) % 26))
688 .collect()
689 }
690
691 #[test]
692 fn large_single_line_diff_stays_fast() {
693 // Two long, unrelated single-line strings — exactly the shape
694 // `similar::TextDiff::from_chars` chokes on with no cutoff (issue
695 // #849: 30,000 unrelated chars measured 86s in a debug build). Above
696 // CHAR_DIFF_LIMIT, `diff` must skip Myers and stay far under budget
697 // regardless of input size.
698 let base = format!("PREFIX-{}-BASE-SUFFIX", filler(25_000, 0));
699 let new = format!("PREFIX-{}-NEW-SUFFIX", filler(25_000, 13));
700
701 let start = std::time::Instant::now();
702 let d = diff(&base, &new);
703 let elapsed = start.elapsed();
704 assert!(
705 elapsed < std::time::Duration::from_secs(2),
706 "large single-line diff took {elapsed:?}, expected well under the 2s budget"
707 );
708
709 // Sensible, not just fast: still round-trips exactly.
710 assert_eq!(d.apply(&base), new);
711 }
712
713 #[test]
714 fn large_single_line_diff_retains_common_prefix_and_suffix() {
715 // The coarse fallback must still be *usable* for anchor rebasing,
716 // not merely fast: a shared prefix/suffix around a large rewritten
717 // middle should come back as real Retain ops, not a single
718 // whole-field Delete+Insert that would force every anchor through
719 // the move detector.
720 let base = format!("shared-prefix-{}-shared-suffix", filler(20_000, 0));
721 let new = format!("shared-prefix-{}-shared-suffix", filler(20_000, 5));
722 let d = diff(&base, &new);
723 assert_eq!(d.apply(&base), new);
724
725 let Some(Op::Retain(prefix_len)) = d.ops.first() else {
726 panic!("expected a leading Retain for the shared prefix: {:?}", d.ops);
727 };
728 assert!(
729 *prefix_len >= "shared-prefix-".len(),
730 "shared prefix should be retained, got Retain({prefix_len})"
731 );
732 let Some(Op::Retain(suffix_len)) = d.ops.last() else {
733 panic!("expected a trailing Retain for the shared suffix: {:?}", d.ops);
734 };
735 assert!(
736 *suffix_len >= "shared-suffix".len(),
737 "shared suffix should be retained, got Retain({suffix_len})"
738 );
739 }
740
741 #[test]
742 fn diff_import_large_single_line_rewrite_keeps_prefix_anchor() {
743 // End-to-end through the real DoS-exposed path: `diff_import` is
744 // what a full-document LLM rewrite hits. A large, single-line,
745 // unrelated-middle rewrite must complete quickly *and* still rebase
746 // an anchor sitting in unchanged (shared) text.
747 let base_text = format!("hello target world-{}-end", filler(30_000, 0));
748 let mut base = from_markdown(&base_text).unwrap();
749 base.marks.push(Mark {
750 start: 6,
751 end: 12, // "target"
752 kind: MarkKind::Anchor { id: "c1".into() },
753 });
754 base.normalize();
755
756 let new_markdown = format!("hello target world-{}-end", filler(30_000, 11));
757 let start = std::time::Instant::now();
758 let (new_rt, _delta) = diff_import(&base, &new_markdown).unwrap();
759 let elapsed = start.elapsed();
760 assert!(
761 elapsed < std::time::Duration::from_secs(2),
762 "diff_import took {elapsed:?}, expected well under the 2s budget"
763 );
764
765 let anchor = new_rt
766 .marks
767 .iter()
768 .find(|m| matches!(&m.kind, MarkKind::Anchor { id } if id == "c1"))
769 .expect("anchor in shared prefix survives the coarse fallback diff");
770 assert_eq!(
771 new_rt.text[byte(&new_rt.text, anchor.start)..byte(&new_rt.text, anchor.end)]
772 .to_string(),
773 "target"
774 );
775 }
776}