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